funkwhale/front/src/components/Queue.vue

537 lines
20 KiB
Vue

<template>
<section
ref="queueModal"
class="main with-background component-queue"
:aria-label="labels.queue"
>
<div :class="['ui vertical stripe queue segment', playerFocused ? 'player-focused' : '']">
<div class="ui fluid container">
<div
id="queue-grid"
class="ui stackable grid"
>
<div class="ui six wide column current-track">
<div
id="player"
class="ui basic segment"
>
<template v-if="currentTrack">
<img
v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)"
>
<img
v-else-if="currentTrack.album && currentTrack.album.cover && currentTrack.album.cover.urls.large_square_crop"
ref="cover"
alt=""
:src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover.urls.large_square_crop)"
>
<img
v-else
class="ui image"
alt=""
src="../assets/audio/default-cover.png"
>
<h1 class="ui header">
<div class="content ellipsis">
<router-link
class="small header discrete link track"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
>
{{ currentTrack.title }}
</router-link>
<div class="sub header ellipsis">
<router-link
class="discrete link artist"
:to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"
>
{{ currentTrack.artist.name }}
</router-link>
<template v-if="currentTrack.album">
/
<router-link
class="discrete link album"
:to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"
>
{{ currentTrack.album.title }}
</router-link>
</template>
</div>
</div>
</h1>
<div
v-if="currentTrack && errored"
class="ui small warning message"
>
<h3 class="header">
<translate translate-context="Sidebar/Player/Error message.Title">
The track cannot be loaded
</translate>
</h3>
<p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors">
<translate translate-context="Sidebar/Player/Error message.Paragraph">
The next track will play automatically in a few seconds…
</translate>
<i class="loading spinner icon" />
</p>
<p>
<translate translate-context="Sidebar/Player/Error message.Paragraph">
You may have a connectivity issue.
</translate>
</p>
</div>
<div class="additional-controls tablet-and-below">
<track-favorite-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"
/>
<track-playlist-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"
/>
<button
v-if="$store.state.auth.authenticated"
:class="['ui', 'really', 'basic', 'circular', 'icon', 'button']"
:aria-label="labels.addArtistContentFilter"
:title="labels.addArtistContentFilter"
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
>
<i :class="['eye slash outline', 'basic', 'icon']" />
</button>
</div>
<div class="progress-wrapper">
<div
v-if="currentTrack && !errored"
class="progress-area"
>
<div
ref="progress"
:class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']"
@click="touchProgress"
>
<div
class="buffer bar"
:data-percent="bufferProgress"
:style="{ 'width': bufferProgress + '%' }"
/>
<div
class="position bar"
:data-percent="progress"
:style="{ 'width': progress + '%' }"
/>
</div>
</div>
<div
v-else
class="progress-area"
>
<div
ref="progress"
:class="['ui', 'small', 'vibrant', 'progress']"
>
<div class="buffer bar" />
<div class="position bar" />
</div>
</div>
<div class="progress">
<template v-if="!isLoadingAudio">
<a
href=""
:aria-label="labels.restart"
class="left floated timer discrete start"
@click.prevent="setCurrentTime(0)"
>{{ currentTimeFormatted }}</a>
<span class="right floated timer total">{{ durationFormatted }}</span>
</template>
<template v-else>
<span class="left floated timer">00:00</span>
<span class="right floated timer">00:00</span>
</template>
</div>
</div>
<div class="player-controls tablet-and-below">
<span
role="button"
:title="labels.previousTrack"
:aria-label="labels.previousTrack"
class="control"
:disabled="emptyQueue || null"
@click.prevent.stop="$store.dispatch('queue/previous')"
>
<i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']" />
</span>
<span
v-if="!playing"
role="button"
:title="labels.play"
:aria-label="labels.play"
class="control"
@click.prevent.stop="resumePlayback"
>
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" />
</span>
<span
v-else
role="button"
:title="labels.pause"
:aria-label="labels.pause"
class="control"
@click.prevent.stop="pausePlayback"
>
<i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']" />
</span>
<span
role="button"
:title="labels.next"
:aria-label="labels.next"
class="control"
:disabled="hasNext || null"
@click.prevent.stop="$store.dispatch('queue/next')"
>
<i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" />
</span>
</div>
</template>
</div>
</div>
<div class="ui ten wide column queue-column">
<div class="ui basic clearing fixed-header segment">
<h2 class="ui header">
<div class="content">
<button
class="ui right floated basic button"
@click="$store.commit('ui/queueFocused', null)"
>
<translate translate-context="*/Queue/*/Verb">
Close
</translate>
</button>
<button
class="ui right floated basic button danger"
@click="$store.dispatch('queue/clean')"
>
<translate translate-context="*/Queue/*/Verb">
Clear
</translate>
</button>
{{ labels.queue }}
<div class="sub header">
<div>
<translate
translate-context="Sidebar/Queue/Text"
:translate-params="{index: currentIndex + 1, length: queue.tracks.length}"
>
Track %{ index } of %{ length }
</translate><template v-if="!$store.state.radios.running">
-
<span :title="labels.duration">
{{ timeLeft }}
</span>
</template>
</div>
</div>
</div>
</h2>
</div>
<table class="ui compact very basic fixed single line selectable unstackable table">
<draggable
v-model:list="tracks"
tag="tbody"
handle=".handle"
item-key="id"
@update="reorder"
>
<template #item="{ element: track, index }">
<tr
:key="track.id"
:class="['queue-item', {'active': index === currentIndex}]"
>
<td class="handle">
<i class="grip lines icon" />
</td>
<td
class="image-cell"
@click="$store.dispatch('queue/currentIndex', index)"
>
<img
v-if="track.cover && track.cover.urls.original"
class="ui mini image"
alt=""
:src="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
>
<img
v-else-if="track.album && track.album.cover && track.album.cover.urls.original"
class="ui mini image"
alt=""
:src="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
>
<img
v-else
class="ui mini image"
alt=""
src="../assets/audio/default-cover.png"
>
</td>
<td
colspan="3"
@click="$store.dispatch('queue/currentIndex', index)"
>
<button
class="title reset ellipsis"
:title="track.title"
:aria-label="labels.selectTrack"
>
<strong>{{ track.title }}</strong><br>
<span>
{{ track.artist.name }}
</span>
</button>
</td>
<td class="duration-cell">
<template v-if="track.uploads.length > 0">
{{ time.durationFormatted(track.uploads[0].duration) }}
</template>
</td>
<td class="controls">
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
<i class="pink heart icon" />
</template>
<button
:aria-label="labels.removeFromQueue"
:title="labels.removeFromQueue"
:class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']"
@click.stop="cleanTrack(index)"
>
<i class="x icon" />
</button>
</td>
</tr>
</template>
</draggable>
</table>
<div
v-if="$store.state.radios.running"
class="ui info message"
>
<div class="content">
<h3 class="header">
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
You have a radio playing
</translate>
</h3>
<p>
<translate translate-context="Sidebar/Player/Paragraph">
New tracks will be appended here automatically.
</translate>
</p>
<button
class="ui basic primary button"
@click="$store.dispatch('radios/stop')"
>
<translate translate-context="*/Player/Button.Label/Short, Verb">
Stop radio
</translate>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<script>
import { useStore } from '~/store'
import { mapState, mapGetters, mapActions } from 'vuex'
import { nextTick, onMounted, ref, computed } from 'vue'
import moment from 'moment'
import { sum } from 'lodash-es'
import time from '~/utils/time'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import draggable from 'vuedraggable'
import { useTimeoutFn, useWindowScroll, useWindowSize } from '@vueuse/core'
export default {
components: {
TrackFavoriteIcon,
TrackPlaylistIcon,
draggable
},
setup () {
const queueModal = ref()
const { activate } = useFocusTrap(queueModal, { allowOutsideClick: true })
activate()
const store = useStore()
const queue = store.state.queue
const currentIndex = computed(() => store.state.queue.currentIndex)
const { y: pageYOffset } = useWindowScroll()
const { height: windowHeight } = useWindowSize()
const scrollToCurrent = async () => {
await nextTick()
const item = queueModal.value?.querySelector('.queue-item.active')
const { top } = item?.getBoundingClientRect() ?? { top: 0 }
window.scrollTo({
top: top + pageYOffset.value - windowHeight.value / 2,
behavior: 'smooth'
})
}
onMounted(async () => {
await nextTick()
// delay is to let transition work
useTimeoutFn(scrollToCurrent, 400)
})
// TODO (wvffle): Add useVirtualList to speed up the queue rendering and potentially resolve #1471
// Each item has 49px height on desktop and 50.666px on tablet(?) and down
return { queueModal, scrollToCurrent, queue, currentIndex }
},
data () {
return {
showVolume: false,
isShuffling: false,
tracksChangeBuffer: null,
time
}
},
computed: {
...mapState({
playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume,
looping: state => state.player.looping,
duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored,
currentTime: state => state.player.currentTime
}),
...mapGetters({
currentTrack: 'queue/currentTrack',
hasNext: 'queue/hasNext',
emptyQueue: 'queue/isEmpty',
durationFormatted: 'player/durationFormatted',
currentTimeFormatted: 'player/currentTimeFormatted',
progress: 'player/progress'
}),
tracks: {
get () {
return this.$store.state.queue.tracks
},
set (value) {
this.tracksChangeBuffer = value
}
},
labels () {
return {
queue: this.$pgettext('*/*/*', 'Queue'),
duration: this.$pgettext('*/*/*', 'Duration'),
addArtistContentFilter: this.$pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'),
restart: this.$pgettext('*/*/*', 'Restart track')
}
},
timeLeft () {
const seconds = sum(
this.queue.tracks.slice(this.queue.currentIndex).map((t) => {
return (t.uploads || []).map((u) => {
return u.duration || 0
})[0] || 0
})
)
return moment(this.$store.state.ui.lastDate).add(seconds, 'seconds').fromNow(true)
},
sliderVolume: {
get () {
return this.volume
},
set (v) {
this.$store.commit('player/volume', v)
}
},
playerFocused () {
return this.$store.state.ui.queueFocused === 'player'
}
},
watch: {
'$store.state.ui.queueFocused': {
handler (v) {
if (v === 'queue') {
this.$nextTick(() => {
this.scrollToCurrent()
})
}
},
immediate: true
},
'$store.state.queue.currentIndex': {
handler () {
this.$nextTick(() => {
this.scrollToCurrent()
})
}
},
'$store.state.queue.tracks': {
handler (v) {
if (!v || v.length === 0) {
this.$store.commit('ui/queueFocused', null)
}
},
immediate: true,
deep: true
},
'$route.fullPath' () {
this.$store.commit('ui/queueFocused', null)
}
},
methods: {
...mapActions({
cleanTrack: 'queue/cleanTrack',
mute: 'player/mute',
unmute: 'player/unmute',
clean: 'queue/clean',
toggleMute: 'player/toggleMute',
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback'
}),
reorder: function (event) {
this.$store.commit('queue/reorder', {
tracks: this.tracksChangeBuffer,
oldIndex: event.oldIndex,
newIndex: event.newIndex
})
},
touchProgress (e) {
const target = this.$refs.progress
const time = (e.layerX / target.offsetWidth) * this.duration
this.$emit('touch-progress', time)
},
shuffle () {
const disabled = this.queue.tracks.length === 0
if (this.isShuffling || disabled) {
return
}
const self = this
const msg = this.$pgettext('Content/Queue/Message', 'Queue shuffled!')
this.isShuffling = true
setTimeout(() => {
self.$store.dispatch('queue/shuffle', () => {
self.isShuffling = false
self.$store.commit('ui/addMessage', {
content: msg,
date: new Date()
})
})
}, 100)
}
}
}
</script>