Remove PlayOptions and Report mixins
This commit is contained in:
parent
74e88c26e8
commit
77594351ae
|
@ -1,13 +1,145 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { useCurrentElement } from '@vueuse/core'
|
||||||
|
import jQuery from 'jquery'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
dropdownIconClasses?: string[]
|
||||||
|
playIconClass?: string
|
||||||
|
buttonClasses?: string[]
|
||||||
|
discrete?: boolean
|
||||||
|
dropdownOnly?: boolean
|
||||||
|
iconOnly?: boolean
|
||||||
|
playing?: boolean
|
||||||
|
paused?: boolean
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
|
track?: Track | null
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
tracks: () => [],
|
||||||
|
track: () => null,
|
||||||
|
artist: () => null,
|
||||||
|
playlist: () => null,
|
||||||
|
album: () => null,
|
||||||
|
library: () => null,
|
||||||
|
channel: () => null,
|
||||||
|
account: () => null,
|
||||||
|
dropdownIconClasses: () => ['dropdown'],
|
||||||
|
playIconClass: () => 'play icon',
|
||||||
|
buttonClasses: () => ['button'],
|
||||||
|
discrete: () => false,
|
||||||
|
dropdownOnly: () => false,
|
||||||
|
iconOnly: () => false,
|
||||||
|
isPlayable: () => false,
|
||||||
|
playing: () => false,
|
||||||
|
paused: () => false
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
playable,
|
||||||
|
filterableArtist,
|
||||||
|
filterArtist,
|
||||||
|
enqueue,
|
||||||
|
enqueueNext,
|
||||||
|
replacePlay,
|
||||||
|
isLoading
|
||||||
|
} = usePlayOptions(props)
|
||||||
|
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
|
||||||
|
const clicked = ref(false)
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
playNow: $pgettext('*/Queue/Dropdown/Button/Title', 'Play now'),
|
||||||
|
addToQueue: $pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'),
|
||||||
|
playNext: $pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
|
||||||
|
startRadio: $pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'),
|
||||||
|
report: $pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'),
|
||||||
|
addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…'),
|
||||||
|
hideArtist: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Hide content from this artist'),
|
||||||
|
replacePlay: props.track
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Title', 'Play track')
|
||||||
|
: props.album
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Title', 'Play album')
|
||||||
|
: props.artist
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Title', 'Play artist')
|
||||||
|
: props.playlist
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Title', 'Play playlist')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Title', 'Play tracks')
|
||||||
|
}))
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
if (playable.value) {
|
||||||
|
return $pgettext('*/*/Button.Label/Noun', 'More…')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.track) {
|
||||||
|
return $pgettext('*/Queue/Button/Title', 'This track is not available in any library you have access to')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const el = useCurrentElement()
|
||||||
|
watch(clicked, async () => {
|
||||||
|
await nextTick()
|
||||||
|
// @ts-expect-error dropdown is from semantic ui
|
||||||
|
jQuery(el.value).find('.ui.dropdown').dropdown({
|
||||||
|
selectOnKeydown: false,
|
||||||
|
action (text: unknown, value: unknown, $el: JQuery) {
|
||||||
|
// used to ensure focusing the dropdown and clicking via keyboard
|
||||||
|
// works as expected
|
||||||
|
$el[0].click()
|
||||||
|
|
||||||
|
// @ts-expect-error dropdown is from semantic ui
|
||||||
|
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// @ts-expect-error dropdown is from semantic ui
|
||||||
|
jQuery(el.value).find('.ui.dropdown').dropdown('show', function () {
|
||||||
|
// little magic to ensure the menu is always visible in the viewport
|
||||||
|
// By default, try to diplay it on the right if there is enough room
|
||||||
|
const menu = jQuery(el.value).find('.ui.dropdown').find('.menu')
|
||||||
|
const viewportOffset = menu.get(0)?.getBoundingClientRect() ?? { right: 0, left: 0 }
|
||||||
|
const viewportWidth = document.documentElement.clientWidth
|
||||||
|
const rightOverflow = viewportOffset.right - viewportWidth
|
||||||
|
const leftOverflow = -viewportOffset.left
|
||||||
|
let offset = 0
|
||||||
|
if (rightOverflow > 0) {
|
||||||
|
offset = -rightOverflow - 5
|
||||||
|
menu.css({ cssText: `left: ${offset}px !important;` })
|
||||||
|
} else if (leftOverflow > 0) {
|
||||||
|
offset = leftOverflow + 5
|
||||||
|
menu.css({ cssText: `right: -${offset}px !important;` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:title="title"
|
:title="title"
|
||||||
:class="['ui', {'tiny': discrete}, {'icon': !discrete}, {'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
|
:class="['ui', {'tiny': discrete, 'icon': !discrete, 'buttons': !dropdownOnly && !iconOnly}, 'play-button component-play-button']"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
v-if="!dropdownOnly"
|
v-if="!dropdownOnly"
|
||||||
:disabled="!playable || null"
|
:disabled="!playable"
|
||||||
:aria-label="labels.replacePlay"
|
:aria-label="labels.replacePlay"
|
||||||
:class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])"
|
:class="[...buttonClasses, 'ui', {loading: isLoading, 'mini': discrete, disabled: !playable}]"
|
||||||
@click.stop.prevent="replacePlay"
|
@click.stop.prevent="replacePlay"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
|
@ -34,61 +166,58 @@
|
||||||
class="menu"
|
class="menu"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
ref="add"
|
|
||||||
class="item basic"
|
class="item basic"
|
||||||
data-ref="add"
|
data-ref="enqueue"
|
||||||
:disabled="!playable || null"
|
:disabled="!playable"
|
||||||
:title="labels.addToQueue"
|
:title="labels.addToQueue"
|
||||||
@click.stop.prevent="add"
|
@click.stop.prevent="enqueue"
|
||||||
>
|
>
|
||||||
<i class="plus icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
|
<i class="plus icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
ref="addNext"
|
|
||||||
class="item basic"
|
class="item basic"
|
||||||
data-ref="addNext"
|
data-ref="enqueueNext"
|
||||||
:disabled="!playable || null"
|
:disabled="!playable"
|
||||||
:title="labels.playNext"
|
:title="labels.playNext"
|
||||||
@click.stop.prevent="addNext()"
|
@click.stop.prevent="enqueueNext()"
|
||||||
>
|
>
|
||||||
<i class="step forward icon" />{{ labels.playNext }}
|
<i class="step forward icon" />{{ labels.playNext }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
ref="playNow"
|
|
||||||
class="item basic"
|
class="item basic"
|
||||||
data-ref="playNow"
|
data-ref="playNow"
|
||||||
:disabled="!playable || null"
|
:disabled="!playable"
|
||||||
:title="labels.playNow"
|
:title="labels.playNow"
|
||||||
@click.stop.prevent="addNext(true)"
|
@click.stop.prevent="enqueueNext(true)"
|
||||||
>
|
>
|
||||||
<i class="play icon" />{{ labels.playNow }}
|
<i class="play icon" />{{ labels.playNow }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="track"
|
v-if="track"
|
||||||
class="item basic"
|
class="item basic"
|
||||||
:disabled="!playable || null"
|
:disabled="!playable"
|
||||||
:title="labels.startRadio"
|
:title="labels.startRadio"
|
||||||
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})"
|
@click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track?.id})"
|
||||||
>
|
>
|
||||||
<i class="feed icon" /><translate translate-context="*/Queue/Button.Label/Short, Verb">Play radio</translate>
|
<i class="feed icon" /><translate translate-context="*/Queue/Button.Label/Short, Verb">Play radio</translate>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="track"
|
v-if="track"
|
||||||
class="item basic"
|
class="item basic"
|
||||||
:disabled="!playable || null"
|
:disabled="!playable"
|
||||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||||
>
|
>
|
||||||
<i class="list icon" />
|
<i class="list icon" />
|
||||||
<translate translate-context="Sidebar/Player/Icon.Tooltip/Verb">Add to playlist…</translate>
|
<translate translate-context="Sidebar/Player/Icon.Tooltip/Verb">Add to playlist…</translate>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="track && !onTrackPage"
|
v-if="track && $route.name !== 'library.tracks.detail'"
|
||||||
class="item basic"
|
class="item basic"
|
||||||
@click.stop.prevent="$router.push(`/library/tracks/${track.id}/`)"
|
@click.stop.prevent="$router.push(`/library/tracks/${track?.id}/`)"
|
||||||
>
|
>
|
||||||
<i class="info icon" />
|
<i class="info icon" />
|
||||||
<translate
|
<translate
|
||||||
v-if="track.artist.content_category === 'podcast'"
|
v-if="track.artist?.content_category === 'podcast'"
|
||||||
translate-context="*/Queue/Dropdown/Button/Label/Short"
|
translate-context="*/Queue/Dropdown/Button/Label/Short"
|
||||||
>Episode details</translate>
|
>Episode details</translate>
|
||||||
<translate
|
<translate
|
||||||
|
@ -99,22 +228,21 @@
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<button
|
<button
|
||||||
v-if="filterableArtist"
|
v-if="filterableArtist"
|
||||||
ref="filterArtist"
|
|
||||||
data-ref="filterArtist"
|
data-ref="filterArtist"
|
||||||
class="item basic"
|
class="item basic"
|
||||||
:disabled="!filterableArtist || null"
|
:disabled="!filterableArtist"
|
||||||
:title="labels.hideArtist"
|
:title="labels.hideArtist"
|
||||||
@click.stop.prevent="filterArtist"
|
@click.stop.prevent="filterArtist"
|
||||||
>
|
>
|
||||||
<i class="eye slash outline icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Hide content from this artist</translate>
|
<i class="eye slash outline icon" />
|
||||||
|
{{ labels.hideArtist }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="obj in getReportableObjs({track, album, artist, playlist, account, channel})"
|
v-for="obj in getReportableObjects({track, album, artist, playlist, account, channel})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
:ref="`report${obj.target.type}${obj.target.id}`"
|
|
||||||
class="item basic"
|
class="item basic"
|
||||||
:data-ref="`report${obj.target.type}${obj.target.id}`"
|
:data-ref="`report${obj.target.type}${obj.target.id}`"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -122,117 +250,3 @@
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import jQuery from 'jquery'
|
|
||||||
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
import PlayOptionsMixin from '~/components/mixins/PlayOptions.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mixins: [ReportMixin, PlayOptionsMixin],
|
|
||||||
props: {
|
|
||||||
// we can either have a single or multiple tracks to play when clicked
|
|
||||||
tracks: { type: Array, required: false, default: () => { return null } },
|
|
||||||
track: { type: Object, required: false, default: () => { return null } },
|
|
||||||
account: { type: Object, required: false, default: () => { return null } },
|
|
||||||
dropdownIconClasses: { type: Array, required: false, default: () => { return ['dropdown'] } },
|
|
||||||
playIconClass: { type: String, required: false, default: 'play icon' },
|
|
||||||
buttonClasses: { type: Array, required: false, default: () => { return ['button'] } },
|
|
||||||
playlist: { type: Object, required: false, default: () => { return null } },
|
|
||||||
discrete: { type: Boolean, default: false },
|
|
||||||
dropdownOnly: { type: Boolean, default: false },
|
|
||||||
iconOnly: { type: Boolean, default: false },
|
|
||||||
artist: { type: Object, required: false, default: () => { return null } },
|
|
||||||
album: { type: Object, required: false, default: () => { return null } },
|
|
||||||
library: { type: Object, required: false, default: () => { return null } },
|
|
||||||
channel: { type: Object, required: false, default: () => { return null } },
|
|
||||||
isPlayable: { type: Boolean, required: false, default: null },
|
|
||||||
playing: { type: Boolean, required: false, default: false },
|
|
||||||
paused: { type: Boolean, required: false, default: false }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isLoading: false,
|
|
||||||
clicked: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
labels () {
|
|
||||||
let replacePlay
|
|
||||||
if (this.track) {
|
|
||||||
replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play track')
|
|
||||||
} else if (this.album) {
|
|
||||||
replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play album')
|
|
||||||
} else if (this.artist) {
|
|
||||||
replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play artist')
|
|
||||||
} else if (this.playlist) {
|
|
||||||
replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play playlist')
|
|
||||||
} else {
|
|
||||||
replacePlay = this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play tracks')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'),
|
|
||||||
addToQueue: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'),
|
|
||||||
playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
|
|
||||||
startRadio: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs'),
|
|
||||||
report: this.$pgettext('*/Moderation/*/Button/Label,Verb', 'Report…'),
|
|
||||||
addToPlaylist: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…'),
|
|
||||||
replacePlay
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title () {
|
|
||||||
if (this.playable) {
|
|
||||||
return this.$pgettext('*/*/Button.Label/Noun', 'More…')
|
|
||||||
} else {
|
|
||||||
if (this.track) {
|
|
||||||
return this.$pgettext('*/Queue/Button/Title', 'This track is not available in any library you have access to')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
onTrackPage () {
|
|
||||||
return this.$router.currentRoute.value.name === 'library.tracks.detail'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
clicked () {
|
|
||||||
const self = this
|
|
||||||
this.$nextTick(() => {
|
|
||||||
jQuery(this.$el).find('.ui.dropdown').dropdown({
|
|
||||||
selectOnKeydown: false,
|
|
||||||
action: function (text, value, $el) {
|
|
||||||
// used to ensure focusing the dropdown and clicking via keyboard
|
|
||||||
// works as expected
|
|
||||||
const button = self.$refs[$el.data('ref')]
|
|
||||||
if (Array.isArray(button)) {
|
|
||||||
button[0].click()
|
|
||||||
} else {
|
|
||||||
button.click()
|
|
||||||
}
|
|
||||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
jQuery(this.$el).find('.ui.dropdown').dropdown('show', function () {
|
|
||||||
// little magic to ensure the menu is always visible in the viewport
|
|
||||||
// By default, try to diplay it on the right if there is enough room
|
|
||||||
const menu = jQuery(self.$el).find('.ui.dropdown').find('.menu')
|
|
||||||
const viewportOffset = menu.get(0).getBoundingClientRect()
|
|
||||||
const viewportWidth = document.documentElement.clientWidth
|
|
||||||
const rightOverflow = viewportOffset.right - viewportWidth
|
|
||||||
const leftOverflow = -viewportOffset.left
|
|
||||||
let offset = 0
|
|
||||||
if (rightOverflow > 0) {
|
|
||||||
offset = -rightOverflow - 5
|
|
||||||
menu.css({ cssText: `left: ${offset}px !important;` })
|
|
||||||
} else if (leftOverflow > 0) {
|
|
||||||
offset = leftOverflow + 5
|
|
||||||
menu.css({ cssText: `right: -${offset}px !important;` })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Album } from '~/types'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import { momentFormat } from '~/utils/filters'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: Album
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const imageUrl = computed(() => props.album.cover?.urls.original
|
||||||
|
? store.getters['instance/absoluteUrl'](props.album.cover.urls.medium_square_crop)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card app-card component-album-card">
|
<div class="card app-card component-album-card">
|
||||||
<router-link
|
<router-link
|
||||||
|
@ -37,7 +57,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="extra content">
|
<div class="extra content">
|
||||||
<span v-if="album.release_date">{{ momentFormat(album.release_date, 'Y') }} · </span>
|
<span v-if="album.release_date">{{ momentFormat(new Date(album.release_date), 'Y') }} · </span>
|
||||||
<translate
|
<translate
|
||||||
translate-context="*/*/*"
|
translate-context="*/*/*"
|
||||||
:translate-params="{count: album.tracks_count}"
|
:translate-params="{count: album.tracks_count}"
|
||||||
|
@ -56,28 +76,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import { momentFormat } from '~/utils/filters'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
PlayButton
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
album: { type: Object, required: true }
|
|
||||||
},
|
|
||||||
setup () {
|
|
||||||
return { momentFormat }
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
imageUrl () {
|
|
||||||
if (this.album.cover && this.album.cover.urls.original) {
|
|
||||||
return this.$store.getters['instance/absoluteUrl'](this.album.cover.urls.medium_square_crop)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
// import { Track } from '~/types'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
|
import TrackModal from '~/components/audio/track/Modal.vue'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
|
import usePlayer from '~/composables/audio/usePlayer'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
track: Track
|
||||||
|
index: number
|
||||||
|
|
||||||
|
showArt?: boolean
|
||||||
|
isArtist?: boolean
|
||||||
|
isAlbum?: boolean
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showArt: true,
|
||||||
|
isArtist: false,
|
||||||
|
isAlbum: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showTrackModal = ref(false)
|
||||||
|
|
||||||
|
const { currentTrack } = useQueue()
|
||||||
|
const { playing } = usePlayer()
|
||||||
|
const { activateTrack } = usePlayOptions(props)
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const actionsButtonLabel = computed(() => $pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions'))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
|
@ -11,38 +57,20 @@
|
||||||
@click.prevent.exact="activateTrack(track, index)"
|
@click.prevent.exact="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="
|
v-if="track.album?.cover?.urls.original"
|
||||||
track.album && track.album.cover && track.album.cover.urls.original
|
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.album.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="track.cover"
|
||||||
track.cover
|
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="track.artist?.cover"
|
||||||
track.artist.cover
|
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) "
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.artist.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -63,13 +91,13 @@
|
||||||
:class="[
|
:class="[
|
||||||
'track-title',
|
'track-title',
|
||||||
'mobile',
|
'mobile',
|
||||||
{ 'play-indicator': isPlaying && currentTrack && track.id === currentTrack.id },
|
{ 'play-indicator': playing && track.id === currentTrack?.id },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="track.artist.content_category === 'podcast'"
|
v-if="track.artist?.content_category === 'podcast'"
|
||||||
class="track-meta mobile"
|
class="track-meta mobile"
|
||||||
>
|
>
|
||||||
<human-date
|
<human-date
|
||||||
|
@ -86,7 +114,7 @@
|
||||||
v-else
|
v-else
|
||||||
class="track-meta mobile"
|
class="track-meta mobile"
|
||||||
>
|
>
|
||||||
{{ track.artist.name }} <span>·</span>
|
{{ track.artist?.name }} <span>·</span>
|
||||||
<human-duration
|
<human-duration
|
||||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
v-if="track.uploads[0] && track.uploads[0].duration"
|
||||||
:duration="track.uploads[0].duration"
|
:duration="track.uploads[0].duration"
|
||||||
|
@ -94,7 +122,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'"
|
v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'"
|
||||||
:class="[
|
:class="[
|
||||||
'meta',
|
'meta',
|
||||||
'right',
|
'right',
|
||||||
|
@ -135,67 +163,3 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapActions, mapGetters } from 'vuex'
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|
||||||
import TrackModal from '~/components/audio/track/Modal.vue'
|
|
||||||
import PlayOptionsMixin from '~/components/mixins/PlayOptions.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
|
|
||||||
components: {
|
|
||||||
TrackFavoriteIcon,
|
|
||||||
TrackModal
|
|
||||||
},
|
|
||||||
mixins: [PlayOptionsMixin],
|
|
||||||
props: {
|
|
||||||
tracks: { type: Array, required: true },
|
|
||||||
showAlbum: { type: Boolean, required: false, default: true },
|
|
||||||
showArtist: { type: Boolean, required: false, default: true },
|
|
||||||
showPosition: { type: Boolean, required: false, default: false },
|
|
||||||
showArt: { type: Boolean, required: false, default: true },
|
|
||||||
search: { type: Boolean, required: false, default: false },
|
|
||||||
filters: { type: Object, required: false, default: null },
|
|
||||||
nextUrl: { type: String, required: false, default: null },
|
|
||||||
displayActions: { type: Boolean, required: false, default: true },
|
|
||||||
showDuration: { type: Boolean, required: false, default: true },
|
|
||||||
index: { type: Number, required: true },
|
|
||||||
track: { type: Object, required: true },
|
|
||||||
isArtist: { type: Boolean, required: false, default: false },
|
|
||||||
isAlbum: { type: Boolean, required: false, default: false }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
showTrackModal: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
currentTrack: 'queue/currentTrack'
|
|
||||||
}),
|
|
||||||
|
|
||||||
isPlaying () {
|
|
||||||
return this.$store.state.player.playing
|
|
||||||
},
|
|
||||||
actionsButtonLabel () {
|
|
||||||
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
prettyPosition (position, size) {
|
|
||||||
let s = String(position)
|
|
||||||
while (s.length < (size || 2)) {
|
|
||||||
s = '0' + s
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
},
|
|
||||||
|
|
||||||
...mapActions({
|
|
||||||
resumePlayback: 'player/resumePlayback',
|
|
||||||
pausePlayback: 'player/pausePlayback'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,7 +1,83 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
// import { Track } from '~/types'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
track: Track
|
||||||
|
index: number
|
||||||
|
show: boolean
|
||||||
|
|
||||||
|
isArtist?: boolean
|
||||||
|
isAlbum?: boolean
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isArtist: false,
|
||||||
|
isAlbum: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
const show = useVModel(props, 'show', emit)
|
||||||
|
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
const { enqueue, enqueueNext } = usePlayOptions(props)
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const favoriteButton = computed(() => isFavorite.value
|
||||||
|
? $pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites')
|
||||||
|
: $pgettext('Content/Track/*/Verb', 'Add to favorites')
|
||||||
|
)
|
||||||
|
|
||||||
|
const trackDetailsButton = computed(() => props.track.artist?.content_category === 'podcast'
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details')
|
||||||
|
)
|
||||||
|
|
||||||
|
const albumDetailsButton = computed(() => props.track.artist?.content_category === 'podcast'
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album')
|
||||||
|
)
|
||||||
|
|
||||||
|
const artistDetailsButton = computed(() => props.track.artist?.content_category === 'podcast'
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist')
|
||||||
|
)
|
||||||
|
|
||||||
|
const labels = computed(() => ({
|
||||||
|
startRadio: $pgettext('*/Queue/Dropdown/Button/Title', 'Play radio'),
|
||||||
|
playNow: $pgettext('*/Queue/Dropdown/Button/Title', 'Play now'),
|
||||||
|
addToQueue: $pgettext('*/Queue/Dropdown/Button/Title', 'Add to queue'),
|
||||||
|
playNext: $pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
|
||||||
|
addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…')
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<modal
|
<semantic-modal
|
||||||
ref="modal"
|
ref="modal"
|
||||||
v-model:show="showRef"
|
v-model:show="show"
|
||||||
:scrolling="true"
|
:scrolling="true"
|
||||||
:additional-classes="['scrolling-track-options']"
|
:additional-classes="['scrolling-track-options']"
|
||||||
>
|
>
|
||||||
|
@ -28,7 +104,7 @@
|
||||||
class="ui centered image"
|
class="ui centered image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.artist.cover"
|
v-else-if="track.artist?.cover"
|
||||||
v-lazy="
|
v-lazy="
|
||||||
$store.getters['instance/absoluteUrl'](
|
$store.getters['instance/absoluteUrl'](
|
||||||
track.artist.cover.urls.medium_square_crop
|
track.artist.cover.urls.medium_square_crop
|
||||||
|
@ -48,14 +124,14 @@
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<h4 class="track-modal-subtitle">
|
<h4 class="track-modal-subtitle">
|
||||||
{{ track.artist.name }}
|
{{ track.artist?.name }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="ui one column unstackable grid">
|
<div class="ui one column unstackable grid">
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'"
|
v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -85,8 +161,8 @@
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.addToQueue"
|
:aria-label="labels.addToQueue"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
add();
|
enqueue();
|
||||||
$refs.modal.closeModal();
|
modal.closeModal();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="plus icon track-modal list-icon" />
|
<i class="plus icon track-modal list-icon" />
|
||||||
|
@ -99,8 +175,8 @@
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.playNext"
|
:aria-label="labels.playNext"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
addNext(true);
|
enqueueNext(true);
|
||||||
$refs.modal.closeModal();
|
modal.closeModal();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="step forward icon track-modal list-icon" />
|
<i class="step forward icon track-modal list-icon" />
|
||||||
|
@ -117,7 +193,7 @@
|
||||||
type: 'similar',
|
type: 'similar',
|
||||||
objectId: track.id,
|
objectId: track.id,
|
||||||
});
|
});
|
||||||
$refs.modal.closeModal();
|
modal.closeModal();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="rss icon track-modal list-icon" />
|
<i class="rss icon track-modal list-icon" />
|
||||||
|
@ -149,7 +225,7 @@
|
||||||
@click.prevent.exact="
|
@click.prevent.exact="
|
||||||
$router.push({
|
$router.push({
|
||||||
name: 'library.albums.detail',
|
name: 'library.albums.detail',
|
||||||
params: { id: track.album.id },
|
params: { id: track.album?.id },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
@ -170,7 +246,7 @@
|
||||||
@click.prevent.exact="
|
@click.prevent.exact="
|
||||||
$router.push({
|
$router.push({
|
||||||
name: 'library.artists.detail',
|
name: 'library.artists.detail',
|
||||||
params: { id: track.artist.id },
|
params: { id: track.artist?.id },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
@ -200,16 +276,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui divider" />
|
<div class="ui divider" />
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({
|
v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })"
|
||||||
track,
|
|
||||||
album,
|
|
||||||
artist,
|
|
||||||
})"
|
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
:ref="`report${obj.target.type}${obj.target.id}`"
|
:ref="`report${obj.target.type}${obj.target.id}`"
|
||||||
class="row"
|
class="row"
|
||||||
:data-ref="`report${obj.target.type}${obj.target.id}`"
|
:data-ref="`report${obj.target.type}${obj.target.id}`"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<i class="share icon track-modal list-icon" /><span
|
<i class="share icon track-modal list-icon" /><span
|
||||||
|
@ -219,93 +291,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modal>
|
</semantic-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import Modal from '~/components/semantic/Modal.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
import PlayOptionsMixin from '~/components/mixins/PlayOptions.vue'
|
|
||||||
import { useVModel } from '@vueuse/core'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
Modal
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin, PlayOptionsMixin],
|
|
||||||
props: {
|
|
||||||
show: { type: Boolean, required: true, default: false },
|
|
||||||
track: { type: Object, required: true },
|
|
||||||
index: { type: Number, required: true },
|
|
||||||
isArtist: { type: Boolean, required: false, default: false },
|
|
||||||
isAlbum: { type: Boolean, required: false, default: false }
|
|
||||||
},
|
|
||||||
setup (props) {
|
|
||||||
// TODO (wvffle): Add defineEmits when rewriting to <script setup>
|
|
||||||
const showRef = useVModel(props, 'show'/*, emit */)
|
|
||||||
return { showRef }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isShowing: this.show,
|
|
||||||
tracks: [this.track],
|
|
||||||
album: this.track.album,
|
|
||||||
artist: this.track.artist
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isFavorite () {
|
|
||||||
return this.$store.getters['favorites/isFavorite'](this.track.id)
|
|
||||||
},
|
|
||||||
favoriteButton () {
|
|
||||||
if (this.isFavorite) {
|
|
||||||
return this.$pgettext(
|
|
||||||
'Content/Track/Icon.Tooltip/Verb',
|
|
||||||
'Remove from favorites'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('Content/Track/*/Verb', 'Add to favorites')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trackDetailsButton () {
|
|
||||||
if (this.track.artist.content_category === 'podcast') {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details')
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
albumDetailsButton () {
|
|
||||||
if (this.track.artist.content_category === 'podcast') {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series')
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
artistDetailsButton () {
|
|
||||||
if (this.track.artist.content_category === 'podcast') {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel')
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
startRadio: this.$pgettext(
|
|
||||||
'*/Queue/Dropdown/Button/Title',
|
|
||||||
'Play radio'
|
|
||||||
),
|
|
||||||
playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'),
|
|
||||||
addToQueue: this.$pgettext(
|
|
||||||
'*/Queue/Dropdown/Button/Title',
|
|
||||||
'Add to queue'
|
|
||||||
),
|
|
||||||
playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
|
|
||||||
addToPlaylist: this.$pgettext(
|
|
||||||
'Sidebar/Player/Icon.Tooltip/Verb',
|
|
||||||
'Add to playlist…'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,11 +1,61 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
import { /* Track, */ Cover } from '~/types'
|
||||||
|
import axios from 'axios'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
tracks: Track[]
|
||||||
|
track: Track
|
||||||
|
index: number
|
||||||
|
|
||||||
|
showArt?: boolean
|
||||||
|
displayActions?: boolean
|
||||||
|
defaultCover?: Cover | null
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showArt: true,
|
||||||
|
displayActions: true,
|
||||||
|
defaultCover: () => null
|
||||||
|
})
|
||||||
|
|
||||||
|
const description = ref('')
|
||||||
|
|
||||||
|
const { currentTrack } = useQueue()
|
||||||
|
const { activateTrack } = usePlayOptions(props)
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`tracks/${props.track.id}/`)
|
||||||
|
description.value = response.data.description.text
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Let the <Suspense> take care of showing the loader
|
||||||
|
await fetchData()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
{ active: currentTrack && track.id === currentTrack.id },
|
{ active: currentTrack && track.id === currentTrack.id },
|
||||||
'track-row podcast row',
|
'track-row podcast row',
|
||||||
]"
|
]"
|
||||||
@mouseover="hover = track.id"
|
|
||||||
@mouseleave="hover = null"
|
|
||||||
@dblclick="activateTrack(track, index)"
|
@dblclick="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -15,26 +65,14 @@
|
||||||
@click.prevent.exact="activateTrack(track, index)"
|
@click.prevent.exact="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="
|
v-if="track.cover?.urls.original "
|
||||||
track.cover && track.cover.urls.original
|
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="defaultCover"
|
||||||
defaultCover
|
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
defaultCover.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -57,7 +95,7 @@
|
||||||
v-if="description"
|
v-if="description"
|
||||||
class="podcast-episode-meta"
|
class="podcast-episode-meta"
|
||||||
>
|
>
|
||||||
{{ description.text }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -79,86 +117,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import { mapActions, mapGetters } from 'vuex'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import PlayOptions from '~/components/mixins/PlayOptions.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
|
|
||||||
components: {
|
|
||||||
PlayButton
|
|
||||||
},
|
|
||||||
mixins: [PlayOptions],
|
|
||||||
props: {
|
|
||||||
tracks: { type: Array, required: true },
|
|
||||||
showAlbum: { type: Boolean, required: false, default: true },
|
|
||||||
showArtist: { type: Boolean, required: false, default: true },
|
|
||||||
showPosition: { type: Boolean, required: false, default: false },
|
|
||||||
showArt: { type: Boolean, required: false, default: true },
|
|
||||||
search: { type: Boolean, required: false, default: false },
|
|
||||||
filters: { type: Object, required: false, default: null },
|
|
||||||
nextUrl: { type: String, required: false, default: null },
|
|
||||||
displayActions: { type: Boolean, required: false, default: true },
|
|
||||||
showDuration: { type: Boolean, required: false, default: true },
|
|
||||||
index: { type: Number, required: true },
|
|
||||||
track: { type: Object, required: true },
|
|
||||||
defaultCover: { type: Object, required: false, default: null }
|
|
||||||
},
|
|
||||||
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
hover: null,
|
|
||||||
errors: null,
|
|
||||||
description: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
currentTrack: 'queue/currentTrack'
|
|
||||||
}),
|
|
||||||
|
|
||||||
isPlaying () {
|
|
||||||
return this.$store.state.player.playing
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
created () {
|
|
||||||
this.fetchData('tracks/' + this.track.id + '/')
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async fetchData (url) {
|
|
||||||
if (!url) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.isLoading = true
|
|
||||||
const self = this
|
|
||||||
try {
|
|
||||||
const channelsPromise = await axios.get(url)
|
|
||||||
self.description = channelsPromise.data.description
|
|
||||||
self.isLoading = false
|
|
||||||
} catch (e) {
|
|
||||||
self.isLoading = false
|
|
||||||
self.errors = e.backendErrors
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
prettyPosition (position, size) {
|
|
||||||
let s = String(position)
|
|
||||||
while (s.length < (size || 2)) {
|
|
||||||
s = '0' + s
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
},
|
|
||||||
|
|
||||||
...mapActions({
|
|
||||||
resumePlayback: 'player/resumePlayback',
|
|
||||||
pausePlayback: 'player/pausePlayback'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
// import { Track } from '~/types'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
|
import TrackModal from '~/components/audio/track/Modal.vue'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
|
import usePlayer from '~/composables/audio/usePlayer'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
track: Track
|
||||||
|
index: number
|
||||||
|
|
||||||
|
showArt?: boolean
|
||||||
|
isArtist?: boolean
|
||||||
|
isAlbum?: boolean
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showArt: true,
|
||||||
|
isArtist: false,
|
||||||
|
isAlbum: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showTrackModal = ref(false)
|
||||||
|
|
||||||
|
const { currentTrack } = useQueue()
|
||||||
|
const { playing } = usePlayer()
|
||||||
|
const { activateTrack } = usePlayOptions(props)
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const actionsButtonLabel = computed(() => $pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions'))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
|
@ -11,38 +57,20 @@
|
||||||
@click.prevent.exact="activateTrack(track, index)"
|
@click.prevent.exact="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="
|
v-if="track.album?.cover?.urls.original"
|
||||||
track.album && track.album.cover && track.album.cover.urls.original
|
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.album.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="track.cover"
|
||||||
track.cover
|
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="track.artist?.cover"
|
||||||
track.artist.cover
|
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.artist.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -63,13 +91,13 @@
|
||||||
:class="[
|
:class="[
|
||||||
'track-title',
|
'track-title',
|
||||||
'mobile',
|
'mobile',
|
||||||
{ 'play-indicator': isPlaying && currentTrack && track.id === currentTrack.id },
|
{ 'play-indicator': playing && track.id === currentTrack?.id },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</p>
|
</p>
|
||||||
<p class="track-meta mobile">
|
<p class="track-meta mobile">
|
||||||
{{ track.artist.name }} <span>·</span>
|
{{ track.artist?.name }} <span>·</span>
|
||||||
<human-duration
|
<human-duration
|
||||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
v-if="track.uploads[0] && track.uploads[0].duration"
|
||||||
:duration="track.uploads[0].duration"
|
:duration="track.uploads[0].duration"
|
||||||
|
@ -118,67 +146,3 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapActions, mapGetters } from 'vuex'
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|
||||||
import TrackModal from '~/components/audio/track/Modal.vue'
|
|
||||||
import PlayOptionsMixin from '~/components/mixins/PlayOptions.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
|
|
||||||
components: {
|
|
||||||
TrackFavoriteIcon,
|
|
||||||
TrackModal
|
|
||||||
},
|
|
||||||
mixins: [PlayOptionsMixin],
|
|
||||||
props: {
|
|
||||||
tracks: { type: Array, required: true },
|
|
||||||
showAlbum: { type: Boolean, required: false, default: true },
|
|
||||||
showArtist: { type: Boolean, required: false, default: true },
|
|
||||||
showPosition: { type: Boolean, required: false, default: false },
|
|
||||||
showArt: { type: Boolean, required: false, default: true },
|
|
||||||
search: { type: Boolean, required: false, default: false },
|
|
||||||
filters: { type: Object, required: false, default: null },
|
|
||||||
nextUrl: { type: String, required: false, default: null },
|
|
||||||
displayActions: { type: Boolean, required: false, default: true },
|
|
||||||
showDuration: { type: Boolean, required: false, default: true },
|
|
||||||
index: { type: Number, required: true },
|
|
||||||
track: { type: Object, required: true },
|
|
||||||
isArtist: { type: Boolean, required: false, default: false },
|
|
||||||
isAlbum: { type: Boolean, required: false, default: false }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
showTrackModal: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
currentTrack: 'queue/currentTrack'
|
|
||||||
}),
|
|
||||||
|
|
||||||
isPlaying () {
|
|
||||||
return this.$store.state.player.playing
|
|
||||||
},
|
|
||||||
actionsButtonLabel () {
|
|
||||||
return this.$pgettext('Content/Track/Icon.Tooltip/Verb', 'Show track actions')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
prettyPosition (position, size) {
|
|
||||||
let s = String(position)
|
|
||||||
while (s.length < (size || 2)) {
|
|
||||||
s = '0' + s
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
},
|
|
||||||
|
|
||||||
...mapActions({
|
|
||||||
resumePlayback: 'player/resumePlayback',
|
|
||||||
pausePlayback: 'player/pausePlayback'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,7 +1,83 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
// import { Track } from '~/types'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import SemanticModal from '~/components/semantic/Modal.vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { useVModel } from '@vueuse/core'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
track: Track
|
||||||
|
index: number
|
||||||
|
show: boolean
|
||||||
|
|
||||||
|
isArtist?: boolean
|
||||||
|
isAlbum?: boolean
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isArtist: false,
|
||||||
|
isAlbum: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const modal = ref()
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
const show = useVModel(props, 'show', emit)
|
||||||
|
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
const { enqueue, enqueueNext } = usePlayOptions(props)
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const isFavorite = computed(() => store.getters['favorites/isFavorite'](props.track.id))
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const favoriteButton = computed(() => isFavorite.value
|
||||||
|
? $pgettext('Content/Track/Icon.Tooltip/Verb', 'Remove from favorites')
|
||||||
|
: $pgettext('Content/Track/*/Verb', 'Add to favorites')
|
||||||
|
)
|
||||||
|
|
||||||
|
const trackDetailsButton = computed(() => props.track.artist?.content_category === 'podcast'
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details')
|
||||||
|
)
|
||||||
|
|
||||||
|
const albumDetailsButton = computed(() => props.track.artist?.content_category === 'podcast'
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album')
|
||||||
|
)
|
||||||
|
|
||||||
|
const artistDetailsButton = computed(() => props.track.artist?.content_category === 'podcast'
|
||||||
|
? $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel')
|
||||||
|
: $pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist')
|
||||||
|
)
|
||||||
|
|
||||||
|
const labels = computed(() => ({
|
||||||
|
startRadio: $pgettext('*/Queue/Dropdown/Button/Title', 'Play radio'),
|
||||||
|
playNow: $pgettext('*/Queue/Dropdown/Button/Title', 'Play now'),
|
||||||
|
addToQueue: $pgettext('*/Queue/Dropdown/Button/Title', 'Add to queue'),
|
||||||
|
playNext: $pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
|
||||||
|
addToPlaylist: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Add to playlist…')
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<modal
|
<semantic-modal
|
||||||
ref="modal"
|
ref="modal"
|
||||||
v-model:show="showRef"
|
v-model:show="show"
|
||||||
:scrolling="true"
|
:scrolling="true"
|
||||||
:additional-classes="['scrolling-track-options']"
|
:additional-classes="['scrolling-track-options']"
|
||||||
>
|
>
|
||||||
|
@ -30,12 +106,8 @@
|
||||||
class="ui centered image"
|
class="ui centered image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.artist.cover"
|
v-else-if="track.artist?.cover"
|
||||||
v-lazy="
|
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)"
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.artist.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui centered image"
|
class="ui centered image"
|
||||||
>
|
>
|
||||||
|
@ -50,14 +122,14 @@
|
||||||
{{ track.title }}
|
{{ track.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<h4 class="track-modal-subtitle">
|
<h4 class="track-modal-subtitle">
|
||||||
{{ track.artist.name }}
|
{{ track.artist?.name }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="ui one column unstackable grid">
|
<div class="ui one column unstackable grid">
|
||||||
<div
|
<div
|
||||||
v-if="$store.state.auth.authenticated && track.artist.content_category !== 'podcast'"
|
v-if="$store.state.auth.authenticated && track.artist?.content_category !== 'podcast'"
|
||||||
class="row"
|
class="row"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
@ -87,8 +159,8 @@
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.addToQueue"
|
:aria-label="labels.addToQueue"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
add();
|
enqueue();
|
||||||
$refs.modal.closeModal();
|
modal.closeModal();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="plus icon track-modal list-icon" />
|
<i class="plus icon track-modal list-icon" />
|
||||||
|
@ -101,8 +173,8 @@
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="labels.playNext"
|
:aria-label="labels.playNext"
|
||||||
@click.stop.prevent="
|
@click.stop.prevent="
|
||||||
addNext(true);
|
enqueueNext(true);
|
||||||
$refs.modal.closeModal();
|
modal.closeModal();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="step forward icon track-modal list-icon" />
|
<i class="step forward icon track-modal list-icon" />
|
||||||
|
@ -119,7 +191,7 @@
|
||||||
type: 'similar',
|
type: 'similar',
|
||||||
objectId: track.id,
|
objectId: track.id,
|
||||||
});
|
});
|
||||||
$refs.modal.closeModal();
|
modal.closeModal();
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<i class="rss icon track-modal list-icon" />
|
<i class="rss icon track-modal list-icon" />
|
||||||
|
@ -151,7 +223,7 @@
|
||||||
@click.prevent.exact="
|
@click.prevent.exact="
|
||||||
$router.push({
|
$router.push({
|
||||||
name: 'library.albums.detail',
|
name: 'library.albums.detail',
|
||||||
params: { id: track.album.id },
|
params: { id: track.album?.id },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
@ -172,7 +244,7 @@
|
||||||
@click.prevent.exact="
|
@click.prevent.exact="
|
||||||
$router.push({
|
$router.push({
|
||||||
name: 'library.artists.detail',
|
name: 'library.artists.detail',
|
||||||
params: { id: track.artist.id },
|
params: { id: track.artist?.id },
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
|
@ -202,16 +274,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui divider" />
|
<div class="ui divider" />
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({
|
v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })"
|
||||||
track,
|
|
||||||
album,
|
|
||||||
artist,
|
|
||||||
})"
|
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
:ref="`report${obj.target.type}${obj.target.id}`"
|
:ref="`report${obj.target.type}${obj.target.id}`"
|
||||||
class="row"
|
class="row"
|
||||||
:data-ref="`report${obj.target.type}${obj.target.id}`"
|
:data-ref="`report${obj.target.type}${obj.target.id}`"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<i class="share icon track-modal list-icon" /><span
|
<i class="share icon track-modal list-icon" /><span
|
||||||
|
@ -221,93 +289,5 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modal>
|
</semantic-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import Modal from '~/components/semantic/Modal.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
import PlayOptionsMixin from '~/components/mixins/PlayOptions.vue'
|
|
||||||
import { useVModel } from '@vueuse/core'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
Modal
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin, PlayOptionsMixin],
|
|
||||||
props: {
|
|
||||||
show: { type: Boolean, required: true, default: false },
|
|
||||||
track: { type: Object, required: true },
|
|
||||||
index: { type: Number, required: true },
|
|
||||||
isArtist: { type: Boolean, required: false, default: false },
|
|
||||||
isAlbum: { type: Boolean, required: false, default: false }
|
|
||||||
},
|
|
||||||
setup (props) {
|
|
||||||
// TODO (wvffle): Add defineEmits when rewriting to <script setup>
|
|
||||||
const showRef = useVModel(props, 'show'/*, emit */)
|
|
||||||
return { showRef }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isShowing: this.show,
|
|
||||||
tracks: [this.track],
|
|
||||||
album: this.track.album,
|
|
||||||
artist: this.track.artist
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isFavorite () {
|
|
||||||
return this.$store.getters['favorites/isFavorite'](this.track.id)
|
|
||||||
},
|
|
||||||
favoriteButton () {
|
|
||||||
if (this.isFavorite) {
|
|
||||||
return this.$pgettext(
|
|
||||||
'Content/Track/Icon.Tooltip/Verb',
|
|
||||||
'Remove from favorites'
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('Content/Track/*/Verb', 'Add to favorites')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trackDetailsButton () {
|
|
||||||
if (this.track.artist.content_category === 'podcast') {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Episode details')
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'Track details')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
albumDetailsButton () {
|
|
||||||
if (this.track.artist.content_category === 'podcast') {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View series')
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View album')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
artistDetailsButton () {
|
|
||||||
if (this.track.artist.content_category === 'podcast') {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View channel')
|
|
||||||
} else {
|
|
||||||
return this.$pgettext('*/Queue/Dropdown/Button/Label/Short', 'View artist')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
startRadio: this.$pgettext(
|
|
||||||
'*/Queue/Dropdown/Button/Title',
|
|
||||||
'Play radio'
|
|
||||||
),
|
|
||||||
playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'),
|
|
||||||
addToQueue: this.$pgettext(
|
|
||||||
'*/Queue/Dropdown/Button/Title',
|
|
||||||
'Add to queue'
|
|
||||||
),
|
|
||||||
playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'),
|
|
||||||
addToPlaylist: this.$pgettext(
|
|
||||||
'Sidebar/Player/Icon.Tooltip/Verb',
|
|
||||||
'Add to playlist…'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,53 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
// import { Track } from '~/types'
|
||||||
|
import PlayIndicator from '~/components/audio/track/PlayIndicator.vue'
|
||||||
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import usePlayOptions, { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
|
import usePlayer from '~/composables/audio/usePlayer'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props extends PlayOptionsProps {
|
||||||
|
tracks: Track[]
|
||||||
|
track: Track
|
||||||
|
index: number
|
||||||
|
|
||||||
|
showAlbum?: boolean
|
||||||
|
showArt?: boolean
|
||||||
|
showArtist?: boolean
|
||||||
|
showDuration?: boolean
|
||||||
|
showPosition?: boolean
|
||||||
|
displayActions?: boolean
|
||||||
|
|
||||||
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
|
isPlayable?: boolean
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showAlbum: true,
|
||||||
|
showArt: true,
|
||||||
|
showArtist: true,
|
||||||
|
showDuration: true,
|
||||||
|
showPosition: false,
|
||||||
|
displayActions: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const hover = ref<string | null>(null)
|
||||||
|
|
||||||
|
const { playing } = usePlayer()
|
||||||
|
const { currentTrack } = useQueue()
|
||||||
|
const { activateTrack } = usePlayOptions(props)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
|
@ -17,7 +67,7 @@
|
||||||
v-if="
|
v-if="
|
||||||
!$store.state.player.isLoadingAudio &&
|
!$store.state.player.isLoadingAudio &&
|
||||||
currentTrack &&
|
currentTrack &&
|
||||||
isPlaying &&
|
playing &&
|
||||||
track.id === currentTrack.id &&
|
track.id === currentTrack.id &&
|
||||||
!(track.id == hover)
|
!(track.id == hover)
|
||||||
"
|
"
|
||||||
|
@ -25,9 +75,9 @@
|
||||||
<button
|
<button
|
||||||
v-else-if="
|
v-else-if="
|
||||||
currentTrack &&
|
currentTrack &&
|
||||||
!isPlaying &&
|
!playing &&
|
||||||
track.id === currentTrack.id &&
|
track.id === currentTrack.id &&
|
||||||
!track.id == hover
|
track.id !== hover
|
||||||
"
|
"
|
||||||
class="ui really tiny basic icon button play-button paused"
|
class="ui really tiny basic icon button play-button paused"
|
||||||
>
|
>
|
||||||
|
@ -36,7 +86,7 @@
|
||||||
<button
|
<button
|
||||||
v-else-if="
|
v-else-if="
|
||||||
currentTrack &&
|
currentTrack &&
|
||||||
isPlaying &&
|
playing &&
|
||||||
track.id === currentTrack.id &&
|
track.id === currentTrack.id &&
|
||||||
track.id == hover
|
track.id == hover
|
||||||
"
|
"
|
||||||
|
@ -54,7 +104,7 @@
|
||||||
v-else-if="showPosition"
|
v-else-if="showPosition"
|
||||||
class="track-position"
|
class="track-position"
|
||||||
>
|
>
|
||||||
{{ prettyPosition(track.position) }}
|
{{ (track.position as unknown as string).padStart(2, '0') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -64,38 +114,20 @@
|
||||||
@click.prevent.exact="activateTrack(track, index)"
|
@click.prevent.exact="activateTrack(track, index)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="
|
v-if="track.album?.cover?.urls.original"
|
||||||
track.album && track.album.cover && track.album.cover.urls.original
|
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.album.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="track.cover?.urls.original"
|
||||||
track.cover && track.cover.urls.original
|
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="
|
v-else-if="track.artist?.cover?.urls.original"
|
||||||
track.artist && track.artist.cover && track.album.cover.urls.original
|
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) "
|
||||||
"
|
|
||||||
v-lazy="
|
|
||||||
$store.getters['instance/absoluteUrl'](
|
|
||||||
track.cover.urls.medium_square_crop
|
|
||||||
)
|
|
||||||
"
|
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
>
|
>
|
||||||
|
@ -121,9 +153,9 @@
|
||||||
class="content ellipsis left floated column"
|
class="content ellipsis left floated column"
|
||||||
>
|
>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'library.albums.detail', params: { id: track.album.id } }"
|
:to="{ name: 'library.albums.detail', params: { id: track.album?.id } }"
|
||||||
>
|
>
|
||||||
{{ track.album.title }}
|
{{ track.album?.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -134,10 +166,10 @@
|
||||||
class="artist link"
|
class="artist link"
|
||||||
:to="{
|
:to="{
|
||||||
name: 'library.artists.detail',
|
name: 'library.artists.detail',
|
||||||
params: { id: track.artist.id },
|
params: { id: track.artist?.id },
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ track.artist.name }}
|
{{ track.artist?.name }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -178,67 +210,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import PlayIndicator from '~/components/audio/track/PlayIndicator.vue'
|
|
||||||
import { mapActions, mapGetters } from 'vuex'
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import PlayOptions from '~/components/mixins/PlayOptions.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
|
|
||||||
components: {
|
|
||||||
PlayIndicator,
|
|
||||||
TrackFavoriteIcon,
|
|
||||||
PlayButton
|
|
||||||
},
|
|
||||||
mixins: [PlayOptions],
|
|
||||||
props: {
|
|
||||||
tracks: { type: Array, required: true },
|
|
||||||
showAlbum: { type: Boolean, required: false, default: true },
|
|
||||||
showArtist: { type: Boolean, required: false, default: true },
|
|
||||||
showPosition: { type: Boolean, required: false, default: false },
|
|
||||||
showArt: { type: Boolean, required: false, default: true },
|
|
||||||
search: { type: Boolean, required: false, default: false },
|
|
||||||
filters: { type: Object, required: false, default: null },
|
|
||||||
nextUrl: { type: String, required: false, default: null },
|
|
||||||
displayActions: { type: Boolean, required: false, default: true },
|
|
||||||
showDuration: { type: Boolean, required: false, default: true },
|
|
||||||
index: { type: Number, required: true },
|
|
||||||
track: { type: Object, required: true }
|
|
||||||
},
|
|
||||||
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
hover: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
currentTrack: 'queue/currentTrack'
|
|
||||||
}),
|
|
||||||
|
|
||||||
isPlaying () {
|
|
||||||
return this.$store.state.player.playing
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
prettyPosition (position, size) {
|
|
||||||
let s = String(position)
|
|
||||||
while (s.length < (size || 2)) {
|
|
||||||
s = '0' + s
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
},
|
|
||||||
|
|
||||||
...mapActions({
|
|
||||||
resumePlayback: 'player/resumePlayback',
|
|
||||||
pausePlayback: 'player/pausePlayback'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,98 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Album, Artist, Library } from '~/types'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { sum } from 'lodash-es'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import TagsList from '~/components/tags/List.vue'
|
||||||
|
import ArtistLabel from '~/components/audio/ArtistLabel.vue'
|
||||||
|
import AlbumDropdown from './AlbumDropdown.vue'
|
||||||
|
import { momentFormat } from '~/utils/filters'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const object = ref<Album | null>(null)
|
||||||
|
const artist = ref<Artist | null>(null)
|
||||||
|
const discs = ref([] as Track[][])
|
||||||
|
const libraries = ref([] as Library[])
|
||||||
|
const page = ref(1)
|
||||||
|
const paginateBy = ref(50)
|
||||||
|
|
||||||
|
const totalTracks = computed(() => object.value?.tracks_count ?? 0)
|
||||||
|
const isChannel = computed(() => !!object.value?.artist.channel)
|
||||||
|
const isAlbum = computed(() => object.value?.artist.content_category === 'music')
|
||||||
|
const isSerie = computed(() => object.value?.artist.content_category === 'podcast')
|
||||||
|
const totalDuration = computed(() => sum((object.value?.tracks ?? []).map(track => track.uploads[0]?.duration ?? 0)))
|
||||||
|
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
title: $pgettext('*/*/*', 'Album')
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const fetchData = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
const albumResponse = await axios.get(`albums/${props.id}/`, { params: { refresh: 'true' } })
|
||||||
|
const [artistResponse, tracksResponse] = await Promise.all([
|
||||||
|
axios.get(`artists/${albumResponse.data.artist.id}/`),
|
||||||
|
axios.get('tracks/', {
|
||||||
|
params: {
|
||||||
|
ordering: 'disc_number,position',
|
||||||
|
album: props.id,
|
||||||
|
page_size: paginateBy.value,
|
||||||
|
page: page.value,
|
||||||
|
include_channels: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
artist.value = artistResponse.data
|
||||||
|
if (artist.value?.channel) {
|
||||||
|
artist.value.channel.artist = artist.value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
object.value = albumResponse.data
|
||||||
|
if (object.value) {
|
||||||
|
object.value.tracks = tracksResponse.data.results
|
||||||
|
discs.value = object.value.tracks.reduce((acc: Track[][], track: Track) => {
|
||||||
|
const discNumber = track.disc_number - (object.value?.tracks[0]?.disc_number ?? 1)
|
||||||
|
acc[discNumber] ??= []
|
||||||
|
acc[discNumber].push(track)
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
watch(page, fetchData)
|
||||||
|
|
||||||
|
const emit = defineEmits(['deleted'])
|
||||||
|
const router = useRouter()
|
||||||
|
const remove = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await axios.delete(`albums/${object.value?.id}`)
|
||||||
|
isLoading.value = false
|
||||||
|
emit('deleted')
|
||||||
|
router.push({ name: 'library.artists.detail', params: { id: artist.value?.id } })
|
||||||
|
} catch (error) {
|
||||||
|
isLoading.value = false
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<div
|
<div
|
||||||
|
@ -93,6 +188,7 @@
|
||||||
:is-serie="isSerie"
|
:is-serie="isSerie"
|
||||||
:is-channel="isChannel"
|
:is-channel="isChannel"
|
||||||
:artist="artist"
|
:artist="artist"
|
||||||
|
@remove="remove"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -140,7 +236,7 @@
|
||||||
v-if="object.release_date || (totalTracks > 0)"
|
v-if="object.release_date || (totalTracks > 0)"
|
||||||
class="ui small hidden divider"
|
class="ui small hidden divider"
|
||||||
/>
|
/>
|
||||||
<span v-if="object.release_date">{{ momentFormat(object.release_date, 'Y') }} · </span>
|
<span v-if="object.release_date">{{ momentFormat(new Date(object.release_date ?? '1970-01-01'), 'Y') }} · </span>
|
||||||
<template v-if="totalTracks > 0">
|
<template v-if="totalTracks > 0">
|
||||||
<translate
|
<translate
|
||||||
v-if="isSerie"
|
v-if="isSerie"
|
||||||
|
@ -180,6 +276,7 @@
|
||||||
:is-serie="isSerie"
|
:is-serie="isSerie"
|
||||||
:is-channel="isChannel"
|
:is-channel="isChannel"
|
||||||
:artist="artist"
|
:artist="artist"
|
||||||
|
@remove="remove"
|
||||||
/>
|
/>
|
||||||
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
|
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
|
||||||
<div class="ui small hidden divider" />
|
<div class="ui small hidden divider" />
|
||||||
|
@ -244,123 +341,3 @@
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import { sum } from 'lodash-es'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import TagsList from '~/components/tags/List.vue'
|
|
||||||
import ArtistLabel from '~/components/audio/ArtistLabel.vue'
|
|
||||||
import AlbumDropdown from './AlbumDropdown.vue'
|
|
||||||
import { momentFormat } from '~/utils/filters'
|
|
||||||
|
|
||||||
function groupByDisc (initial) {
|
|
||||||
function inner (acc, track) {
|
|
||||||
const dn = track.disc_number - initial
|
|
||||||
if (acc[dn] === undefined) {
|
|
||||||
acc.push([track])
|
|
||||||
} else {
|
|
||||||
acc[dn].push(track)
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}
|
|
||||||
return inner
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
PlayButton,
|
|
||||||
TagsList,
|
|
||||||
ArtistLabel,
|
|
||||||
AlbumDropdown
|
|
||||||
},
|
|
||||||
props: { id: { type: [String, Number], required: true } },
|
|
||||||
setup () {
|
|
||||||
return { momentFormat }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
object: null,
|
|
||||||
artist: null,
|
|
||||||
discs: [],
|
|
||||||
libraries: [],
|
|
||||||
page: 1,
|
|
||||||
paginateBy: 50
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
totalTracks () {
|
|
||||||
return this.object.tracks_count
|
|
||||||
},
|
|
||||||
isChannel () {
|
|
||||||
return !!this.object.artist.channel
|
|
||||||
},
|
|
||||||
isSerie () {
|
|
||||||
return this.object.artist.content_category === 'podcast'
|
|
||||||
},
|
|
||||||
isAlbum () {
|
|
||||||
return this.object.artist.content_category === 'music'
|
|
||||||
},
|
|
||||||
totalDuration () {
|
|
||||||
const durations = [0]
|
|
||||||
this.object.tracks.forEach((t) => {
|
|
||||||
if (t.uploads[0] && t.uploads[0].duration) {
|
|
||||||
durations.push(t.uploads[0].duration)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return sum(durations)
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
title: this.$pgettext('*/*/*', 'Album')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
publicLibraries () {
|
|
||||||
return this.libraries.filter(l => {
|
|
||||||
return l.privacy_level === 'everyone'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
id () {
|
|
||||||
this.fetchData()
|
|
||||||
},
|
|
||||||
page () {
|
|
||||||
this.fetchData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async created () {
|
|
||||||
await this.fetchData()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async fetchData () {
|
|
||||||
this.isLoading = true
|
|
||||||
let tracksResponse = axios.get('tracks/', { params: { ordering: 'disc_number,position', album: this.id, page_size: this.paginateBy, page: this.page, include_channels: 'true' } })
|
|
||||||
const albumResponse = await axios.get(`albums/${this.id}/`, { params: { refresh: 'true' } })
|
|
||||||
const artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`)
|
|
||||||
this.artist = artistResponse.data
|
|
||||||
if (this.artist.channel) {
|
|
||||||
this.artist.channel.artist = this.artist
|
|
||||||
}
|
|
||||||
tracksResponse = await tracksResponse
|
|
||||||
this.object = albumResponse.data
|
|
||||||
this.object.tracks = tracksResponse.data.results
|
|
||||||
this.discs = this.object.tracks.reduce(groupByDisc(this.object.tracks[0].disc_number), [])
|
|
||||||
this.isLoading = false
|
|
||||||
},
|
|
||||||
remove () {
|
|
||||||
const self = this
|
|
||||||
self.isLoading = true
|
|
||||||
axios.delete(`albums/${this.object.id}`).then((response) => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.$emit('deleted')
|
|
||||||
self.$router.push({ name: 'library.artists.detail', params: { id: this.artist.id } })
|
|
||||||
}, error => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.errors = error.backendErrors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Album, Artist, Library } from '~/types'
|
||||||
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||||
|
import Modal from '~/components/semantic/Modal.vue'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
|
||||||
|
import { getDomain } from '~/utils'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLoading: boolean
|
||||||
|
artist: Artist | null
|
||||||
|
object: Album
|
||||||
|
publicLibraries: Library[]
|
||||||
|
isAlbum: boolean
|
||||||
|
isChannel: boolean
|
||||||
|
isSerie: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
|
||||||
|
const showEmbedModal = ref(false)
|
||||||
|
|
||||||
|
const domain = computed(() => getDomain(props.object.fid))
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
more: $pgettext('*/*/Button.Label/Noun', 'More…')
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isEmbedable = computed(() => (props.isChannel && props.artist?.channel?.actor) || props.publicLibraries.length)
|
||||||
|
const musicbrainzUrl = computed(() => props.object?.mbid ? `https://musicbrainz.org/release/${props.object.mbid}` : null)
|
||||||
|
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(props.object?.title)}&artist=${encodeURI(props.object?.artist.name)}`)
|
||||||
|
|
||||||
|
const emit = defineEmits(['remove'])
|
||||||
|
const remove = () => emit('remove')
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
|
|
||||||
|
@ -109,11 +149,11 @@
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({album: object, channel: artist.channel})"
|
v-for="obj in getReportableObjects({album: object, channel: artist?.channel})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
role="button"
|
role="button"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -127,7 +167,7 @@
|
||||||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||||
</router-link>
|
</router-link>
|
||||||
<a
|
<a
|
||||||
v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser"
|
v-if="$store.state.auth.profile && $store.state.auth.profile?.is_superuser"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
|
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
@ -140,62 +180,3 @@
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
|
||||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
||||||
import Modal from '~/components/semantic/Modal.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
|
|
||||||
import { getDomain } from '~/utils'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
EmbedWizard,
|
|
||||||
Modal
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
props: {
|
|
||||||
isLoading: Boolean,
|
|
||||||
artist: { type: Object, required: true },
|
|
||||||
object: { type: Object, required: true },
|
|
||||||
publicLibraries: { type: Array, required: true },
|
|
||||||
isAlbum: Boolean,
|
|
||||||
isChannel: Boolean,
|
|
||||||
isSerie: Boolean
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
showEmbedModal: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
domain () {
|
|
||||||
if (this.object) {
|
|
||||||
return getDomain(this.object.fid)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
more: this.$pgettext('*/*/Button.Label/Noun', 'More…')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isEmbedable () {
|
|
||||||
return (this.isChannel && this.artist.channel.actor) || this.publicLibraries.length > 0
|
|
||||||
},
|
|
||||||
|
|
||||||
musicbrainzUrl () {
|
|
||||||
if (this.object.mbid) {
|
|
||||||
return 'https://musicbrainz.org/release/' + this.object.mbid
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
discogsUrl () {
|
|
||||||
return (
|
|
||||||
'https://discogs.com/search/?type=release&title=' +
|
|
||||||
encodeURI(this.object.title) + '&artist=' +
|
|
||||||
encodeURI(this.object.artist.name)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,96 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Album, Artist, Library } from '~/types'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import axios from 'axios'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||||
|
import Modal from '~/components/semantic/Modal.vue'
|
||||||
|
import RadioButton from '~/components/radios/Button.vue'
|
||||||
|
import TagsList from '~/components/tags/List.vue'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import useLogger from '~/composables/useLogger'
|
||||||
|
import { getDomain } from '~/utils'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
|
||||||
|
const object = ref<Artist | null>(null)
|
||||||
|
const libraries = ref([] as Library[])
|
||||||
|
const albums = ref([] as Album[])
|
||||||
|
const tracks = ref([] as Track[])
|
||||||
|
const showEmbedModal = ref(false)
|
||||||
|
|
||||||
|
const nextAlbumsUrl = ref(null)
|
||||||
|
const nextTracksUrl = ref(null)
|
||||||
|
const totalAlbums = ref(0)
|
||||||
|
const totalTracks = ref(0)
|
||||||
|
|
||||||
|
const dropdown = ref()
|
||||||
|
|
||||||
|
const logger = useLogger()
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const domain = computed(() => getDomain(object.value?.fid ?? ''))
|
||||||
|
const isPlayable = computed(() => !!object.value?.albums.some(album => album.is_playable))
|
||||||
|
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(object.value?.name ?? '')}`)
|
||||||
|
const musicbrainzUrl = computed(() => object.value?.mbid ? `https://musicbrainz.org/artist/${object.value.mbid}` : null)
|
||||||
|
const discogsUrl = computed(() => `https://discogs.com/search/?type=artist&title=${encodeURI(object.value?.name ?? '')}`)
|
||||||
|
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
|
||||||
|
const cover = computed(() => object.value?.cover?.urls.original
|
||||||
|
? object.value.cover
|
||||||
|
: object.value?.albums.find(album => album.cover?.urls.original)?.cover
|
||||||
|
)
|
||||||
|
const headerStyle = computed(() => cover.value?.urls.original
|
||||||
|
? { backgroundImage: `url(${store.getters['instance/absoluteUrl'](cover.value.urls.original)})` }
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
title: $pgettext('*/*/*', 'Artist')
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const fetchData = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
logger.debug(`Fetching artist "${props.id}"`)
|
||||||
|
|
||||||
|
const artistsResponse = await axios.get(`artists/${props.id}/`, { params: { refresh: 'true' } })
|
||||||
|
if (artistsResponse.data.channel) {
|
||||||
|
return router.replace({ name: 'channels.detail', params: { id: artistsResponse.data.channel.uuid } })
|
||||||
|
}
|
||||||
|
|
||||||
|
object.value = artistsResponse.data
|
||||||
|
|
||||||
|
const [tracksResponse, albumsResponse] = await Promise.all([
|
||||||
|
axios.get('tracks/', { params: { artist: props.id, hidden: '', ordering: '-creation_date' } }),
|
||||||
|
axios.get('albums/', { params: { artist: props.id, hidden: '', ordering: '-release_date' } })
|
||||||
|
])
|
||||||
|
|
||||||
|
tracks.value = tracksResponse.data.results
|
||||||
|
nextTracksUrl.value = tracksResponse.data.next
|
||||||
|
totalTracks.value = tracksResponse.data.count
|
||||||
|
|
||||||
|
|
||||||
|
nextAlbumsUrl.value = albumsResponse.data.next
|
||||||
|
totalAlbums.value = albumsResponse.data.count
|
||||||
|
|
||||||
|
albums.value = albumsResponse.data.results
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main v-title="labels.title">
|
<main v-title="labels.title">
|
||||||
<div
|
<div
|
||||||
|
@ -85,7 +178,7 @@
|
||||||
<div class="ui buttons">
|
<div class="ui buttons">
|
||||||
<button
|
<button
|
||||||
class="ui button"
|
class="ui button"
|
||||||
@click="$refs.dropdown.click()"
|
@click="dropdown.click()"
|
||||||
>
|
>
|
||||||
<translate translate-context="*/*/Button.Label/Noun">
|
<translate translate-context="*/*/Button.Label/Noun">
|
||||||
More…
|
More…
|
||||||
|
@ -162,11 +255,11 @@
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({artist: object})"
|
v-for="obj in getReportableObjects({artist: object})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
role="button"
|
role="button"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -204,7 +297,7 @@
|
||||||
:next-tracks-url="nextTracksUrl"
|
:next-tracks-url="nextTracksUrl"
|
||||||
:next-albums-url="nextAlbumsUrl"
|
:next-albums-url="nextAlbumsUrl"
|
||||||
:albums="albums"
|
:albums="albums"
|
||||||
:is-loading-albums="isLoadingAlbums"
|
:is-loading-albums="isLoading"
|
||||||
:object="object"
|
:object="object"
|
||||||
object-type="artist"
|
object-type="artist"
|
||||||
@libraries-loaded="libraries = $event"
|
@libraries-loaded="libraries = $event"
|
||||||
|
@ -212,160 +305,3 @@
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
||||||
import Modal from '~/components/semantic/Modal.vue'
|
|
||||||
import RadioButton from '~/components/radios/Button.vue'
|
|
||||||
import TagsList from '~/components/tags/List.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
|
|
||||||
import { getDomain } from '~/utils'
|
|
||||||
import useLogger from '~/composables/useLogger'
|
|
||||||
|
|
||||||
const logger = useLogger()
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
PlayButton,
|
|
||||||
EmbedWizard,
|
|
||||||
Modal,
|
|
||||||
RadioButton,
|
|
||||||
TagsList
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
props: { id: { type: [String, Number], required: true } },
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
isLoadingAlbums: true,
|
|
||||||
object: null,
|
|
||||||
albums: null,
|
|
||||||
libraries: [],
|
|
||||||
showEmbedModal: false,
|
|
||||||
tracks: [],
|
|
||||||
nextAlbumsUrl: null,
|
|
||||||
nextTracksUrl: null,
|
|
||||||
totalAlbums: null,
|
|
||||||
totalTracks: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
domain () {
|
|
||||||
if (this.object) {
|
|
||||||
return getDomain(this.object.fid)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
isPlayable () {
|
|
||||||
return (
|
|
||||||
this.object.albums.filter(a => {
|
|
||||||
return a.is_playable
|
|
||||||
}).length > 0
|
|
||||||
)
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
title: this.$pgettext('*/*/*', 'Album')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wikipediaUrl () {
|
|
||||||
return (
|
|
||||||
'https://en.wikipedia.org/w/index.php?search=' +
|
|
||||||
encodeURI(this.object.name)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
musicbrainzUrl () {
|
|
||||||
if (this.object.mbid) {
|
|
||||||
return 'https://musicbrainz.org/artist/' + this.object.mbid
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
discogsUrl () {
|
|
||||||
return (
|
|
||||||
'https://discogs.com/search/?type=artist&title=' +
|
|
||||||
encodeURI(this.object.name)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
cover () {
|
|
||||||
if (this.object.cover && this.object.cover.urls.original) {
|
|
||||||
return this.object.cover
|
|
||||||
}
|
|
||||||
return this.object.albums
|
|
||||||
.filter(album => {
|
|
||||||
return album.cover && album.cover.urls.original
|
|
||||||
})
|
|
||||||
.map(album => {
|
|
||||||
return album.cover
|
|
||||||
})[0]
|
|
||||||
},
|
|
||||||
|
|
||||||
publicLibraries () {
|
|
||||||
return this.libraries.filter(l => {
|
|
||||||
return l.privacy_level === 'everyone'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
headerStyle () {
|
|
||||||
if (!this.cover || !this.cover.urls.original) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
'background-image: url(' +
|
|
||||||
this.$store.getters['instance/absoluteUrl'](this.cover.urls.original) +
|
|
||||||
')'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
contentFilter () {
|
|
||||||
return this.$store.getters['moderation/artistFilters']().filter((e) => {
|
|
||||||
return e.target.id === this.object.id
|
|
||||||
})[0]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
id () {
|
|
||||||
this.fetchData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async created () {
|
|
||||||
await this.fetchData()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async fetchData () {
|
|
||||||
const self = this
|
|
||||||
this.isLoading = true
|
|
||||||
logger.debug('Fetching artist "' + this.id + '"')
|
|
||||||
|
|
||||||
const artistPromise = axios.get('artists/' + this.id + '/', { params: { refresh: 'true' } }).then(response => {
|
|
||||||
if (response.data.channel) {
|
|
||||||
self.$router.replace({ name: 'channels.detail', params: { id: response.data.channel.uuid } })
|
|
||||||
} else {
|
|
||||||
self.object = response.data
|
|
||||||
}
|
|
||||||
})
|
|
||||||
await artistPromise
|
|
||||||
if (!self.object) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const trackPromise = axios.get('tracks/', { params: { artist: this.id, hidden: '', ordering: '-creation_date' } }).then(response => {
|
|
||||||
self.tracks = response.data.results
|
|
||||||
self.nextTracksUrl = response.data.next
|
|
||||||
self.totalTracks = response.data.count
|
|
||||||
})
|
|
||||||
const albumPromise = axios.get('albums/', {
|
|
||||||
params: { artist: self.id, ordering: '-release_date', hidden: '' }
|
|
||||||
}).then(response => {
|
|
||||||
self.nextAlbumsUrl = response.data.next
|
|
||||||
self.totalAlbums = response.data.count
|
|
||||||
const parsed = JSON.parse(JSON.stringify(response.data.results))
|
|
||||||
self.albums = parsed
|
|
||||||
})
|
|
||||||
await trackPromise
|
|
||||||
await albumPromise
|
|
||||||
self.isLoadingAlbums = false
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,123 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Track, Artist, Library } from '~/types'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import axios from 'axios'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
|
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||||
|
import Modal from '~/components/semantic/Modal.vue'
|
||||||
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||||
|
import { momentFormat } from '~/utils/filters'
|
||||||
|
import updateQueryString from '~/composables/updateQueryString'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import useLogger from '~/composables/useLogger'
|
||||||
|
import { getDomain } from '~/utils'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
|
||||||
|
const track = ref<Track | null>(null)
|
||||||
|
const artist = ref<Artist | null>(null)
|
||||||
|
const showEmbedModal = ref(false)
|
||||||
|
const libraries = ref([] as Library[])
|
||||||
|
|
||||||
|
const logger = useLogger()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const domain = computed(() => getDomain(track.value?.fid ?? ''))
|
||||||
|
const publicLibraries = computed(() => libraries.value?.filter(library => library.privacy_level === 'everyone') ?? [])
|
||||||
|
const isEmbedable = computed(() => artist.value?.channel?.actor || publicLibraries.value.length)
|
||||||
|
const upload = computed(() => track.value?.uploads?.[0] ?? null)
|
||||||
|
const wikipediaUrl = computed(() => `https://en.wikipedia.org/w/index.php?search=${encodeURI(`${track.value?.title ?? ''} ${track.value?.artist?.name ?? ''}`)}`)
|
||||||
|
const discogsUrl = computed(() => `https://discogs.com/search/?type=release&title=${encodeURI(track.value?.album?.title ?? '')}&artist=${encodeURI(track.value?.artist?.name ?? '')}&title=${encodeURI(track.value?.title ?? '')}`)
|
||||||
|
const downloadUrl = computed(() => {
|
||||||
|
const url = store.getters['instance/absoluteUrl'](upload.value?.listen_url ?? '')
|
||||||
|
return store.state.auth.authenticated
|
||||||
|
? updateQueryString(url, 'token', encodeURI(store.state.auth.scopedTokens.listen ?? ''))
|
||||||
|
: url
|
||||||
|
})
|
||||||
|
|
||||||
|
const attributedToUrl = computed(() => router.resolve({
|
||||||
|
name: 'profile.full.overview',
|
||||||
|
params: {
|
||||||
|
username: track.value?.attributed_to.preferred_username,
|
||||||
|
domain: track.value?.attributed_to.domain
|
||||||
|
}
|
||||||
|
})?.href)
|
||||||
|
|
||||||
|
const escapeHtml = (unsafe: string) => document.createTextNode(unsafe).textContent ?? ''
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (track.value?.attributed_to) {
|
||||||
|
return $pgettext(
|
||||||
|
'Content/Track/Paragraph',
|
||||||
|
'Uploaded by <a class="internal" href="%{ uploaderUrl }">%{ uploader }</a> on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>',
|
||||||
|
{
|
||||||
|
uploaderUrl: attributedToUrl.value,
|
||||||
|
uploader: escapeHtml(`@${track.value.attributed_to.full_username}`),
|
||||||
|
date: escapeHtml(track.value.creation_date),
|
||||||
|
prettyDate: escapeHtml(momentFormat(new Date(track.value.creation_date), 'LL'))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pgettext(
|
||||||
|
'Content/Track/Paragraph',
|
||||||
|
'Uploaded on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>',
|
||||||
|
{
|
||||||
|
date: escapeHtml(track.value?.creation_date ?? ''),
|
||||||
|
prettyDate: escapeHtml(momentFormat(new Date(track.value?.creation_date ?? '1970-01-01'), 'LL'))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
title: $pgettext('*/*/*/Noun', 'Track'),
|
||||||
|
download: $pgettext('Content/Track/Link/Verb', 'Download'),
|
||||||
|
more: $pgettext('*/*/Button.Label/Noun', 'More…')
|
||||||
|
}))
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const fetchData = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
logger.debug(`Fetching track "${props.id}"`)
|
||||||
|
try {
|
||||||
|
const trackResponse = await axios.get(`tracks/${props.id}/`, { params: { refresh: 'true' } })
|
||||||
|
track.value = trackResponse.data
|
||||||
|
const artistResponse = await axios.get(`artists/${trackResponse.data.artist.id}/`)
|
||||||
|
artist.value = artistResponse.data
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
|
||||||
|
const emit = defineEmits(['deleted'])
|
||||||
|
const remove = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await axios.delete(`tracks/${track.value?.id}`)
|
||||||
|
emit('deleted')
|
||||||
|
router.push({ name: 'library.artists.detail', params: { id: artist.value?.id } })
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<div
|
<div
|
||||||
|
@ -177,11 +297,11 @@
|
||||||
</dangerous-button>
|
</dangerous-button>
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({track})"
|
v-for="obj in getReportableObjects({track})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
role="button"
|
role="button"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -223,196 +343,3 @@
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import time from '~/utils/time'
|
|
||||||
import axios from 'axios'
|
|
||||||
import { getDomain } from '~/utils'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
|
||||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
|
||||||
import Modal from '~/components/semantic/Modal.vue'
|
|
||||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
import { momentFormat } from '~/utils/filters'
|
|
||||||
import updateQueryString from '~/composables/updateQueryString'
|
|
||||||
import useLogger from '~/composables/useLogger'
|
|
||||||
|
|
||||||
const logger = useLogger()
|
|
||||||
|
|
||||||
const FETCH_URL = 'tracks/'
|
|
||||||
|
|
||||||
function escapeHtml (unsafe) {
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''')
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
PlayButton,
|
|
||||||
TrackPlaylistIcon,
|
|
||||||
TrackFavoriteIcon,
|
|
||||||
Modal,
|
|
||||||
EmbedWizard
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
props: { id: { type: [String, Number], required: true } },
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
time,
|
|
||||||
isLoading: true,
|
|
||||||
track: null,
|
|
||||||
artist: null,
|
|
||||||
showEmbedModal: false,
|
|
||||||
libraries: []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
domain () {
|
|
||||||
if (this.track) {
|
|
||||||
return getDomain(this.track.fid)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
publicLibraries () {
|
|
||||||
return this.libraries.filter(l => {
|
|
||||||
return l.privacy_level === 'everyone'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
isEmbedable () {
|
|
||||||
const self = this
|
|
||||||
return (self.artist && self.artist.channel && self.artist.channel.actor) || this.publicLibraries.length > 0
|
|
||||||
},
|
|
||||||
upload () {
|
|
||||||
if (this.track.uploads) {
|
|
||||||
return this.track.uploads[0]
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
title: this.$pgettext('*/*/*/Noun', 'Track'),
|
|
||||||
download: this.$pgettext('Content/Track/Link/Verb', 'Download'),
|
|
||||||
more: this.$pgettext('*/*/Button.Label/Noun', 'More…')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
wikipediaUrl () {
|
|
||||||
return (
|
|
||||||
'https://en.wikipedia.org/w/index.php?search=' +
|
|
||||||
encodeURI(this.track.title + ' ' + this.track.artist.name)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
discogsUrl () {
|
|
||||||
if (this.track.album) {
|
|
||||||
return (
|
|
||||||
'https://discogs.com/search/?type=release&title=' +
|
|
||||||
encodeURI(this.track.album.title) + '&artist=' +
|
|
||||||
encodeURI(this.track.artist.name) + '&track=' +
|
|
||||||
encodeURI(this.track.title)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
downloadUrl () {
|
|
||||||
const url = this.$store.getters['instance/absoluteUrl'](
|
|
||||||
this.upload.listen_url
|
|
||||||
)
|
|
||||||
|
|
||||||
if (this.$store.state.auth.authenticated) {
|
|
||||||
return updateQueryString(
|
|
||||||
url,
|
|
||||||
'token',
|
|
||||||
encodeURI(this.$store.state.auth.scopedTokens.listen)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return url
|
|
||||||
},
|
|
||||||
attributedToUrl () {
|
|
||||||
const route = this.$router.resolve({
|
|
||||||
name: 'profile.full.overview',
|
|
||||||
params: {
|
|
||||||
username: this.track.attributed_to.preferred_username,
|
|
||||||
domain: this.track.attributed_to.domain
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return route.href
|
|
||||||
},
|
|
||||||
albumUrl () {
|
|
||||||
const route = this.$router.resolve({ name: 'library.albums.detail', params: { id: this.track.album.id } })
|
|
||||||
return route.href
|
|
||||||
},
|
|
||||||
artistUrl () {
|
|
||||||
const route = this.$router.resolve({ name: 'library.artists.detail', params: { id: this.track.artist.id } })
|
|
||||||
return route.href
|
|
||||||
},
|
|
||||||
headerStyle () {
|
|
||||||
if (!this.cover || !this.cover.urls.original) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
'background-image: url(' +
|
|
||||||
this.$store.getters['instance/absoluteUrl'](this.cover.urls.original) +
|
|
||||||
')'
|
|
||||||
)
|
|
||||||
},
|
|
||||||
subtitle () {
|
|
||||||
let msg
|
|
||||||
if (this.track.attributed_to) {
|
|
||||||
msg = this.$pgettext('Content/Track/Paragraph', 'Uploaded by <a class="internal" href="%{ uploaderUrl }">%{ uploader }</a> on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>')
|
|
||||||
return this.$gettextInterpolate(msg, {
|
|
||||||
uploaderUrl: this.attributedToUrl,
|
|
||||||
uploader: escapeHtml(`@${this.track.attributed_to.full_username}`),
|
|
||||||
date: escapeHtml(this.track.creation_date),
|
|
||||||
prettyDate: escapeHtml(momentFormat(this.track.creation_date, 'LL'))
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
msg = this.$pgettext('Content/Track/Paragraph', 'Uploaded on <time title="%{ date }" datetime="%{ date }">%{ prettyDate }</time>')
|
|
||||||
return this.$gettextInterpolate(msg, {
|
|
||||||
date: escapeHtml(this.track.creation_date),
|
|
||||||
prettyDate: escapeHtml(momentFormat(this.track.creation_date, 'LL'))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
id () {
|
|
||||||
this.fetchData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.fetchData()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
fetchData () {
|
|
||||||
const self = this
|
|
||||||
this.isLoading = true
|
|
||||||
const url = FETCH_URL + this.id + '/'
|
|
||||||
logger.debug('Fetching track "' + this.id + '"')
|
|
||||||
axios.get(url, { params: { refresh: 'true' } }).then(response => {
|
|
||||||
self.track = response.data
|
|
||||||
axios.get(`artists/${response.data.artist.id}/`).then(response => {
|
|
||||||
self.artist = response.data
|
|
||||||
})
|
|
||||||
self.isLoading = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
remove () {
|
|
||||||
const self = this
|
|
||||||
self.isLoading = true
|
|
||||||
axios.delete(`tracks/${this.track.id}`).then((response) => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.$emit('deleted')
|
|
||||||
self.$router.push({ name: 'library.artists.detail', params: { id: this.artist.id } })
|
|
||||||
}, error => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.errors = error.backendErrors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
type ResponseType = { count: number, results: any[] }
|
type ResponseType = { count: number, results: any[] }
|
||||||
const result = ref<null | ResponseType>(null)
|
const result = ref<null | ResponseType>(null)
|
||||||
|
const search = ref()
|
||||||
|
|
||||||
const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props.defaultQuery, props.updateUrl)
|
const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props.defaultQuery, props.updateUrl)
|
||||||
const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props.orderingConfigName)
|
const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props.orderingConfigName)
|
||||||
|
@ -81,7 +82,7 @@ const labels = computed(() => ({
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="ui six wide field">
|
<div class="ui six wide field">
|
||||||
<label for="channel-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
|
<label for="channel-search"><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
|
||||||
<form @submit.prevent="query = $refs.search.value">
|
<form @submit.prevent="query = search.value">
|
||||||
<input
|
<input
|
||||||
id="channel-search"
|
id="channel-search"
|
||||||
ref="search"
|
ref="search"
|
||||||
|
|
|
@ -1,184 +0,0 @@
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import jQuery from 'jquery'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
computed: {
|
|
||||||
playable () {
|
|
||||||
if (this.isPlayable) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (this.track) {
|
|
||||||
return this.track.uploads && this.track.uploads.length > 0
|
|
||||||
} else if (this.artist && this.artist.tracks_count) {
|
|
||||||
return this.artist.tracks_count > 0
|
|
||||||
} else if (this.artist && this.artist.albums) {
|
|
||||||
return this.artist.albums.filter((a) => {
|
|
||||||
return a.is_playable === true
|
|
||||||
}).length > 0
|
|
||||||
} else if (this.tracks) {
|
|
||||||
return this.tracks.filter((t) => {
|
|
||||||
return t.uploads && t.uploads.length > 0
|
|
||||||
}).length > 0
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
filterableArtist () {
|
|
||||||
if (this.track) {
|
|
||||||
return this.track.artist
|
|
||||||
}
|
|
||||||
if (this.album) {
|
|
||||||
return this.album.artist
|
|
||||||
}
|
|
||||||
if (this.artist) {
|
|
||||||
return this.artist
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
filterArtist () {
|
|
||||||
this.$store.dispatch('moderation/hide', { type: 'artist', target: this.filterableArtist })
|
|
||||||
},
|
|
||||||
activateTrack (track, index) {
|
|
||||||
if (
|
|
||||||
this.currentTrack &&
|
|
||||||
this.isPlaying &&
|
|
||||||
track.id === this.currentTrack.id
|
|
||||||
) {
|
|
||||||
this.pausePlayback()
|
|
||||||
} else if (
|
|
||||||
this.currentTrack &&
|
|
||||||
!this.isPlaying &&
|
|
||||||
track.id === this.currentTrack.id
|
|
||||||
) {
|
|
||||||
this.resumePlayback()
|
|
||||||
} else {
|
|
||||||
this.replacePlay(this.tracks, index)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getTracksPage (page, params, resolve, tracks) {
|
|
||||||
if (page > 10) {
|
|
||||||
// it's 10 * 100 tracks already, let's stop here
|
|
||||||
resolve(tracks)
|
|
||||||
}
|
|
||||||
// when fetching artists/or album tracks, sometimes, we may have to fetch
|
|
||||||
// multiple pages
|
|
||||||
const self = this
|
|
||||||
params.page_size = 100
|
|
||||||
params.page = page
|
|
||||||
params.hidden = ''
|
|
||||||
params.playable = 'true'
|
|
||||||
tracks = tracks || []
|
|
||||||
axios.get('tracks/', { params: params }).then((response) => {
|
|
||||||
response.data.results.forEach(t => {
|
|
||||||
tracks.push(t)
|
|
||||||
})
|
|
||||||
if (response.data.next) {
|
|
||||||
self.getTracksPage(page + 1, params, resolve, tracks)
|
|
||||||
} else {
|
|
||||||
resolve(tracks)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getPlayableTracks () {
|
|
||||||
const self = this
|
|
||||||
this.isLoading = true
|
|
||||||
const getTracks = new Promise((resolve, reject) => {
|
|
||||||
if (self.tracks) {
|
|
||||||
resolve(self.tracks)
|
|
||||||
} else if (self.track) {
|
|
||||||
if (!self.track.uploads || self.track.uploads.length === 0) {
|
|
||||||
// fetch uploads from api
|
|
||||||
axios.get(`tracks/${self.track.id}/`).then((response) => {
|
|
||||||
resolve([response.data])
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
resolve([self.track])
|
|
||||||
}
|
|
||||||
} else if (self.playlist) {
|
|
||||||
const url = 'playlists/' + self.playlist.id + '/'
|
|
||||||
axios.get(url + 'tracks/').then((response) => {
|
|
||||||
const artistIds = self.$store.getters['moderation/artistFilters']().map((f) => {
|
|
||||||
return f.target.id
|
|
||||||
})
|
|
||||||
let tracks = response.data.results.map(plt => {
|
|
||||||
return plt.track
|
|
||||||
})
|
|
||||||
if (artistIds.length > 0) {
|
|
||||||
// skip tracks from hidden artists
|
|
||||||
tracks = tracks.filter((t) => {
|
|
||||||
const matchArtist = artistIds.indexOf(t.artist.id) > -1
|
|
||||||
return !((matchArtist || t.album) && artistIds.indexOf(t.album.artist.id) > -1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(tracks)
|
|
||||||
})
|
|
||||||
} else if (self.artist) {
|
|
||||||
const params = { artist: self.artist.id, include_channels: 'true', ordering: 'album__release_date,disc_number,position' }
|
|
||||||
self.getTracksPage(1, params, resolve)
|
|
||||||
} else if (self.album) {
|
|
||||||
const params = { album: self.album.id, include_channels: 'true', ordering: 'disc_number,position' }
|
|
||||||
self.getTracksPage(1, params, resolve)
|
|
||||||
} else if (self.library) {
|
|
||||||
const params = { library: self.library.uuid, ordering: '-creation_date' }
|
|
||||||
self.getTracksPage(1, params, resolve)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return getTracks.then((tracks) => {
|
|
||||||
setTimeout(e => {
|
|
||||||
self.isLoading = false
|
|
||||||
}, 250)
|
|
||||||
return tracks.filter(e => {
|
|
||||||
return e.uploads && e.uploads.length > 0
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
add () {
|
|
||||||
const self = this
|
|
||||||
this.getPlayableTracks().then((tracks) => {
|
|
||||||
self.$store.dispatch('queue/appendMany', { tracks: tracks }).then(() => self.addMessage(tracks))
|
|
||||||
})
|
|
||||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
|
||||||
},
|
|
||||||
replacePlay () {
|
|
||||||
const self = this
|
|
||||||
self.$store.dispatch('queue/clean')
|
|
||||||
this.getPlayableTracks().then((tracks) => {
|
|
||||||
self.$store.dispatch('queue/appendMany', { tracks: tracks }).then(() => {
|
|
||||||
if (self.track) {
|
|
||||||
// set queue position to selected track
|
|
||||||
const trackIndex = self.tracks.findIndex(track => track.id === self.track.id)
|
|
||||||
self.$store.dispatch('queue/currentIndex', trackIndex)
|
|
||||||
}
|
|
||||||
self.addMessage(tracks)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
|
||||||
},
|
|
||||||
addNext (next) {
|
|
||||||
const self = this
|
|
||||||
const wasEmpty = this.$store.state.queue.tracks.length === 0
|
|
||||||
this.getPlayableTracks().then((tracks) => {
|
|
||||||
self.$store.dispatch('queue/appendMany', { tracks: tracks, index: self.$store.state.queue.currentIndex + 1 }).then(() => self.addMessage(tracks))
|
|
||||||
const goNext = next && !wasEmpty
|
|
||||||
if (goNext) {
|
|
||||||
self.$store.dispatch('queue/next')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
jQuery(self.$el).find('.ui.dropdown').dropdown('hide')
|
|
||||||
},
|
|
||||||
addMessage (tracks) {
|
|
||||||
if (tracks.length < 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const msg = this.$npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length)
|
|
||||||
this.$store.commit('ui/addMessage', {
|
|
||||||
content: this.$gettextInterpolate(msg, { count: tracks.length }),
|
|
||||||
date: new Date()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,104 +0,0 @@
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
methods: {
|
|
||||||
getReportableObjs ({ track, album, artist, playlist, account, library, channel }) {
|
|
||||||
const reportableObjs = []
|
|
||||||
if (account) {
|
|
||||||
const accountLabel = this.$pgettext('*/Moderation/*/Verb', 'Report @%{ username }…')
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$gettextInterpolate(accountLabel, { username: account.preferred_username }),
|
|
||||||
target: {
|
|
||||||
type: 'account',
|
|
||||||
_obj: account,
|
|
||||||
full_username: account.full_username,
|
|
||||||
label: account.full_username,
|
|
||||||
typeLabel: this.$pgettext('*/*/*/Noun', 'Account')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (track) {
|
|
||||||
album = track.album
|
|
||||||
artist = track.artist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (track) {
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', 'Report this track…'),
|
|
||||||
target: {
|
|
||||||
type: 'track',
|
|
||||||
id: track.id,
|
|
||||||
_obj: track,
|
|
||||||
label: track.title,
|
|
||||||
typeLabel: this.$pgettext('*/*/*/Noun', 'Track')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
album = track.album
|
|
||||||
artist = track.artist
|
|
||||||
}
|
|
||||||
if (album) {
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', 'Report this album…'),
|
|
||||||
target: {
|
|
||||||
type: 'album',
|
|
||||||
id: album.id,
|
|
||||||
label: album.title,
|
|
||||||
_obj: album,
|
|
||||||
typeLabel: this.$pgettext('*/*/*', 'Album')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!artist) {
|
|
||||||
artist = album.artist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel) {
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', 'Report this channel…'),
|
|
||||||
target: {
|
|
||||||
type: 'channel',
|
|
||||||
uuid: channel.uuid,
|
|
||||||
label: channel.artist.name,
|
|
||||||
_obj: channel,
|
|
||||||
typeLabel: this.$pgettext('*/*/*', 'Channel')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (artist) {
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', 'Report this artist…'),
|
|
||||||
target: {
|
|
||||||
type: 'artist',
|
|
||||||
id: artist.id,
|
|
||||||
label: artist.name,
|
|
||||||
_obj: artist,
|
|
||||||
typeLabel: this.$pgettext('*/*/*/Noun', 'Artist')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (playlist) {
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', 'Report this playlist…'),
|
|
||||||
target: {
|
|
||||||
type: 'playlist',
|
|
||||||
id: playlist.id,
|
|
||||||
label: playlist.name,
|
|
||||||
_obj: playlist,
|
|
||||||
typeLabel: this.$pgettext('*/*/*', 'Playlist')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (library) {
|
|
||||||
reportableObjs.push({
|
|
||||||
label: this.$pgettext('*/Moderation/*/Verb', 'Report this library…'),
|
|
||||||
target: {
|
|
||||||
type: 'library',
|
|
||||||
uuid: library.uuid,
|
|
||||||
label: library.name,
|
|
||||||
_obj: library,
|
|
||||||
typeLabel: this.$pgettext('*/*/*/Noun', 'Library')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return reportableObjs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,68 +0,0 @@
|
||||||
<script>
|
|
||||||
|
|
||||||
import { normalizeQuery, parseTokens, compileTokens } from '~/utils/search'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
defaultQuery: { type: String, required: false, default: '' },
|
|
||||||
updateUrl: { type: Boolean, required: false, default: false }
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
'search.query' (newValue) {
|
|
||||||
this.search.tokens = parseTokens(normalizeQuery(newValue))
|
|
||||||
},
|
|
||||||
'search.tokens': {
|
|
||||||
handler (newValue) {
|
|
||||||
const newQuery = compileTokens(newValue)
|
|
||||||
if (this.updateUrl) {
|
|
||||||
const params = {}
|
|
||||||
if (newQuery) {
|
|
||||||
params.q = newQuery
|
|
||||||
}
|
|
||||||
this.$router.replace({
|
|
||||||
query: params
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.search.query = newQuery
|
|
||||||
this.page = 1
|
|
||||||
this.fetchData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
deep: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getTokenValue (key, fallback) {
|
|
||||||
const matching = this.search.tokens.filter(t => {
|
|
||||||
return t.field === key
|
|
||||||
})
|
|
||||||
if (matching.length > 0) {
|
|
||||||
return matching[0].value
|
|
||||||
}
|
|
||||||
return fallback
|
|
||||||
},
|
|
||||||
addSearchToken (key, value) {
|
|
||||||
value = String(value)
|
|
||||||
if (!value) {
|
|
||||||
// we remove existing matching tokens, if any
|
|
||||||
this.search.tokens = this.search.tokens.filter(t => {
|
|
||||||
return t.field !== key
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
const existing = this.search.tokens.filter(t => {
|
|
||||||
return t.field === key
|
|
||||||
})
|
|
||||||
if (existing.length > 0) {
|
|
||||||
// we replace the value in existing tokens, if any
|
|
||||||
existing.forEach(t => {
|
|
||||||
t.value = value
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// we add a new token
|
|
||||||
this.search.tokens.push({ field: key, value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import axios from 'axios'
|
||||||
|
import { ContentFilter } from '~/store/moderation'
|
||||||
|
import usePlayer from '~/composables/audio/usePlayer'
|
||||||
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
|
import { useCurrentElement } from '@vueuse/core'
|
||||||
|
|
||||||
|
export interface PlayOptionsProps {
|
||||||
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
|
track?: Track | null
|
||||||
|
artist?: Artist | null
|
||||||
|
album?: Album | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
account?: Actor | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props: PlayOptionsProps) => {
|
||||||
|
// TODO (wvffle): Test if we can defineProps in composable
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const { resume, pause, playing } = usePlayer()
|
||||||
|
const { currentTrack } = useQueue()
|
||||||
|
|
||||||
|
const playable = computed(() => {
|
||||||
|
if (props.isPlayable) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.track) {
|
||||||
|
return props.track.uploads?.length > 0
|
||||||
|
} else if (props.artist) {
|
||||||
|
return props.artist.tracks_count > 0
|
||||||
|
|| props.artist.albums.some((album) => album.is_playable === true)
|
||||||
|
} else if (props.tracks) {
|
||||||
|
return props.tracks.some((track) => (track.uploads?.length ?? 0) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterableArtist = computed(() => props.track?.artist ?? props.album?.artist ?? props.artist)
|
||||||
|
const filterArtist = () => store.dispatch('moderation/hide', { type: 'artist', target: filterableArtist.value })
|
||||||
|
|
||||||
|
const { $npgettext } = useGettext()
|
||||||
|
const addMessage = (tracks: Track[]) => {
|
||||||
|
if (!tracks.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
content: $npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length, {
|
||||||
|
count: tracks.length.toString()
|
||||||
|
}),
|
||||||
|
date: new Date()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTracksPage = async (params: object, page = 1, tracks: Track[] = []): Promise<Track[]> => {
|
||||||
|
if (page > 11) {
|
||||||
|
// it's 10 * 100 tracks already, let's stop here
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
// when fetching artists/or album tracks, sometimes, we may have to fetch
|
||||||
|
// multiple pages
|
||||||
|
const response = await axios.get('tracks/', {
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
page_size: 100,
|
||||||
|
page,
|
||||||
|
hidden: '',
|
||||||
|
playable: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tracks.push(...response.data.results)
|
||||||
|
if (response.data.next) {
|
||||||
|
return getTracksPage(params, page + 1, tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const getPlayableTracks = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
const tracks: Track[] = []
|
||||||
|
|
||||||
|
// TODO (wvffle): There is no channel?
|
||||||
|
if (props.tracks?.length) {
|
||||||
|
tracks.push(...props.tracks)
|
||||||
|
} else if (props.track) {
|
||||||
|
if (props.track.uploads?.length) {
|
||||||
|
tracks.push(props.track)
|
||||||
|
} else {
|
||||||
|
// fetch uploads from api
|
||||||
|
const response = await axios.get(`tracks/${props.track.id}/`)
|
||||||
|
tracks.push(response.data as Track)
|
||||||
|
}
|
||||||
|
} else if (props.playlist) {
|
||||||
|
const response = await axios.get(`playlists/${props.playlist.id}/tracks/`)
|
||||||
|
const playlistTracks = (response.data.results as Array<{ track: Track }>).map(({ track }) => track as Track)
|
||||||
|
|
||||||
|
const artistIds = store.getters['moderation/artistFilters']().map((filter: ContentFilter) => filter.target.id)
|
||||||
|
if (artistIds.length) {
|
||||||
|
tracks.push(...playlistTracks.filter((track) => {
|
||||||
|
return !((artistIds.includes(track.artist?.id) || track.album) && artistIds.includes(track.album?.artist.id))
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
tracks.push(...playlistTracks)
|
||||||
|
}
|
||||||
|
} else if (props.artist) {
|
||||||
|
tracks.push(...await getTracksPage({ artist: props.artist.id, include_channels: 'true', ordering: 'album__release_date,disc_number,position' }))
|
||||||
|
} else if (props.album) {
|
||||||
|
tracks.push(...await getTracksPage({ album: props.album.id, include_channels: 'true', ordering: 'disc_number,position' }))
|
||||||
|
} else if (props.library) {
|
||||||
|
tracks.push(...await getTracksPage({ library: props.library.uuid, ordering: '-creation_date' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO (wvffle): It was behind 250ms timeout, why?
|
||||||
|
isLoading.value = false
|
||||||
|
|
||||||
|
return tracks.filter(track => track.uploads?.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = useCurrentElement()
|
||||||
|
const enqueue = async () => {
|
||||||
|
// @ts-expect-error dropdown is from semantic ui
|
||||||
|
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
||||||
|
|
||||||
|
const tracks = await getPlayableTracks()
|
||||||
|
store.dispatch('queue/appendMany', { tracks: tracks }).then(() => addMessage(tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueueNext = async (next = false) => {
|
||||||
|
// @ts-expect-error dropdown is from semantic ui
|
||||||
|
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
||||||
|
|
||||||
|
const tracks = await getPlayableTracks()
|
||||||
|
|
||||||
|
const wasEmpty = store.state.queue.tracks.length === 0
|
||||||
|
await store.dispatch('queue/appendMany', { tracks, index: store.state.queue.currentIndex + 1 })
|
||||||
|
|
||||||
|
if (next && !wasEmpty) {
|
||||||
|
await store.dispatch('queue/next')
|
||||||
|
resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacePlay = async () => {
|
||||||
|
store.dispatch('queue/clean')
|
||||||
|
|
||||||
|
// @ts-expect-error dropdown is from semantic ui
|
||||||
|
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
||||||
|
|
||||||
|
const tracks = await getPlayableTracks()
|
||||||
|
await store.dispatch('queue/appendMany', { tracks })
|
||||||
|
|
||||||
|
if (props.track && props.tracks?.length) {
|
||||||
|
// set queue position to selected track
|
||||||
|
const trackIndex = props.tracks.findIndex(track => track.id === props.track?.id)
|
||||||
|
store.dispatch('queue/currentIndex', trackIndex)
|
||||||
|
} else {
|
||||||
|
store.dispatch('queue/currentIndex', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
resume()
|
||||||
|
addMessage(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activateTrack = (track: Track, index: number) => {
|
||||||
|
if (playing.value && track.id === currentTrack.value?.id) {
|
||||||
|
pause()
|
||||||
|
} else if (!playing.value && track.id === currentTrack.value?.id) {
|
||||||
|
resume()
|
||||||
|
} else {
|
||||||
|
replacePlay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
playable,
|
||||||
|
filterableArtist,
|
||||||
|
filterArtist,
|
||||||
|
enqueue,
|
||||||
|
enqueueNext,
|
||||||
|
replacePlay,
|
||||||
|
activateTrack,
|
||||||
|
isLoading
|
||||||
|
}
|
||||||
|
}
|
|
@ -44,7 +44,7 @@ const playTrack = async (track: Track, oldTrack?: Track) => {
|
||||||
.then(response => response.data, () => null)
|
.then(response => response.data, () => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track == null) {
|
if (track === null) {
|
||||||
store.commit('player/isLoadingAudio', false)
|
store.commit('player/isLoadingAudio', false)
|
||||||
store.dispatch('player/trackErrored')
|
store.dispatch('player/trackErrored')
|
||||||
return
|
return
|
||||||
|
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
|
import { gettext } from '~/init/locale'
|
||||||
|
import store from '~/store'
|
||||||
|
const { $pgettext } = gettext
|
||||||
|
|
||||||
|
interface Objects {
|
||||||
|
track?: Track | null
|
||||||
|
album?: Album | null
|
||||||
|
artist?: Artist | null
|
||||||
|
playlist?: Playlist | null
|
||||||
|
account?: Actor | null
|
||||||
|
library?: Library | null
|
||||||
|
channel?: Channel | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportableObject {
|
||||||
|
label: string,
|
||||||
|
target: {
|
||||||
|
type: keyof Objects
|
||||||
|
label: string
|
||||||
|
typeLabel: string
|
||||||
|
_obj: Objects[keyof Objects]
|
||||||
|
|
||||||
|
full_username?: string
|
||||||
|
id?: string
|
||||||
|
uuid?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReportableObjects = ({ track, album, artist, playlist, account, library, channel }: Objects) => {
|
||||||
|
const reportableObjs: ReportableObject[] = []
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report @%{ username }…', { username: account.preferred_username }),
|
||||||
|
target: {
|
||||||
|
type: 'account',
|
||||||
|
_obj: account,
|
||||||
|
full_username: account.full_username,
|
||||||
|
label: account.full_username,
|
||||||
|
typeLabel: $pgettext('*/*/*/Noun', 'Account')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report this track…'),
|
||||||
|
target: {
|
||||||
|
type: 'track',
|
||||||
|
id: track.id,
|
||||||
|
_obj: track,
|
||||||
|
label: track.title,
|
||||||
|
typeLabel: $pgettext('*/*/*/Noun', 'Track')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
album = track.album
|
||||||
|
artist = track.artist
|
||||||
|
}
|
||||||
|
|
||||||
|
if (album) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report this album…'),
|
||||||
|
target: {
|
||||||
|
type: 'album',
|
||||||
|
id: album.id,
|
||||||
|
label: album.title,
|
||||||
|
_obj: album,
|
||||||
|
typeLabel: $pgettext('*/*/*', 'Album')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
artist = album.artist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report this channel…'),
|
||||||
|
target: {
|
||||||
|
type: 'channel',
|
||||||
|
uuid: channel.uuid,
|
||||||
|
label: channel.artist?.name ?? $pgettext('*/*/*', 'Unknown artist'),
|
||||||
|
_obj: channel,
|
||||||
|
typeLabel: $pgettext('*/*/*', 'Channel')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (artist) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report this artist…'),
|
||||||
|
target: {
|
||||||
|
type: 'artist',
|
||||||
|
id: artist.id,
|
||||||
|
label: artist.name,
|
||||||
|
_obj: artist,
|
||||||
|
typeLabel: $pgettext('*/*/*/Noun', 'Artist')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playlist) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report this playlist…'),
|
||||||
|
target: {
|
||||||
|
type: 'playlist',
|
||||||
|
id: playlist.id,
|
||||||
|
label: playlist.name,
|
||||||
|
_obj: playlist,
|
||||||
|
typeLabel: $pgettext('*/*/*', 'Playlist')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library) {
|
||||||
|
reportableObjs.push({
|
||||||
|
label: $pgettext('*/Moderation/*/Verb', 'Report this library…'),
|
||||||
|
target: {
|
||||||
|
type: 'library',
|
||||||
|
uuid: library.uuid,
|
||||||
|
label: library.name,
|
||||||
|
_obj: library,
|
||||||
|
typeLabel: $pgettext('*/*/*/Noun', 'Library')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return reportableObjs
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = (obj: ReportableObject) => {
|
||||||
|
store.dispatch('moderation/report', obj.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => ({
|
||||||
|
getReportableObjects,
|
||||||
|
report
|
||||||
|
})
|
|
@ -0,0 +1,13 @@
|
||||||
|
|
||||||
|
import { NavigationGuardNext, RouteLocationNormalized } from 'vue-router'
|
||||||
|
import { Permission } from '~/store/auth'
|
||||||
|
import store from '~/store'
|
||||||
|
|
||||||
|
export const hasPermissions = (permission: Permission) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
|
||||||
|
if (store.state.auth.authenticated && store.state.auth.availablePermissions[permission]) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Not authenticated. Redirecting to library.')
|
||||||
|
next({ name: 'library.index' })
|
||||||
|
}
|
|
@ -1,21 +1,11 @@
|
||||||
import { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
|
import { RouteRecordRaw } from 'vue-router'
|
||||||
import { Permission } from '~/store/auth'
|
import { hasPermissions } from '~/router/guards'
|
||||||
import store from '~/store'
|
|
||||||
|
|
||||||
const hasPermissions = (permission: Permission) => (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
|
|
||||||
if (store.state.auth.authenticated && store.state.auth.availablePermissions[permission]) {
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Not authenticated. Redirecting to library.')
|
|
||||||
next({ name: 'library.index' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: '/manage/settings',
|
path: '/manage/settings',
|
||||||
name: 'manage.settings',
|
name: 'manage.settings',
|
||||||
beforeEnter: hasPermissions('admin'),
|
beforeEnter: hasPermissions('settings'),
|
||||||
component: () => import('~/views/admin/Settings.vue')
|
component: () => import('~/views/admin/Settings.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -117,7 +107,7 @@ export default [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/manage/users',
|
path: '/manage/users',
|
||||||
beforeEnter: hasPermissions('admin'),
|
beforeEnter: hasPermissions('settings'),
|
||||||
component: () => import('~/views/admin/users/Base.vue'),
|
component: () => import('~/views/admin/users/Base.vue'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { RouteRecordRaw } from 'vue-router'
|
import { RouteRecordRaw } from 'vue-router'
|
||||||
|
import store from '~/store'
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{ suffix: '.full', path: '/@:username@:domain' },
|
{ suffix: '.full', path: '/@:username@:domain' },
|
||||||
|
@ -8,6 +9,13 @@ export default [
|
||||||
path: route.path,
|
path: route.path,
|
||||||
name: `profile${route.suffix}`,
|
name: `profile${route.suffix}`,
|
||||||
component: () => import('~/views/auth/ProfileBase.vue'),
|
component: () => import('~/views/auth/ProfileBase.vue'),
|
||||||
|
beforeEnter (to, from, next) {
|
||||||
|
if (!store.state.auth.authenticated && to.query.domain && store.getters['instance/domain'] !== to.query.domain) {
|
||||||
|
return next({ name: 'login', query: { next: to.fullPath } })
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
},
|
||||||
props: true,
|
props: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -22,6 +22,7 @@ interface Profile {
|
||||||
full_username: string
|
full_username: string
|
||||||
instance_support_message_display_date: string
|
instance_support_message_display_date: string
|
||||||
funkwhale_support_message_display_date: string
|
funkwhale_support_message_display_date: string
|
||||||
|
is_superuser: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScopedTokens {
|
interface ScopedTokens {
|
||||||
|
@ -140,7 +141,7 @@ const store: Module<State, RootState> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(payload)) {
|
for (const [key, value] of Object.entries(payload)) {
|
||||||
state.profile[key as keyof Profile] = value
|
state.profile[key as keyof Profile] = value as never
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
oauthApp: (state, payload) => {
|
oauthApp: (state, payload) => {
|
||||||
|
|
|
@ -19,11 +19,12 @@ export interface State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentFilter {
|
export interface ContentFilter {
|
||||||
uuid: string
|
uuid: string
|
||||||
creation_date: Date
|
creation_date: Date
|
||||||
target: {
|
target: {
|
||||||
type: 'artist'
|
type: 'artist'
|
||||||
|
id: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,22 +30,30 @@ export interface ThemeEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track stuff
|
// Track stuff
|
||||||
export type ContentCategory = 'podcast'
|
export type ContentCategory = 'podcast' | 'music'
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
id: string
|
id: string
|
||||||
|
fid: string
|
||||||
|
mbid?: string
|
||||||
|
|
||||||
name: string
|
name: string
|
||||||
description: Content
|
description: Content
|
||||||
cover?: Cover
|
cover?: Cover
|
||||||
|
channel?: Channel
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
|
||||||
content_category: ContentCategory
|
content_category: ContentCategory
|
||||||
albums: Album[]
|
albums: Album[]
|
||||||
|
tracks_count: number
|
||||||
|
attributed_to: Actor
|
||||||
|
is_local: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Album {
|
export interface Album {
|
||||||
id: string
|
id: string
|
||||||
|
fid: string
|
||||||
|
mbid?: string
|
||||||
|
|
||||||
title: string
|
title: string
|
||||||
description: Content
|
description: Content
|
||||||
|
@ -56,10 +64,15 @@ export interface Album {
|
||||||
artist: Artist
|
artist: Artist
|
||||||
tracks_count: number
|
tracks_count: number
|
||||||
tracks: Track[]
|
tracks: Track[]
|
||||||
|
|
||||||
|
is_playable: boolean
|
||||||
|
is_local: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string
|
id: string
|
||||||
|
fid: string
|
||||||
|
mbid?: string
|
||||||
|
|
||||||
title: string
|
title: string
|
||||||
description: Content
|
description: Content
|
||||||
|
@ -72,19 +85,63 @@ export interface Track {
|
||||||
|
|
||||||
album?: Album
|
album?: Album
|
||||||
artist?: Artist
|
artist?: Artist
|
||||||
|
disc_number: number
|
||||||
|
|
||||||
// TODO (wvffle): Make sure it really has listen_url
|
|
||||||
listen_url: string
|
listen_url: string
|
||||||
|
creation_date: string
|
||||||
|
attributed_to: Actor
|
||||||
|
|
||||||
|
is_playable: boolean
|
||||||
|
is_local: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string
|
id: string
|
||||||
|
uuid: string
|
||||||
artist?: Artist
|
artist?: Artist
|
||||||
|
actor: Actor
|
||||||
|
attributed_to: Actor
|
||||||
|
url?: string
|
||||||
|
rss_url: string
|
||||||
|
subscriptions_count: number
|
||||||
|
downloads_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Library {
|
||||||
|
id: string
|
||||||
|
uuid: string
|
||||||
|
fid?: string
|
||||||
|
name: string
|
||||||
|
actor: Actor
|
||||||
|
uploads_count: number
|
||||||
|
size: number
|
||||||
|
description: string
|
||||||
|
privacy_level: 'everyone' | 'instance' | 'me'
|
||||||
|
creation_date: string
|
||||||
|
follow?: LibraryFollow
|
||||||
|
latest_scan: LibraryScan
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryScan {
|
||||||
|
processed_files: number
|
||||||
|
total_files: number
|
||||||
|
status: 'scanning' | 'pending' | 'finished' | 'errored'
|
||||||
|
errored_files: number
|
||||||
|
modification_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibraryFollow {
|
||||||
|
uuid: string
|
||||||
|
approved: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Cover {
|
export interface Cover {
|
||||||
uuid: string
|
uuid: string
|
||||||
|
urls: {
|
||||||
|
original: string
|
||||||
|
medium_square_crop: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface License {
|
export interface License {
|
||||||
|
@ -188,8 +245,12 @@ export interface FSLogs {
|
||||||
logs: string[]
|
logs: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yet uncategorized stuff
|
// Profile stuff
|
||||||
export interface Actor {
|
export interface Actor {
|
||||||
|
fid?: string
|
||||||
|
name?: string
|
||||||
|
icon?: Cover
|
||||||
|
summary: string
|
||||||
preferred_username: string
|
preferred_username: string
|
||||||
full_username: string
|
full_username: string
|
||||||
is_local: boolean
|
is_local: boolean
|
||||||
|
|
|
@ -1,3 +1,64 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Actor } from '~/types'
|
||||||
|
import axios from 'axios'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { onBeforeRouteUpdate } from 'vue-router'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
domain?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
domain: null
|
||||||
|
})
|
||||||
|
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const object = ref<Actor | null>(null)
|
||||||
|
|
||||||
|
const displayName = computed(() => object.value?.name ?? object.value?.preferred_username)
|
||||||
|
const fullUsername = computed(() => props.domain
|
||||||
|
? `${props.username}@${props.domain}`
|
||||||
|
: `${props.username}@${store.getters['instance/domain']}`
|
||||||
|
)
|
||||||
|
|
||||||
|
const routerParams = computed(() => props.domain
|
||||||
|
? { username: props.username, domain: props.domain }
|
||||||
|
: { username: props.username }
|
||||||
|
)
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
usernameProfile: $pgettext('Head/Profile/Title', "%{ username }'s profile", { username: props.username })
|
||||||
|
}))
|
||||||
|
|
||||||
|
onBeforeRouteUpdate((to) => {
|
||||||
|
to.meta.preserveScrollPosition = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const fetchData = async () => {
|
||||||
|
object.value = null
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`federation/actors/${fullUsername.value}/`)
|
||||||
|
object.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(props, fetchData, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.usernameProfile"
|
v-title="labels.usernameProfile"
|
||||||
|
@ -36,11 +97,11 @@
|
||||||
>View on %{ domain }</translate>
|
>View on %{ domain }</translate>
|
||||||
</a>
|
</a>
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({account: object})"
|
v-for="obj in getReportableObjects({account: object})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
role="button"
|
role="button"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -124,7 +185,7 @@
|
||||||
<div class="ui hidden divider" />
|
<div class="ui hidden divider" />
|
||||||
<router-view
|
<router-view
|
||||||
:object="object"
|
:object="object"
|
||||||
@updated="fetch"
|
@updated="fetchData"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -133,82 +194,3 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
beforeRouteUpdate (to, from, next) {
|
|
||||||
to.meta.preserveScrollPosition = true
|
|
||||||
next()
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
username: { type: String, required: true },
|
|
||||||
domain: { type: String, required: false, default: null }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
object: null,
|
|
||||||
isLoading: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
labels () {
|
|
||||||
const msg = this.$pgettext('Head/Profile/Title', "%{ username }'s profile")
|
|
||||||
const usernameProfile = this.$gettextInterpolate(msg, {
|
|
||||||
username: this.username
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
usernameProfile
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fullUsername () {
|
|
||||||
if (this.username && this.domain) {
|
|
||||||
return `${this.username}@${this.domain}`
|
|
||||||
} else {
|
|
||||||
return `${this.username}@${this.$store.getters['instance/domain']}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
routerParams () {
|
|
||||||
if (this.domain) {
|
|
||||||
return { username: this.username, domain: this.domain }
|
|
||||||
} else {
|
|
||||||
return { username: this.username }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
displayName () {
|
|
||||||
return this.object.name || this.object.preferred_username
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
domain () {
|
|
||||||
this.fetch()
|
|
||||||
},
|
|
||||||
username () {
|
|
||||||
this.fetch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created () {
|
|
||||||
const authenticated = this.$store.state.auth.authenticated
|
|
||||||
if (!authenticated && this.domain && this.$store.getters['instance/domain'] !== this.domain) {
|
|
||||||
this.$router.push({ name: 'login', query: { next: this.$route.fullPath } })
|
|
||||||
} else {
|
|
||||||
this.fetch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
fetch () {
|
|
||||||
const self = this
|
|
||||||
self.object = null
|
|
||||||
self.isLoading = true
|
|
||||||
axios.get(`federation/actors/${this.fullUsername}/`).then((response) => {
|
|
||||||
self.object = response.data
|
|
||||||
self.isLoading = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,120 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Channel } from '~/types'
|
||||||
|
import axios from 'axios'
|
||||||
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
||||||
|
import Modal from '~/components/semantic/Modal.vue'
|
||||||
|
import TagsList from '~/components/tags/List.vue'
|
||||||
|
import SubscribeButton from '~/components/channels/SubscribeButton.vue'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import ChannelForm from '~/components/audio/ChannelForm.vue'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||||
|
import { computed, ref, reactive, watch, watchEffect } from 'vue'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const object = ref<Channel | null>(null)
|
||||||
|
const editForm = ref()
|
||||||
|
const totalTracks = ref(0)
|
||||||
|
|
||||||
|
const edit = reactive({
|
||||||
|
submittable: false,
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const showEmbedModal = ref(false)
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const showSubscribeModal = ref(false)
|
||||||
|
|
||||||
|
const isOwner = computed(() => store.state.auth.authenticated && object.value?.attributed_to.full_username === store.state.auth.fullUsername)
|
||||||
|
const isPodcast = computed(() => object.value?.artist?.content_category === 'podcast')
|
||||||
|
const isPlayable = computed(() => totalTracks.value > 0)
|
||||||
|
const externalDomain = computed(() => {
|
||||||
|
const parser = document.createElement('a')
|
||||||
|
parser.href = object.value?.url ?? object.value?.rss_url ?? ''
|
||||||
|
return parser.hostname
|
||||||
|
})
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
title: $pgettext('*/*/*', 'Channel')
|
||||||
|
}))
|
||||||
|
|
||||||
|
onBeforeRouteUpdate((to) => {
|
||||||
|
to.meta.preserveScrollPosition = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const fetchData = async () => {
|
||||||
|
showEditModal.value = false
|
||||||
|
edit.loading = false
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`channels/${props.id}`, { params: { refresh: 'true' } })
|
||||||
|
object.value = response.data
|
||||||
|
totalTracks.value = response.data.artist.tracks_count
|
||||||
|
|
||||||
|
if (props.id === response.data.uuid && response.data.actor) {
|
||||||
|
// replace with the pretty channel url if possible
|
||||||
|
const actor = response.data.actor
|
||||||
|
if (actor.is_local) {
|
||||||
|
await router.replace({ name: 'channels.detail', params: { id: actor.preferred_username } })
|
||||||
|
} else {
|
||||||
|
await router.replace({ name: 'channels.detail', params: { id: actor.full_username } })
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
watchEffect(() => {
|
||||||
|
const publication = store.state.channels.latestPublication
|
||||||
|
if (publication?.uploads && publication.channel.uuid === object.value?.uuid) {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!store.state.auth.authenticated && store.getters['instance/domain'] !== object.value?.actor.domain) {
|
||||||
|
router.push({ name: 'login', query: { next: route.fullPath } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['deleted'])
|
||||||
|
const remove = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
await axios.delete(`channels/${object.value?.uuid}`)
|
||||||
|
emit('deleted')
|
||||||
|
return router.push({ name: 'profile.overview', params: { username: store.state.auth.username } })
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSubscriptionCount = (delta: number) => {
|
||||||
|
if (object.value) {
|
||||||
|
object.value.subscriptions_count += delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
v-title="labels.title"
|
v-title="labels.title"
|
||||||
|
@ -11,7 +128,7 @@
|
||||||
</div>
|
</div>
|
||||||
<template v-if="object && !isLoading">
|
<template v-if="object && !isLoading">
|
||||||
<section
|
<section
|
||||||
v-title="object.artist.name"
|
v-title="object.artist?.name"
|
||||||
class="ui head vertical stripe segment container"
|
class="ui head vertical stripe segment container"
|
||||||
>
|
>
|
||||||
<div class="ui stackable grid">
|
<div class="ui stackable grid">
|
||||||
|
@ -19,7 +136,7 @@
|
||||||
<div class="ui two column grid">
|
<div class="ui two column grid">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<img
|
<img
|
||||||
v-if="object.artist.cover"
|
v-if="object.artist?.cover"
|
||||||
alt=""
|
alt=""
|
||||||
class="huge channel-image"
|
class="huge channel-image"
|
||||||
:src="$store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)"
|
:src="$store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)"
|
||||||
|
@ -31,7 +148,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui column right aligned">
|
<div class="ui column right aligned">
|
||||||
<tags-list
|
<tags-list
|
||||||
v-if="object.artist.tags && object.artist.tags.length > 0"
|
v-if="object.artist?.tags && object.artist?.tags.length > 0"
|
||||||
:tags="object.artist.tags"
|
:tags="object.artist.tags"
|
||||||
/>
|
/>
|
||||||
<actor-link
|
<actor-link
|
||||||
|
@ -43,7 +160,7 @@
|
||||||
<template v-if="totalTracks > 0">
|
<template v-if="totalTracks > 0">
|
||||||
<div class="ui hidden very small divider" />
|
<div class="ui hidden very small divider" />
|
||||||
<translate
|
<translate
|
||||||
v-if="object.artist.content_category === 'podcast'"
|
v-if="object.artist?.content_category === 'podcast'"
|
||||||
translate-context="Content/Channel/Paragraph"
|
translate-context="Content/Channel/Paragraph"
|
||||||
translate-plural="%{ count } episodes"
|
translate-plural="%{ count } episodes"
|
||||||
:translate-n="totalTracks"
|
:translate-n="totalTracks"
|
||||||
|
@ -65,16 +182,16 @@
|
||||||
<br><translate
|
<br><translate
|
||||||
translate-context="Content/Channel/Paragraph"
|
translate-context="Content/Channel/Paragraph"
|
||||||
translate-plural="%{ count } subscribers"
|
translate-plural="%{ count } subscribers"
|
||||||
:translate-n="object.subscriptions_count"
|
:translate-n="object?.subscriptions_count"
|
||||||
:translate-params="{count: object.subscriptions_count}"
|
:translate-params="{count: object?.subscriptions_count}"
|
||||||
>
|
>
|
||||||
%{ count } subscriber
|
%{ count } subscriber
|
||||||
</translate>
|
</translate>
|
||||||
<br><translate
|
<br><translate
|
||||||
translate-context="Content/Channel/Paragraph"
|
translate-context="Content/Channel/Paragraph"
|
||||||
translate-plural="%{ count } listenings"
|
translate-plural="%{ count } listenings"
|
||||||
:translate-n="object.downloads_count"
|
:translate-n="object?.downloads_count"
|
||||||
:translate-params="{count: object.downloads_count}"
|
:translate-params="{count: object?.downloads_count}"
|
||||||
>
|
>
|
||||||
%{ count } listening
|
%{ count } listening
|
||||||
</translate>
|
</translate>
|
||||||
|
@ -106,8 +223,8 @@
|
||||||
</h3>
|
</h3>
|
||||||
<subscribe-button
|
<subscribe-button
|
||||||
:channel="object"
|
:channel="object"
|
||||||
@subscribed="object.subscriptions_count += 1"
|
@subscribed="updateSubscriptionCount(1)"
|
||||||
@unsubscribed="object.subscriptions_count -= 1"
|
@unsubscribed="updateSubscriptionCount(-1)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="object.rss_url">
|
<template v-if="object.rss_url">
|
||||||
|
@ -181,11 +298,11 @@
|
||||||
</a>
|
</a>
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
<a
|
<a
|
||||||
v-for="obj in getReportableObjs({account: object.attributed_to, channel: object})"
|
v-for="obj in getReportableObjects({account: object.attributed_to, channel: object})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
href=""
|
href=""
|
||||||
class="basic item"
|
class="basic item"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</a>
|
</a>
|
||||||
|
@ -253,9 +370,9 @@
|
||||||
<h1 class="ui header">
|
<h1 class="ui header">
|
||||||
<div
|
<div
|
||||||
class="left aligned"
|
class="left aligned"
|
||||||
:title="object.artist.name"
|
:title="object.artist?.name"
|
||||||
>
|
>
|
||||||
{{ object.artist.name }}
|
{{ object.artist?.name }}
|
||||||
<div class="ui hidden very small divider" />
|
<div class="ui hidden very small divider" />
|
||||||
<div
|
<div
|
||||||
v-if="object.actor"
|
v-if="object.actor"
|
||||||
|
@ -311,8 +428,8 @@
|
||||||
<div class="ui buttons">
|
<div class="ui buttons">
|
||||||
<subscribe-button
|
<subscribe-button
|
||||||
:channel="object"
|
:channel="object"
|
||||||
@subscribed="object.subscriptions_count += 1"
|
@subscribed="updateSubscriptionCount(1)"
|
||||||
@unsubscribed="object.subscriptions_count -= 1"
|
@unsubscribed="updateSubscriptionCount(-1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -328,7 +445,7 @@
|
||||||
<div class="scrolling content">
|
<div class="scrolling content">
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<embed-wizard
|
<embed-wizard
|
||||||
:id="object.artist.id"
|
:id="object.artist?.id"
|
||||||
type="artist"
|
type="artist"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -347,7 +464,7 @@
|
||||||
>
|
>
|
||||||
<h4 class="header">
|
<h4 class="header">
|
||||||
<translate
|
<translate
|
||||||
v-if="object.artist.content_category === 'podcast'"
|
v-if="object.artist?.content_category === 'podcast'"
|
||||||
translate-context="Content/Channel/*"
|
translate-context="Content/Channel/*"
|
||||||
>
|
>
|
||||||
Podcast channel
|
Podcast channel
|
||||||
|
@ -363,7 +480,7 @@
|
||||||
<channel-form
|
<channel-form
|
||||||
ref="editForm"
|
ref="editForm"
|
||||||
:object="object"
|
:object="object"
|
||||||
@loading="edit.isLoading = $event"
|
@loading="edit.loading = $event"
|
||||||
@submittable="edit.submittable = $event"
|
@submittable="edit.submittable = $event"
|
||||||
@updated="fetchData"
|
@updated="fetchData"
|
||||||
/>
|
/>
|
||||||
|
@ -376,9 +493,9 @@
|
||||||
</translate>
|
</translate>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']"
|
:class="['ui', 'primary', 'confirm', {loading: edit.loading}, 'button']"
|
||||||
:disabled="!edit.submittable || null"
|
:disabled="!edit.submittable"
|
||||||
@click.stop="$refs.editForm.submit"
|
@click.stop="editForm?.submit"
|
||||||
>
|
>
|
||||||
<translate translate-context="*/Channels/Button.Label">
|
<translate translate-context="*/Channels/Button.Label">
|
||||||
Update channel
|
Update channel
|
||||||
|
@ -389,7 +506,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$store.getters['ui/layoutVersion'] === 'large'">
|
<div v-if="$store.getters['ui/layoutVersion'] === 'large'">
|
||||||
<rendered-description
|
<rendered-description
|
||||||
:content="object.artist.description"
|
:content="object.artist?.description"
|
||||||
:update-url="`channels/${object.uuid}/`"
|
:update-url="`channels/${object.uuid}/`"
|
||||||
:can-update="false"
|
:can-update="false"
|
||||||
@updated="object = $event"
|
@updated="object = $event"
|
||||||
|
@ -438,126 +555,3 @@
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
|
||||||
import EmbedWizard from '~/components/audio/EmbedWizard.vue'
|
|
||||||
import Modal from '~/components/semantic/Modal.vue'
|
|
||||||
import TagsList from '~/components/tags/List.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
|
|
||||||
import SubscribeButton from '~/components/channels/SubscribeButton.vue'
|
|
||||||
import ChannelForm from '~/components/audio/ChannelForm.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
PlayButton,
|
|
||||||
EmbedWizard,
|
|
||||||
Modal,
|
|
||||||
TagsList,
|
|
||||||
SubscribeButton,
|
|
||||||
ChannelForm
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
beforeRouteUpdate (to, from, next) {
|
|
||||||
to.meta.preserveScrollPosition = true
|
|
||||||
next()
|
|
||||||
},
|
|
||||||
props: { id: { type: String, required: true } },
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
object: null,
|
|
||||||
totalTracks: 0,
|
|
||||||
latestTracks: null,
|
|
||||||
showEmbedModal: false,
|
|
||||||
showEditModal: false,
|
|
||||||
showSubscribeModal: false,
|
|
||||||
edit: {
|
|
||||||
submittable: false,
|
|
||||||
loading: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
externalDomain () {
|
|
||||||
const parser = document.createElement('a')
|
|
||||||
parser.href = this.object.url || this.object.rss_url
|
|
||||||
return parser.hostname
|
|
||||||
},
|
|
||||||
|
|
||||||
isOwner () {
|
|
||||||
return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername
|
|
||||||
},
|
|
||||||
isPodcast () {
|
|
||||||
return this.object.artist.content_category === 'podcast'
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
title: this.$pgettext('*/*/*', 'Channel')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentFilter () {
|
|
||||||
return this.$store.getters['moderation/artistFilters']().filter((e) => {
|
|
||||||
return e.target.id === this.object.artist.id
|
|
||||||
})[0]
|
|
||||||
},
|
|
||||||
isPlayable () {
|
|
||||||
return this.totalTracks > 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
id () {
|
|
||||||
this.fetchData()
|
|
||||||
},
|
|
||||||
'$store.state.channels.latestPublication' (v) {
|
|
||||||
if (v && v.uploads && v.channel.uuid === this.object.uuid) {
|
|
||||||
this.fetchData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async created () {
|
|
||||||
await this.fetchData()
|
|
||||||
const authenticated = this.$store.state.auth.authenticated
|
|
||||||
if (!authenticated && this.$store.getters['instance/domain'] !== this.object.actor.domain) {
|
|
||||||
this.$router.push({ name: 'login', query: { next: this.$route.fullPath } })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async fetchData () {
|
|
||||||
const self = this
|
|
||||||
this.showEditModal = false
|
|
||||||
this.edit.isLoading = false
|
|
||||||
this.isLoading = true
|
|
||||||
const channelPromise = axios.get(`channels/${this.id}`, { params: { refresh: 'true' } }).then(response => {
|
|
||||||
self.object = response.data
|
|
||||||
if ((self.id === response.data.uuid) && response.data.actor) {
|
|
||||||
// replace with the pretty channel url if possible
|
|
||||||
const actor = response.data.actor
|
|
||||||
if (actor.is_local) {
|
|
||||||
self.$router.replace({ name: 'channels.detail', params: { id: actor.preferred_username } })
|
|
||||||
} else {
|
|
||||||
self.$router.replace({ name: 'channels.detail', params: { id: actor.full_username } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.totalTracks = response.data.artist.tracks_count
|
|
||||||
self.isLoading = false
|
|
||||||
})
|
|
||||||
await channelPromise
|
|
||||||
},
|
|
||||||
remove () {
|
|
||||||
const self = this
|
|
||||||
self.isLoading = true
|
|
||||||
axios.delete(`channels/${this.object.uuid}`).then((response) => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.$emit('deleted')
|
|
||||||
self.$router.push({ name: 'profile.overview', params: { username: self.$store.state.auth.username } })
|
|
||||||
}, error => {
|
|
||||||
self.isLoading = false
|
|
||||||
self.errors = error.backendErrors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,139 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Library } from '~/types'
|
||||||
|
import axios from 'axios'
|
||||||
|
import RadioButton from '~/components/radios/Button.vue'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { useTimeoutFn } from '@vueuse/core'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialLibrary: Library
|
||||||
|
displayFollow?: boolean
|
||||||
|
displayScan?: boolean
|
||||||
|
displayCopyFid?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
displayFollow: true,
|
||||||
|
displayScan: true,
|
||||||
|
displayCopyFid: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const library = ref(props.initialLibrary)
|
||||||
|
const isLoadingFollow = ref(false)
|
||||||
|
const showScan = ref(false)
|
||||||
|
const latestScan = ref(props.initialLibrary.latest_scan)
|
||||||
|
|
||||||
|
const scanProgress = computed(() => Math.min(latestScan.value.processed_files * 100 / latestScan.value.total_files, 100))
|
||||||
|
const scanStatus = computed(() => latestScan.value?.status ?? 'unknown')
|
||||||
|
const canLaunchScan = computed(() => scanStatus.value !== 'pending' && scanStatus.value !== 'scanning')
|
||||||
|
const radioPlayable = computed(() => (
|
||||||
|
(library.value.actor.is_local || scanStatus.value === 'finished')
|
||||||
|
&& (library.value.privacy_level === 'everyone' || library.value.follow?.approved)
|
||||||
|
))
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
tooltips: {
|
||||||
|
me: $pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'),
|
||||||
|
everyone: $pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const launchScan = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`federation/libraries/${library.value.uuid}/scan/`)
|
||||||
|
if (response.data.status !== 'skipped') {
|
||||||
|
latestScan.value = response.data.scan
|
||||||
|
}
|
||||||
|
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
date: new Date(),
|
||||||
|
content: response.data.status === 'skipped'
|
||||||
|
? $pgettext('Content/Library/Message', 'Scan skipped (previous scan is too recent)')
|
||||||
|
: $pgettext('Content/Library/Message', 'Scan launched')
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['followed', 'deleted'])
|
||||||
|
const follow = async () => {
|
||||||
|
isLoadingFollow.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.post('federation/follows/library/', { target: library.value.uuid })
|
||||||
|
library.value.follow = response.data
|
||||||
|
emit('followed')
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): ==> CORRECTLY HANDLED ERROR HERE <==
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
// TODO (wvffle): Translate
|
||||||
|
content: 'Cannot follow remote library: ' + error,
|
||||||
|
date: new Date()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingFollow.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const unfollow = async () => {
|
||||||
|
isLoadingFollow.value = true
|
||||||
|
try {
|
||||||
|
if (library.value.follow) {
|
||||||
|
await axios.delete(`federation/follows/library/${library.value.follow.uuid}/`)
|
||||||
|
library.value.follow = undefined
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
store.commit('ui/addMessage', {
|
||||||
|
// TODO (wvffle): Translate
|
||||||
|
content: 'Cannot unfollow remote library: ' + error,
|
||||||
|
date: new Date()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingFollow.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchScanStatus = async () => {
|
||||||
|
try {
|
||||||
|
if (!library.value.follow) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(`federation/follows/library/${library.value.follow.uuid}/`)
|
||||||
|
latestScan.value = response.data.target.latest_scan
|
||||||
|
|
||||||
|
if (scanStatus.value === 'pending' || scanStatus.value === 'scanning') {
|
||||||
|
startFetching()
|
||||||
|
} else {
|
||||||
|
stopFetching()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start: startFetching, stop: stopFetching } = useTimeoutFn(fetchScanStatus, 5000, { immediate: false })
|
||||||
|
|
||||||
|
watch(showScan, (shouldShow) => {
|
||||||
|
if (shouldShow) {
|
||||||
|
if (scanStatus.value === 'pending' || scanStatus.value === 'scanning') {
|
||||||
|
fetchScanStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopFetching()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ui card">
|
<div class="ui card">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
@ -12,10 +148,10 @@
|
||||||
<i class="ellipsis vertical large icon nomargin" />
|
<i class="ellipsis vertical large icon nomargin" />
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<button
|
<button
|
||||||
v-for="obj in getReportableObjs({library, account: library.actor})"
|
v-for="obj in getReportableObjects({library, account: library.actor})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
class="item basic"
|
class="item basic"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -228,143 +364,3 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
import RadioButton from '~/components/radios/Button.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
RadioButton
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
props: {
|
|
||||||
initialLibrary: { type: Object, required: true },
|
|
||||||
displayFollow: { type: Boolean, default: true },
|
|
||||||
displayScan: { type: Boolean, default: true },
|
|
||||||
displayCopyFid: { type: Boolean, default: true }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
library: this.initialLibrary,
|
|
||||||
isLoadingFollow: false,
|
|
||||||
showScan: false,
|
|
||||||
scanTimeout: null,
|
|
||||||
latestScan: this.initialLibrary.latest_scan
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
labels () {
|
|
||||||
const me = this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content')
|
|
||||||
const everyone = this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely')
|
|
||||||
|
|
||||||
return {
|
|
||||||
tooltips: {
|
|
||||||
me,
|
|
||||||
everyone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scanProgress () {
|
|
||||||
const scan = this.latestScan
|
|
||||||
const progress = scan.processed_files * 100 / scan.total_files
|
|
||||||
return Math.min(parseInt(progress), 100)
|
|
||||||
},
|
|
||||||
scanStatus () {
|
|
||||||
if (this.latestScan) {
|
|
||||||
return this.latestScan.status
|
|
||||||
}
|
|
||||||
return 'unknown'
|
|
||||||
},
|
|
||||||
canLaunchScan () {
|
|
||||||
if (this.scanStatus === 'pending') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (this.scanStatus === 'scanning') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
radioPlayable () {
|
|
||||||
return (
|
|
||||||
(this.library.actor.is_local || this.scanStatus === 'finished') &&
|
|
||||||
(this.library.privacy_level === 'everyone' || (this.library.follow && this.library.follow.is_approved))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
showScan (newValue, oldValue) {
|
|
||||||
if (newValue) {
|
|
||||||
if (this.scanStatus === 'pending' || this.scanStatus === 'scanning') {
|
|
||||||
this.fetchScanStatus()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.scanTimeout) {
|
|
||||||
clearTimeout(this.scanTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
launchScan () {
|
|
||||||
const self = this
|
|
||||||
const successMsg = this.$pgettext('Content/Library/Message', 'Scan launched')
|
|
||||||
const skippedMsg = this.$pgettext('Content/Library/Message', 'Scan skipped (previous scan is too recent)')
|
|
||||||
axios.post(`federation/libraries/${this.library.uuid}/scan/`).then((response) => {
|
|
||||||
let msg
|
|
||||||
if (response.data.status === 'skipped') {
|
|
||||||
msg = skippedMsg
|
|
||||||
} else {
|
|
||||||
self.latestScan = response.data.scan
|
|
||||||
msg = successMsg
|
|
||||||
}
|
|
||||||
self.$store.commit('ui/addMessage', {
|
|
||||||
content: msg,
|
|
||||||
date: new Date()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
follow () {
|
|
||||||
const self = this
|
|
||||||
this.isLoadingFollow = true
|
|
||||||
axios.post('federation/follows/library/', { target: this.library.uuid }).then((response) => {
|
|
||||||
self.library.follow = response.data
|
|
||||||
self.isLoadingFollow = false
|
|
||||||
self.$emit('followed')
|
|
||||||
}, error => {
|
|
||||||
self.isLoadingFollow = false
|
|
||||||
self.$store.commit('ui/addMessage', {
|
|
||||||
content: 'Cannot follow remote library: ' + error,
|
|
||||||
date: new Date()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
unfollow () {
|
|
||||||
const self = this
|
|
||||||
this.isLoadingFollow = true
|
|
||||||
axios.delete(`federation/follows/library/${this.library.follow.uuid}/`).then((response) => {
|
|
||||||
self.$emit('deleted')
|
|
||||||
self.library.follow = null
|
|
||||||
self.isLoadingFollow = false
|
|
||||||
}, error => {
|
|
||||||
self.isLoadingFollow = false
|
|
||||||
self.$store.commit('ui/addMessage', {
|
|
||||||
content: 'Cannot unfollow remote library: ' + error,
|
|
||||||
date: new Date()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fetchScanStatus () {
|
|
||||||
const self = this
|
|
||||||
axios.get(`federation/follows/library/${this.library.follow.uuid}/`).then((response) => {
|
|
||||||
self.latestScan = response.data.target.latest_scan
|
|
||||||
if (self.scanStatus === 'pending' || self.scanStatus === 'scanning') {
|
|
||||||
self.scanTimeout = setTimeout(self.fetchScanStatus(), 5000)
|
|
||||||
} else {
|
|
||||||
clearTimeout(self.scanTimeout)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,84 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
// TODO (wvffle): Rename to LibraryBase
|
||||||
|
import { Library } from '~/types'
|
||||||
|
import axios from 'axios'
|
||||||
|
import LibraryFollowButton from '~/components/audio/LibraryFollowButton.vue'
|
||||||
|
import RadioButton from '~/components/radios/Button.vue'
|
||||||
|
import useReport from '~/composables/moderation/useReport'
|
||||||
|
import { humanSize } from '~/utils/filters'
|
||||||
|
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useStore } from '~/store'
|
||||||
|
import { useGettext } from 'vue3-gettext'
|
||||||
|
import { computed, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const { report, getReportableObjects } = useReport()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const object = ref<Library | null>(null)
|
||||||
|
|
||||||
|
const isOwner = computed(() => store.state.auth.authenticated && object.value?.actor.full_username === store.state.auth.fullUsername)
|
||||||
|
const isPlayable = computed(() => (object.value?.uploads_count ?? 0) > 0 && (
|
||||||
|
isOwner.value
|
||||||
|
|| object.value?.privacy_level === 'everyone'
|
||||||
|
|| (object.value?.privacy_level === 'instance' && store.state.auth.authenticated && object.value.actor.domain === store.getters['instance/domain'])
|
||||||
|
|| (store.getters['libraries/follow'](object.value?.uuid) || {}).approved === true
|
||||||
|
))
|
||||||
|
|
||||||
|
const { $pgettext } = useGettext()
|
||||||
|
const labels = computed(() => ({
|
||||||
|
title: $pgettext('*/*/*', 'Library'),
|
||||||
|
visibility: {
|
||||||
|
me: $pgettext('Content/Library/Card.Help text', 'Private'),
|
||||||
|
instance: $pgettext('Content/Library/Card.Help text', 'Restricted'),
|
||||||
|
everyone: $pgettext('Content/Library/Card.Help text', 'Public')
|
||||||
|
},
|
||||||
|
tooltips: {
|
||||||
|
me: $pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'),
|
||||||
|
instance: $pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'),
|
||||||
|
everyone: $pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
onBeforeRouteUpdate((to) => {
|
||||||
|
to.meta.preserveScrollPosition = true
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const fetchData = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`libraries/${props.id}`)
|
||||||
|
object.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
// TODO (wvffle): Handle error
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.id, fetchData, { immediate: true })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!store.state.auth.authenticated && object.value && store.getters['instance/domain'] !== object.value.actor.domain) {
|
||||||
|
router.push({ name: 'login', query: { next: route.fullPath } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateUploads = (count: number) => {
|
||||||
|
if (object.value) {
|
||||||
|
object.value.uploads_count += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main v-title="labels.title">
|
<main v-title="labels.title">
|
||||||
<div class="ui vertical stripe segment container">
|
<div class="ui vertical stripe segment container">
|
||||||
|
@ -31,11 +112,11 @@
|
||||||
>View on %{ domain }</translate>
|
>View on %{ domain }</translate>
|
||||||
</a>
|
</a>
|
||||||
<div
|
<div
|
||||||
v-for="obj in getReportableObjs({library: object})"
|
v-for="obj in getReportableObjects({library: object})"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
role="button"
|
role="button"
|
||||||
class="basic item"
|
class="basic item"
|
||||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
<i class="share icon" /> {{ obj.label }}
|
<i class="share icon" /> {{ obj.label }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,7 +142,7 @@
|
||||||
<div class="ui very small hidden divider" />
|
<div class="ui very small hidden divider" />
|
||||||
<div
|
<div
|
||||||
class="sub header ellipsis"
|
class="sub header ellipsis"
|
||||||
:title="object.full_username"
|
:title="object.actor.full_username"
|
||||||
>
|
>
|
||||||
<actor-link
|
<actor-link
|
||||||
:avatar="false"
|
:avatar="false"
|
||||||
|
@ -215,7 +296,7 @@
|
||||||
:is-owner="isOwner"
|
:is-owner="isOwner"
|
||||||
:object="object"
|
:object="object"
|
||||||
@updated="fetchData"
|
@updated="fetchData"
|
||||||
@uploads-finished="object.uploads_count += $event"
|
@uploads-finished="updateUploads"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -224,85 +305,3 @@
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import axios from 'axios'
|
|
||||||
import LibraryFollowButton from '~/components/audio/LibraryFollowButton.vue'
|
|
||||||
import ReportMixin from '~/components/mixins/Report.vue'
|
|
||||||
import RadioButton from '~/components/radios/Button.vue'
|
|
||||||
import { humanSize } from '~/utils/filters'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
RadioButton,
|
|
||||||
LibraryFollowButton
|
|
||||||
},
|
|
||||||
mixins: [ReportMixin],
|
|
||||||
beforeRouteUpdate (to, from, next) {
|
|
||||||
to.meta.preserveScrollPosition = true
|
|
||||||
next()
|
|
||||||
},
|
|
||||||
props: { id: { type: String, required: true } },
|
|
||||||
setup () {
|
|
||||||
return { humanSize }
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
object: null,
|
|
||||||
latestTracks: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isOwner () {
|
|
||||||
return this.$store.state.auth.authenticated && this.object.actor.full_username === this.$store.state.auth.fullUsername
|
|
||||||
},
|
|
||||||
labels () {
|
|
||||||
return {
|
|
||||||
title: this.$pgettext('*/*/*', 'Library'),
|
|
||||||
visibility: {
|
|
||||||
me: this.$pgettext('Content/Library/Card.Help text', 'Private'),
|
|
||||||
instance: this.$pgettext('Content/Library/Card.Help text', 'Restricted'),
|
|
||||||
everyone: this.$pgettext('Content/Library/Card.Help text', 'Public')
|
|
||||||
},
|
|
||||||
tooltips: {
|
|
||||||
me: this.$pgettext('Content/Library/Card.Help text', 'This library is private and your approval from its owner is needed to access its content'),
|
|
||||||
instance: this.$pgettext('Content/Library/Card.Help text', 'This library is restricted to users on this pod only'),
|
|
||||||
everyone: this.$pgettext('Content/Library/Card.Help text', 'This library is public and you can access its content freely')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isPlayable () {
|
|
||||||
return this.object.uploads_count > 0 && (
|
|
||||||
this.isOwner ||
|
|
||||||
this.object.privacy_level === 'everyone' ||
|
|
||||||
(this.object.privacy_level === 'instance' && this.$store.state.auth.authenticated && this.object.actor.domain === this.$store.getters['instance/domain']) ||
|
|
||||||
(this.$store.getters['libraries/follow'](this.object.uuid) || {}).approved === true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
id () {
|
|
||||||
this.fetchData()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async created () {
|
|
||||||
await this.fetchData()
|
|
||||||
const authenticated = this.$store.state.auth.authenticated
|
|
||||||
if (!authenticated && this.$store.getters['instance/domain'] !== this.object.actor.domain) {
|
|
||||||
this.$router.push({ name: 'login', query: { next: this.$route.fullPath } })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async fetchData () {
|
|
||||||
const self = this
|
|
||||||
this.isLoading = true
|
|
||||||
const libraryPromise = axios.get(`libraries/${this.id}`).then(response => {
|
|
||||||
self.object = response.data
|
|
||||||
})
|
|
||||||
await libraryPromise
|
|
||||||
self.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
Loading…
Reference in New Issue