Remove PlayOptions and Report mixins

This commit is contained in:
wvffle 2022-07-04 18:17:58 +00:00 committed by Georg Krause
parent 74e88c26e8
commit 77594351ae
29 changed files with 1814 additions and 2140 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>&#183;</span>
{{ track.artist?.name }} <span>&#183;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>&#183;</span>
{{ track.artist?.name }} <span>&#183;</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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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>

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
}
}

View File

@ -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

View File

@ -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
})

View File

@ -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' })
}

View File

@ -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: [
{

View File

@ -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: [
{

View File

@ -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) => {

View File

@ -19,11 +19,12 @@ export interface State {
}
}
interface ContentFilter {
export interface ContentFilter {
uuid: string
creation_date: Date
target: {
type: 'artist'
id: string
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>