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>
|
||||
<span
|
||||
: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
|
||||
v-if="!dropdownOnly"
|
||||
:disabled="!playable || null"
|
||||
:disabled="!playable"
|
||||
: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"
|
||||
>
|
||||
<i
|
||||
|
@ -34,61 +166,58 @@
|
|||
class="menu"
|
||||
>
|
||||
<button
|
||||
ref="add"
|
||||
class="item basic"
|
||||
data-ref="add"
|
||||
:disabled="!playable || null"
|
||||
data-ref="enqueue"
|
||||
:disabled="!playable"
|
||||
: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>
|
||||
</button>
|
||||
<button
|
||||
ref="addNext"
|
||||
class="item basic"
|
||||
data-ref="addNext"
|
||||
:disabled="!playable || null"
|
||||
data-ref="enqueueNext"
|
||||
:disabled="!playable"
|
||||
:title="labels.playNext"
|
||||
@click.stop.prevent="addNext()"
|
||||
@click.stop.prevent="enqueueNext()"
|
||||
>
|
||||
<i class="step forward icon" />{{ labels.playNext }}
|
||||
</button>
|
||||
<button
|
||||
ref="playNow"
|
||||
class="item basic"
|
||||
data-ref="playNow"
|
||||
:disabled="!playable || null"
|
||||
:disabled="!playable"
|
||||
:title="labels.playNow"
|
||||
@click.stop.prevent="addNext(true)"
|
||||
@click.stop.prevent="enqueueNext(true)"
|
||||
>
|
||||
<i class="play icon" />{{ labels.playNow }}
|
||||
</button>
|
||||
<button
|
||||
v-if="track"
|
||||
class="item basic"
|
||||
:disabled="!playable || null"
|
||||
:disabled="!playable"
|
||||
: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>
|
||||
</button>
|
||||
<button
|
||||
v-if="track"
|
||||
class="item basic"
|
||||
:disabled="!playable || null"
|
||||
:disabled="!playable"
|
||||
@click.stop="$store.commit('playlists/chooseTrack', track)"
|
||||
>
|
||||
<i class="list icon" />
|
||||
<translate translate-context="Sidebar/Player/Icon.Tooltip/Verb">Add to playlist…</translate>
|
||||
</button>
|
||||
<button
|
||||
v-if="track && !onTrackPage"
|
||||
v-if="track && $route.name !== 'library.tracks.detail'"
|
||||
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" />
|
||||
<translate
|
||||
v-if="track.artist.content_category === 'podcast'"
|
||||
v-if="track.artist?.content_category === 'podcast'"
|
||||
translate-context="*/Queue/Dropdown/Button/Label/Short"
|
||||
>Episode details</translate>
|
||||
<translate
|
||||
|
@ -99,22 +228,21 @@
|
|||
<div class="divider" />
|
||||
<button
|
||||
v-if="filterableArtist"
|
||||
ref="filterArtist"
|
||||
data-ref="filterArtist"
|
||||
class="item basic"
|
||||
:disabled="!filterableArtist || null"
|
||||
:disabled="!filterableArtist"
|
||||
:title="labels.hideArtist"
|
||||
@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
|
||||
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"
|
||||
:ref="`report${obj.target.type}${obj.target.id}`"
|
||||
class="item basic"
|
||||
: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 }}
|
||||
</button>
|
||||
|
@ -122,117 +250,3 @@
|
|||
</button>
|
||||
</span>
|
||||
</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>
|
||||
<div class="card app-card component-album-card">
|
||||
<router-link
|
||||
|
@ -37,7 +57,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<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-context="*/*/*"
|
||||
:translate-params="{count: album.tracks_count}"
|
||||
|
@ -56,28 +76,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div
|
||||
:class="[
|
||||
|
@ -11,38 +57,20 @@
|
|||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="
|
||||
track.album && track.album.cover && track.album.cover.urls.original
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.album.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
track.cover
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
track.artist.cover
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.artist.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.artist?.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) "
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -63,13 +91,13 @@
|
|||
:class="[
|
||||
'track-title',
|
||||
'mobile',
|
||||
{ 'play-indicator': isPlaying && currentTrack && track.id === currentTrack.id },
|
||||
{ 'play-indicator': playing && track.id === currentTrack?.id },
|
||||
]"
|
||||
>
|
||||
{{ track.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="track.artist.content_category === 'podcast'"
|
||||
v-if="track.artist?.content_category === 'podcast'"
|
||||
class="track-meta mobile"
|
||||
>
|
||||
<human-date
|
||||
|
@ -86,7 +114,7 @@
|
|||
v-else
|
||||
class="track-meta mobile"
|
||||
>
|
||||
{{ track.artist.name }} <span>·</span>
|
||||
{{ track.artist?.name }} <span>·</span>
|
||||
<human-duration
|
||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
||||
:duration="track.uploads[0].duration"
|
||||
|
@ -94,7 +122,7 @@
|
|||
</p>
|
||||
</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="[
|
||||
'meta',
|
||||
'right',
|
||||
|
@ -135,67 +163,3 @@
|
|||
/>
|
||||
</div>
|
||||
</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>
|
||||
<modal
|
||||
<semantic-modal
|
||||
ref="modal"
|
||||
v-model:show="showRef"
|
||||
v-model:show="show"
|
||||
:scrolling="true"
|
||||
:additional-classes="['scrolling-track-options']"
|
||||
>
|
||||
|
@ -28,7 +104,7 @@
|
|||
class="ui centered image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.artist.cover"
|
||||
v-else-if="track.artist?.cover"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.artist.cover.urls.medium_square_crop
|
||||
|
@ -48,14 +124,14 @@
|
|||
{{ track.title }}
|
||||
</h3>
|
||||
<h4 class="track-modal-subtitle">
|
||||
{{ track.artist.name }}
|
||||
{{ track.artist?.name }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="content">
|
||||
<div class="ui one column unstackable grid">
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -85,8 +161,8 @@
|
|||
role="button"
|
||||
:aria-label="labels.addToQueue"
|
||||
@click.stop.prevent="
|
||||
add();
|
||||
$refs.modal.closeModal();
|
||||
enqueue();
|
||||
modal.closeModal();
|
||||
"
|
||||
>
|
||||
<i class="plus icon track-modal list-icon" />
|
||||
|
@ -99,8 +175,8 @@
|
|||
role="button"
|
||||
:aria-label="labels.playNext"
|
||||
@click.stop.prevent="
|
||||
addNext(true);
|
||||
$refs.modal.closeModal();
|
||||
enqueueNext(true);
|
||||
modal.closeModal();
|
||||
"
|
||||
>
|
||||
<i class="step forward icon track-modal list-icon" />
|
||||
|
@ -117,7 +193,7 @@
|
|||
type: 'similar',
|
||||
objectId: track.id,
|
||||
});
|
||||
$refs.modal.closeModal();
|
||||
modal.closeModal();
|
||||
"
|
||||
>
|
||||
<i class="rss icon track-modal list-icon" />
|
||||
|
@ -149,7 +225,7 @@
|
|||
@click.prevent.exact="
|
||||
$router.push({
|
||||
name: 'library.albums.detail',
|
||||
params: { id: track.album.id },
|
||||
params: { id: track.album?.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
|
@ -170,7 +246,7 @@
|
|||
@click.prevent.exact="
|
||||
$router.push({
|
||||
name: 'library.artists.detail',
|
||||
params: { id: track.artist.id },
|
||||
params: { id: track.artist?.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
|
@ -200,16 +276,12 @@
|
|||
</div>
|
||||
<div class="ui divider" />
|
||||
<div
|
||||
v-for="obj in getReportableObjs({
|
||||
track,
|
||||
album,
|
||||
artist,
|
||||
})"
|
||||
v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
:ref="`report${obj.target.type}${obj.target.id}`"
|
||||
class="row"
|
||||
: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">
|
||||
<i class="share icon track-modal list-icon" /><span
|
||||
|
@ -219,93 +291,5 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</semantic-modal>
|
||||
</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>
|
||||
<div
|
||||
:class="[
|
||||
{ active: currentTrack && track.id === currentTrack.id },
|
||||
'track-row podcast row',
|
||||
]"
|
||||
@mouseover="hover = track.id"
|
||||
@mouseleave="hover = null"
|
||||
@dblclick="activateTrack(track, index)"
|
||||
>
|
||||
<div
|
||||
|
@ -15,26 +65,14 @@
|
|||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="
|
||||
track.cover && track.cover.urls.original
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-if="track.cover?.urls.original "
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
defaultCover
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
defaultCover.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="defaultCover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](defaultCover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -57,7 +95,7 @@
|
|||
v-if="description"
|
||||
class="podcast-episode-meta"
|
||||
>
|
||||
{{ description.text }}
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
@ -79,86 +117,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div
|
||||
:class="[
|
||||
|
@ -11,38 +57,20 @@
|
|||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="
|
||||
track.album && track.album.cover && track.album.cover.urls.original
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.album.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
track.cover
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
track.artist.cover
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.artist.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.artist?.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -63,13 +91,13 @@
|
|||
:class="[
|
||||
'track-title',
|
||||
'mobile',
|
||||
{ 'play-indicator': isPlaying && currentTrack && track.id === currentTrack.id },
|
||||
{ 'play-indicator': playing && track.id === currentTrack?.id },
|
||||
]"
|
||||
>
|
||||
{{ track.title }}
|
||||
</p>
|
||||
<p class="track-meta mobile">
|
||||
{{ track.artist.name }} <span>·</span>
|
||||
{{ track.artist?.name }} <span>·</span>
|
||||
<human-duration
|
||||
v-if="track.uploads[0] && track.uploads[0].duration"
|
||||
:duration="track.uploads[0].duration"
|
||||
|
@ -118,67 +146,3 @@
|
|||
/>
|
||||
</div>
|
||||
</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>
|
||||
<modal
|
||||
<semantic-modal
|
||||
ref="modal"
|
||||
v-model:show="showRef"
|
||||
v-model:show="show"
|
||||
:scrolling="true"
|
||||
:additional-classes="['scrolling-track-options']"
|
||||
>
|
||||
|
@ -30,12 +106,8 @@
|
|||
class="ui centered image"
|
||||
>
|
||||
<img
|
||||
v-else-if="track.artist.cover"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.artist.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.artist?.cover"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui centered image"
|
||||
>
|
||||
|
@ -50,14 +122,14 @@
|
|||
{{ track.title }}
|
||||
</h3>
|
||||
<h4 class="track-modal-subtitle">
|
||||
{{ track.artist.name }}
|
||||
{{ track.artist?.name }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="ui hidden divider" />
|
||||
<div class="content">
|
||||
<div class="ui one column unstackable grid">
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
|
@ -87,8 +159,8 @@
|
|||
role="button"
|
||||
:aria-label="labels.addToQueue"
|
||||
@click.stop.prevent="
|
||||
add();
|
||||
$refs.modal.closeModal();
|
||||
enqueue();
|
||||
modal.closeModal();
|
||||
"
|
||||
>
|
||||
<i class="plus icon track-modal list-icon" />
|
||||
|
@ -101,8 +173,8 @@
|
|||
role="button"
|
||||
:aria-label="labels.playNext"
|
||||
@click.stop.prevent="
|
||||
addNext(true);
|
||||
$refs.modal.closeModal();
|
||||
enqueueNext(true);
|
||||
modal.closeModal();
|
||||
"
|
||||
>
|
||||
<i class="step forward icon track-modal list-icon" />
|
||||
|
@ -119,7 +191,7 @@
|
|||
type: 'similar',
|
||||
objectId: track.id,
|
||||
});
|
||||
$refs.modal.closeModal();
|
||||
modal.closeModal();
|
||||
"
|
||||
>
|
||||
<i class="rss icon track-modal list-icon" />
|
||||
|
@ -151,7 +223,7 @@
|
|||
@click.prevent.exact="
|
||||
$router.push({
|
||||
name: 'library.albums.detail',
|
||||
params: { id: track.album.id },
|
||||
params: { id: track.album?.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
|
@ -172,7 +244,7 @@
|
|||
@click.prevent.exact="
|
||||
$router.push({
|
||||
name: 'library.artists.detail',
|
||||
params: { id: track.artist.id },
|
||||
params: { id: track.artist?.id },
|
||||
})
|
||||
"
|
||||
>
|
||||
|
@ -202,16 +274,12 @@
|
|||
</div>
|
||||
<div class="ui divider" />
|
||||
<div
|
||||
v-for="obj in getReportableObjs({
|
||||
track,
|
||||
album,
|
||||
artist,
|
||||
})"
|
||||
v-for="obj in getReportableObjects({ track, album: track.album, artist: track.artist })"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
:ref="`report${obj.target.type}${obj.target.id}`"
|
||||
class="row"
|
||||
: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">
|
||||
<i class="share icon track-modal list-icon" /><span
|
||||
|
@ -221,93 +289,5 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</semantic-modal>
|
||||
</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>
|
||||
<div
|
||||
:class="[
|
||||
|
@ -17,7 +67,7 @@
|
|||
v-if="
|
||||
!$store.state.player.isLoadingAudio &&
|
||||
currentTrack &&
|
||||
isPlaying &&
|
||||
playing &&
|
||||
track.id === currentTrack.id &&
|
||||
!(track.id == hover)
|
||||
"
|
||||
|
@ -25,9 +75,9 @@
|
|||
<button
|
||||
v-else-if="
|
||||
currentTrack &&
|
||||
!isPlaying &&
|
||||
!playing &&
|
||||
track.id === currentTrack.id &&
|
||||
!track.id == hover
|
||||
track.id !== hover
|
||||
"
|
||||
class="ui really tiny basic icon button play-button paused"
|
||||
>
|
||||
|
@ -36,7 +86,7 @@
|
|||
<button
|
||||
v-else-if="
|
||||
currentTrack &&
|
||||
isPlaying &&
|
||||
playing &&
|
||||
track.id === currentTrack.id &&
|
||||
track.id == hover
|
||||
"
|
||||
|
@ -54,7 +104,7 @@
|
|||
v-else-if="showPosition"
|
||||
class="track-position"
|
||||
>
|
||||
{{ prettyPosition(track.position) }}
|
||||
{{ (track.position as unknown as string).padStart(2, '0') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -64,38 +114,20 @@
|
|||
@click.prevent.exact="activateTrack(track, index)"
|
||||
>
|
||||
<img
|
||||
v-if="
|
||||
track.album && track.album.cover && track.album.cover.urls.original
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.album.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-if="track.album?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
track.cover && track.cover.urls.original
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
<img
|
||||
v-else-if="
|
||||
track.artist && track.artist.cover && track.album.cover.urls.original
|
||||
"
|
||||
v-lazy="
|
||||
$store.getters['instance/absoluteUrl'](
|
||||
track.cover.urls.medium_square_crop
|
||||
)
|
||||
"
|
||||
v-else-if="track.artist?.cover?.urls.original"
|
||||
v-lazy="$store.getters['instance/absoluteUrl'](track.artist.cover.urls.medium_square_crop) "
|
||||
alt=""
|
||||
class="ui artist-track mini image"
|
||||
>
|
||||
|
@ -121,9 +153,9 @@
|
|||
class="content ellipsis left floated column"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
|
@ -134,10 +166,10 @@
|
|||
class="artist link"
|
||||
:to="{
|
||||
name: 'library.artists.detail',
|
||||
params: { id: track.artist.id },
|
||||
params: { id: track.artist?.id },
|
||||
}"
|
||||
>
|
||||
{{ track.artist.name }}
|
||||
{{ track.artist?.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div
|
||||
|
@ -178,67 +210,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<main>
|
||||
<div
|
||||
|
@ -93,6 +188,7 @@
|
|||
:is-serie="isSerie"
|
||||
:is-channel="isChannel"
|
||||
:artist="artist"
|
||||
@remove="remove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -140,7 +236,7 @@
|
|||
v-if="object.release_date || (totalTracks > 0)"
|
||||
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">
|
||||
<translate
|
||||
v-if="isSerie"
|
||||
|
@ -180,6 +276,7 @@
|
|||
:is-serie="isSerie"
|
||||
:is-channel="isChannel"
|
||||
:artist="artist"
|
||||
@remove="remove"
|
||||
/>
|
||||
<div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local">
|
||||
<div class="ui small hidden divider" />
|
||||
|
@ -244,123 +341,3 @@
|
|||
</template>
|
||||
</main>
|
||||
</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>
|
||||
<span>
|
||||
|
||||
|
@ -109,11 +149,11 @@
|
|||
</dangerous-button>
|
||||
<div class="divider" />
|
||||
<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"
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
@ -127,7 +167,7 @@
|
|||
<translate translate-context="Content/Moderation/Link">Open in moderation interface</translate>
|
||||
</router-link>
|
||||
<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"
|
||||
:href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)"
|
||||
target="_blank"
|
||||
|
@ -139,63 +179,4 @@
|
|||
</div>
|
||||
</button>
|
||||
</span>
|
||||
</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>
|
||||
</template>
|
|
@ -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>
|
||||
<main v-title="labels.title">
|
||||
<div
|
||||
|
@ -85,7 +178,7 @@
|
|||
<div class="ui buttons">
|
||||
<button
|
||||
class="ui button"
|
||||
@click="$refs.dropdown.click()"
|
||||
@click="dropdown.click()"
|
||||
>
|
||||
<translate translate-context="*/*/Button.Label/Noun">
|
||||
More…
|
||||
|
@ -162,11 +255,11 @@
|
|||
</router-link>
|
||||
<div class="divider" />
|
||||
<div
|
||||
v-for="obj in getReportableObjs({artist: object})"
|
||||
v-for="obj in getReportableObjects({artist: object})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
@ -204,7 +297,7 @@
|
|||
:next-tracks-url="nextTracksUrl"
|
||||
:next-albums-url="nextAlbumsUrl"
|
||||
:albums="albums"
|
||||
:is-loading-albums="isLoadingAlbums"
|
||||
:is-loading-albums="isLoading"
|
||||
:object="object"
|
||||
object-type="artist"
|
||||
@libraries-loaded="libraries = $event"
|
||||
|
@ -212,160 +305,3 @@
|
|||
</template>
|
||||
</main>
|
||||
</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>
|
||||
<main>
|
||||
<div
|
||||
|
@ -177,11 +297,11 @@
|
|||
</dangerous-button>
|
||||
<div class="divider" />
|
||||
<div
|
||||
v-for="obj in getReportableObjs({track})"
|
||||
v-for="obj in getReportableObjects({track})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
@ -223,196 +343,3 @@
|
|||
</template>
|
||||
</main>
|
||||
</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)
|
||||
type ResponseType = { count: number, results: any[] }
|
||||
const result = ref<null | ResponseType>(null)
|
||||
const search = ref()
|
||||
|
||||
const { onSearch, query, addSearchToken, getTokenValue } = useSmartSearch(props.defaultQuery, props.updateUrl)
|
||||
const { onOrderingUpdate, orderingString, paginateBy, ordering, orderingDirection } = useOrdering(props.orderingConfigName)
|
||||
|
@ -81,7 +82,7 @@ const labels = computed(() => ({
|
|||
<div class="fields">
|
||||
<div class="ui six wide field">
|
||||
<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
|
||||
id="channel-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)
|
||||
}
|
||||
|
||||
if (track == null) {
|
||||
if (track === null) {
|
||||
store.commit('player/isLoadingAudio', false)
|
||||
store.dispatch('player/trackErrored')
|
||||
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 { Permission } from '~/store/auth'
|
||||
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' })
|
||||
}
|
||||
import { RouteRecordRaw } from 'vue-router'
|
||||
import { hasPermissions } from '~/router/guards'
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/manage/settings',
|
||||
name: 'manage.settings',
|
||||
beforeEnter: hasPermissions('admin'),
|
||||
beforeEnter: hasPermissions('settings'),
|
||||
component: () => import('~/views/admin/Settings.vue')
|
||||
},
|
||||
{
|
||||
|
@ -117,7 +107,7 @@ export default [
|
|||
},
|
||||
{
|
||||
path: '/manage/users',
|
||||
beforeEnter: hasPermissions('admin'),
|
||||
beforeEnter: hasPermissions('settings'),
|
||||
component: () => import('~/views/admin/users/Base.vue'),
|
||||
children: [
|
||||
{
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { RouteRecordRaw } from 'vue-router'
|
||||
import store from '~/store'
|
||||
|
||||
export default [
|
||||
{ suffix: '.full', path: '/@:username@:domain' },
|
||||
|
@ -8,6 +9,13 @@ export default [
|
|||
path: route.path,
|
||||
name: `profile${route.suffix}`,
|
||||
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,
|
||||
children: [
|
||||
{
|
||||
|
|
|
@ -22,6 +22,7 @@ interface Profile {
|
|||
full_username: string
|
||||
instance_support_message_display_date: string
|
||||
funkwhale_support_message_display_date: string
|
||||
is_superuser: boolean
|
||||
}
|
||||
|
||||
interface ScopedTokens {
|
||||
|
@ -140,7 +141,7 @@ const store: Module<State, RootState> = {
|
|||
}
|
||||
|
||||
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) => {
|
||||
|
|
|
@ -19,11 +19,12 @@ export interface State {
|
|||
}
|
||||
}
|
||||
|
||||
interface ContentFilter {
|
||||
export interface ContentFilter {
|
||||
uuid: string
|
||||
creation_date: Date
|
||||
target: {
|
||||
type: 'artist'
|
||||
id: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -30,22 +30,30 @@ export interface ThemeEntry {
|
|||
}
|
||||
|
||||
// Track stuff
|
||||
export type ContentCategory = 'podcast'
|
||||
export type ContentCategory = 'podcast' | 'music'
|
||||
|
||||
export interface Artist {
|
||||
id: string
|
||||
fid: string
|
||||
mbid?: string
|
||||
|
||||
name: string
|
||||
description: Content
|
||||
cover?: Cover
|
||||
channel?: Channel
|
||||
tags: string[]
|
||||
|
||||
content_category: ContentCategory
|
||||
albums: Album[]
|
||||
tracks_count: number
|
||||
attributed_to: Actor
|
||||
is_local: boolean
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string
|
||||
fid: string
|
||||
mbid?: string
|
||||
|
||||
title: string
|
||||
description: Content
|
||||
|
@ -56,10 +64,15 @@ export interface Album {
|
|||
artist: Artist
|
||||
tracks_count: number
|
||||
tracks: Track[]
|
||||
|
||||
is_playable: boolean
|
||||
is_local: boolean
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string
|
||||
fid: string
|
||||
mbid?: string
|
||||
|
||||
title: string
|
||||
description: Content
|
||||
|
@ -72,19 +85,63 @@ export interface Track {
|
|||
|
||||
album?: Album
|
||||
artist?: Artist
|
||||
disc_number: number
|
||||
|
||||
// TODO (wvffle): Make sure it really has listen_url
|
||||
listen_url: string
|
||||
creation_date: string
|
||||
attributed_to: Actor
|
||||
|
||||
is_playable: boolean
|
||||
is_local: boolean
|
||||
}
|
||||
|
||||
|
||||
export interface Channel {
|
||||
id: string
|
||||
uuid: string
|
||||
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 {
|
||||
uuid: string
|
||||
urls: {
|
||||
original: string
|
||||
medium_square_crop: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface License {
|
||||
|
@ -188,8 +245,12 @@ export interface FSLogs {
|
|||
logs: string[]
|
||||
}
|
||||
|
||||
// Yet uncategorized stuff
|
||||
// Profile stuff
|
||||
export interface Actor {
|
||||
fid?: string
|
||||
name?: string
|
||||
icon?: Cover
|
||||
summary: string
|
||||
preferred_username: string
|
||||
full_username: string
|
||||
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>
|
||||
<main
|
||||
v-title="labels.usernameProfile"
|
||||
|
@ -36,11 +97,11 @@
|
|||
>View on %{ domain }</translate>
|
||||
</a>
|
||||
<div
|
||||
v-for="obj in getReportableObjs({account: object})"
|
||||
v-for="obj in getReportableObjects({account: object})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
@ -124,7 +185,7 @@
|
|||
<div class="ui hidden divider" />
|
||||
<router-view
|
||||
:object="object"
|
||||
@updated="fetch"
|
||||
@updated="fetchData"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -133,82 +194,3 @@
|
|||
</div>
|
||||
</main>
|
||||
</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>
|
||||
<main
|
||||
v-title="labels.title"
|
||||
|
@ -11,7 +128,7 @@
|
|||
</div>
|
||||
<template v-if="object && !isLoading">
|
||||
<section
|
||||
v-title="object.artist.name"
|
||||
v-title="object.artist?.name"
|
||||
class="ui head vertical stripe segment container"
|
||||
>
|
||||
<div class="ui stackable grid">
|
||||
|
@ -19,7 +136,7 @@
|
|||
<div class="ui two column grid">
|
||||
<div class="column">
|
||||
<img
|
||||
v-if="object.artist.cover"
|
||||
v-if="object.artist?.cover"
|
||||
alt=""
|
||||
class="huge channel-image"
|
||||
:src="$store.getters['instance/absoluteUrl'](object.artist.cover.urls.medium_square_crop)"
|
||||
|
@ -31,7 +148,7 @@
|
|||
</div>
|
||||
<div class="ui column right aligned">
|
||||
<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"
|
||||
/>
|
||||
<actor-link
|
||||
|
@ -43,7 +160,7 @@
|
|||
<template v-if="totalTracks > 0">
|
||||
<div class="ui hidden very small divider" />
|
||||
<translate
|
||||
v-if="object.artist.content_category === 'podcast'"
|
||||
v-if="object.artist?.content_category === 'podcast'"
|
||||
translate-context="Content/Channel/Paragraph"
|
||||
translate-plural="%{ count } episodes"
|
||||
:translate-n="totalTracks"
|
||||
|
@ -65,16 +182,16 @@
|
|||
<br><translate
|
||||
translate-context="Content/Channel/Paragraph"
|
||||
translate-plural="%{ count } subscribers"
|
||||
:translate-n="object.subscriptions_count"
|
||||
:translate-params="{count: object.subscriptions_count}"
|
||||
:translate-n="object?.subscriptions_count"
|
||||
:translate-params="{count: object?.subscriptions_count}"
|
||||
>
|
||||
%{ count } subscriber
|
||||
</translate>
|
||||
<br><translate
|
||||
translate-context="Content/Channel/Paragraph"
|
||||
translate-plural="%{ count } listenings"
|
||||
:translate-n="object.downloads_count"
|
||||
:translate-params="{count: object.downloads_count}"
|
||||
:translate-n="object?.downloads_count"
|
||||
:translate-params="{count: object?.downloads_count}"
|
||||
>
|
||||
%{ count } listening
|
||||
</translate>
|
||||
|
@ -106,8 +223,8 @@
|
|||
</h3>
|
||||
<subscribe-button
|
||||
:channel="object"
|
||||
@subscribed="object.subscriptions_count += 1"
|
||||
@unsubscribed="object.subscriptions_count -= 1"
|
||||
@subscribed="updateSubscriptionCount(1)"
|
||||
@unsubscribed="updateSubscriptionCount(-1)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="object.rss_url">
|
||||
|
@ -181,11 +298,11 @@
|
|||
</a>
|
||||
<div class="divider" />
|
||||
<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"
|
||||
href=""
|
||||
class="basic item"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</a>
|
||||
|
@ -253,9 +370,9 @@
|
|||
<h1 class="ui header">
|
||||
<div
|
||||
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
|
||||
v-if="object.actor"
|
||||
|
@ -311,8 +428,8 @@
|
|||
<div class="ui buttons">
|
||||
<subscribe-button
|
||||
:channel="object"
|
||||
@subscribed="object.subscriptions_count += 1"
|
||||
@unsubscribed="object.subscriptions_count -= 1"
|
||||
@subscribed="updateSubscriptionCount(1)"
|
||||
@unsubscribed="updateSubscriptionCount(-1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -328,7 +445,7 @@
|
|||
<div class="scrolling content">
|
||||
<div class="description">
|
||||
<embed-wizard
|
||||
:id="object.artist.id"
|
||||
:id="object.artist?.id"
|
||||
type="artist"
|
||||
/>
|
||||
</div>
|
||||
|
@ -347,7 +464,7 @@
|
|||
>
|
||||
<h4 class="header">
|
||||
<translate
|
||||
v-if="object.artist.content_category === 'podcast'"
|
||||
v-if="object.artist?.content_category === 'podcast'"
|
||||
translate-context="Content/Channel/*"
|
||||
>
|
||||
Podcast channel
|
||||
|
@ -363,7 +480,7 @@
|
|||
<channel-form
|
||||
ref="editForm"
|
||||
:object="object"
|
||||
@loading="edit.isLoading = $event"
|
||||
@loading="edit.loading = $event"
|
||||
@submittable="edit.submittable = $event"
|
||||
@updated="fetchData"
|
||||
/>
|
||||
|
@ -376,9 +493,9 @@
|
|||
</translate>
|
||||
</button>
|
||||
<button
|
||||
:class="['ui', 'primary', 'confirm', {loading: edit.isLoading}, 'button']"
|
||||
:disabled="!edit.submittable || null"
|
||||
@click.stop="$refs.editForm.submit"
|
||||
:class="['ui', 'primary', 'confirm', {loading: edit.loading}, 'button']"
|
||||
:disabled="!edit.submittable"
|
||||
@click.stop="editForm?.submit"
|
||||
>
|
||||
<translate translate-context="*/Channels/Button.Label">
|
||||
Update channel
|
||||
|
@ -389,7 +506,7 @@
|
|||
</div>
|
||||
<div v-if="$store.getters['ui/layoutVersion'] === 'large'">
|
||||
<rendered-description
|
||||
:content="object.artist.description"
|
||||
:content="object.artist?.description"
|
||||
:update-url="`channels/${object.uuid}/`"
|
||||
:can-update="false"
|
||||
@updated="object = $event"
|
||||
|
@ -438,126 +555,3 @@
|
|||
</template>
|
||||
</main>
|
||||
</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>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
|
@ -12,10 +148,10 @@
|
|||
<i class="ellipsis vertical large icon nomargin" />
|
||||
<div class="menu">
|
||||
<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"
|
||||
class="item basic"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</button>
|
||||
|
@ -227,144 +363,4 @@
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
|
@ -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>
|
||||
<main v-title="labels.title">
|
||||
<div class="ui vertical stripe segment container">
|
||||
|
@ -31,11 +112,11 @@
|
|||
>View on %{ domain }</translate>
|
||||
</a>
|
||||
<div
|
||||
v-for="obj in getReportableObjs({library: object})"
|
||||
v-for="obj in getReportableObjects({library: object})"
|
||||
:key="obj.target.type + obj.target.id"
|
||||
role="button"
|
||||
class="basic item"
|
||||
@click.stop.prevent="$store.dispatch('moderation/report', obj.target)"
|
||||
@click.stop.prevent="report(obj)"
|
||||
>
|
||||
<i class="share icon" /> {{ obj.label }}
|
||||
</div>
|
||||
|
@ -61,7 +142,7 @@
|
|||
<div class="ui very small hidden divider" />
|
||||
<div
|
||||
class="sub header ellipsis"
|
||||
:title="object.full_username"
|
||||
:title="object.actor.full_username"
|
||||
>
|
||||
<actor-link
|
||||
:avatar="false"
|
||||
|
@ -215,7 +296,7 @@
|
|||
:is-owner="isOwner"
|
||||
:object="object"
|
||||
@updated="fetchData"
|
||||
@uploads-finished="object.uploads_count += $event"
|
||||
@uploads-finished="updateUploads"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -224,85 +305,3 @@
|
|||
</div>
|
||||
</main>
|
||||
</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