Rewrite queue

This commit is contained in:
wvffle 2022-07-02 23:49:07 +00:00 committed by Georg Krause
parent 02f8f37824
commit 8c11b6d0ea
6 changed files with 290 additions and 220 deletions

View File

@ -1,3 +1,115 @@
<script setup lang="ts">
import { useStore } from '~/store'
import { nextTick, onMounted, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
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 { whenever, useTimeoutFn, useWindowScroll, useWindowSize } from '@vueuse/core'
import { useGettext } from "vue3-gettext"
import useQueue from '~/composables/useQueue'
import usePlayer from '~/composables/usePlayer'
const queueModal = ref()
const { activate } = useFocusTrap(queueModal, { allowOutsideClick: true })
activate()
const store = useStore()
const queue = computed(() => 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'
})
}
// 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
onMounted(async () => {
await nextTick()
// NOTE: delay is to let transition work
useTimeoutFn(scrollToCurrent, 400)
})
const { $pgettext } = useGettext()
const {
playing,
loading: isLoadingAudio,
errored,
focused: playerFocused,
duration,
durationFormatted,
currentTimeFormatted,
progress,
bufferProgress,
pause,
resume,
} = usePlayer()
const {
focused: queueFocused,
currentTrack,
hasNext,
isEmpty: emptyQueue,
tracks,
reorder: reorderTracks,
endsIn: timeLeft,
removeTrack,
clear,
next,
previous
} = useQueue()
const reorder = (event: { oldIndex: number, newIndex: number }) => reorderTracks(event.oldIndex, event.newIndex)
const labels = computed(() => ({
queue: $pgettext('*/*/*', 'Queue'),
duration: $pgettext('*/*/*', 'Duration'),
addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'),
restart: $pgettext('*/*/*', 'Restart track'),
previous: $pgettext('*/*/*', 'Previous track'),
next: $pgettext('*/*/*', 'Next track'),
pause: $pgettext('*/*/*', 'Pause'),
play: $pgettext('*/*/*', 'Play'),
remove: $pgettext('*/*/*', 'Remove'),
selectTrack: $pgettext('*/*/*', 'Select track')
}))
whenever(queueFocused, scrollToCurrent, { immediate: true })
whenever(currentTrack, scrollToCurrent, { immediate: true })
whenever(
() => tracks.value.length === 0,
() => store.commit('ui/queueFocused', null),
{ immediate: true }
)
const router = useRouter()
router.beforeEach(() => store.commit('ui/queueFocused', null))
// TODO (wvffle): move setCurrentTime to usePlayer
const emit = defineEmits(['touch-progress'])
const progressBar = ref()
const touchProgress = (event: MouseEvent) => {
const time = (event.clientX / progressBar.value.offsetWidth) * duration.value
emit('touch-progress', time)
}
</script>
<template>
<section
ref="queueModal"
@ -107,7 +219,7 @@
class="progress-area"
>
<div
ref="progress"
ref="progressBar"
:class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']"
@click="touchProgress"
>
@ -141,7 +253,7 @@
href=""
:aria-label="labels.restart"
class="left floated timer discrete start"
@click.prevent="setCurrentTime(0)"
@click.prevent="emit('touch-progress', 0)"
>{{ currentTimeFormatted }}</a>
<span class="right floated timer total">{{ durationFormatted }}</span>
</template>
@ -154,11 +266,11 @@
<div class="player-controls tablet-and-below">
<span
role="button"
:title="labels.previousTrack"
:aria-label="labels.previousTrack"
:title="labels.previous"
:aria-label="labels.previous"
class="control"
:disabled="emptyQueue || null"
@click.prevent.stop="$store.dispatch('queue/previous')"
@click.prevent.stop="previous"
>
<i :class="['ui', 'backward step', {'disabled': emptyQueue}, 'icon']" />
</span>
@ -169,7 +281,7 @@
:title="labels.play"
:aria-label="labels.play"
class="control"
@click.prevent.stop="resumePlayback"
@click.prevent.stop="resume"
>
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" />
</span>
@ -179,7 +291,7 @@
:title="labels.pause"
:aria-label="labels.pause"
class="control"
@click.prevent.stop="pausePlayback"
@click.prevent.stop="pause"
>
<i :class="['ui', 'pause', {'disabled': !currentTrack}, 'icon']" />
</span>
@ -189,7 +301,7 @@
:aria-label="labels.next"
class="control"
:disabled="hasNext || null"
@click.prevent.stop="$store.dispatch('queue/next')"
@click.prevent.stop="next"
>
<i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" />
</span>
@ -211,7 +323,7 @@
</button>
<button
class="ui right floated basic button danger"
@click="$store.dispatch('queue/clean')"
@click="clear"
>
<translate translate-context="*/Queue/*/Verb">
Clear
@ -225,7 +337,8 @@
:translate-params="{index: currentIndex + 1, length: queue.tracks.length}"
>
Track %{ index } of %{ length }
</translate><template v-if="!$store.state.radios.running">
</translate>
<template v-if="!$store.state.radios.running">
-
<span :title="labels.duration">
{{ timeLeft }}
@ -300,10 +413,10 @@
<i class="pink heart icon" />
</template>
<button
:aria-label="labels.removeFromQueue"
:title="labels.removeFromQueue"
:aria-label="labels.remove"
:title="labels.remove"
:class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']"
@click.stop="cleanTrack(index)"
@click.stop="removeTrack(index)"
>
<i class="x icon" />
</button>
@ -344,193 +457,3 @@
</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>

View File

@ -334,6 +334,7 @@ import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { useThrottleFn, useTimeoutFn, useToggle } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useGettext } from 'vue3-gettext'
import useQueue from '~/composables/useQueue'
export default {
components: {
@ -357,17 +358,7 @@ export default {
store.commit('ui/queueFocused', ['queue', 'player'].indexOf(store.state.ui.queueFocused) > -1 ? null : 'player')
}
const shuffledMessage = $pgettext('Content/Queue/Message', 'Queue shuffled!')
const shuffle = useThrottleFn(() => {
if (queueIsEmpty.value) return
useTimeoutFn(async () => {
await store.dispatch('queue/shuffle')
store.commit('ui/addMessage', {
content: shuffledMessage,
date: new Date()
})
}, 100)
}, 101, false)
const { shuffle } = useQueue()
const seek = (step) => {
if (step > 0) {

View File

@ -0,0 +1,47 @@
import { useStore } from "~/store"
import { computed } from "vue"
export default () => {
const store = useStore()
const looping = computed(() => store.state.player.looping)
const playing = computed(() => store.state.player.playing)
const loading = computed(() => store.state.player.isLoadingAudio)
const errored = computed(() => store.state.player.errored)
const focused = computed(() => store.state.ui.queueFocused === 'player')
const volume = computed(() => store.state.player.volume)
const duration = computed(() => store.state.player.duration)
const currentTime = computed(() => store.state.player.currentTime)
const durationFormatted = computed(() => store.getters['player/durationFormatted'])
const currentTimeFormatted = computed(() => store.getters['player/currentTimeFormatted'])
const progress = computed(() => store.getters['player/progress'])
const bufferProgress = computed(() => store.state.player.bufferProgress)
const pause = () => store.dispatch('player/pausePlayback')
const resume = () => store.dispatch('player/resumePlayback')
return {
looping,
playing,
loading,
errored,
focused,
volume,
duration,
currentTime,
durationFormatted,
currentTimeFormatted,
progress,
bufferProgress,
pause,
resume
}
}

View File

@ -0,0 +1,107 @@
import { useTimeoutFn, useThrottleFn } from "@vueuse/core"
import { useTimeAgo, useNow } from '@vueuse/core'
import { useGettext } from "vue3-gettext"
import { useStore } from "~/store"
import { ref, computed } from "vue"
import { Track } from "~/types"
import { sum } from 'lodash-es'
export default () => {
const store = useStore()
const { $pgettext } = useGettext()
const currentTrack = computed(() => store.getters['queue/currentTrack'])
const currentIndex = computed(() => store.state.queue.currentIndex)
const hasNext = computed(() => store.getters['queue/hasNext'])
const isEmpty = computed(() => store.getters['queue/isEmpty'])
const removeTrack = (index: number) => store.dispatch('queue/cleanTrack', index)
const clear = () => store.dispatch('queue/clean')
const next = () => store.dispatch('queue/next')
const previous = () => store.dispatch('queue/previous')
const focused = computed(() => store.state.ui.queueFocused === 'queue')
//
// Track list
//
const tracksChangeBuffer = ref<Track[] | null>(null)
const tracks = computed<Track[]>({
get: () => store.state.queue.tracks,
set: (value) => (tracksChangeBuffer.value = value)
})
const reorder = (oldIndex: number, newIndex: number) => {
store.commit('queue/reorder', {
tracks: tracksChangeBuffer.value ?? tracks.value,
oldIndex,
newIndex
})
tracksChangeBuffer.value = null
}
//
// Shuffle
//
const isShuffling = ref(false)
const forceShuffle = useThrottleFn(() => {
isShuffling.value = true
useTimeoutFn(async () => {
await store.dispatch('queue/shuffle')
store.commit('ui/addMessage', {
content: $pgettext('Content/Queue/Message', 'Queue shuffled!'),
date: new Date()
})
isShuffling.value = false
}, 100)
})
const shuffle = useThrottleFn(() => {
if (isShuffling.value || isEmpty.value) {
return
}
return forceShuffle()
}, 101, false)
//
// Time left
//
const now = useNow()
const endsIn = useTimeAgo(computed(() => {
const seconds = sum(
tracks.value
.slice(currentIndex.value)
.map((track) => track.uploads?.[0]?.duration ?? 0)
)
const date = new Date(now.value)
date.setSeconds(date.getSeconds() + seconds)
return date
}))
return {
currentTrack,
hasNext,
isEmpty,
removeTrack,
clear,
next,
previous,
tracks,
reorder,
shuffle,
forceShuffle,
endsIn,
focused
}
}

View File

@ -111,9 +111,11 @@ const store: Module<State, RootState> = {
cleanTrack ({ state, dispatch, commit }, index) {
// are we removing current playin track
const current = index === state.currentIndex
if (current) {
dispatch('player/stop', null, { root: true })
}
commit('splice', { start: index, size: 1 })
if (index < state.currentIndex) {
commit('currentIndex', state.currentIndex - 1)
@ -127,6 +129,7 @@ const store: Module<State, RootState> = {
// we play next track, which now have the same index
commit('currentIndex', index)
}
if (state.currentIndex + 1 === state.tracks.length) {
dispatch('radios/populateQueue', null, { root: true })
}
@ -171,14 +174,11 @@ const store: Module<State, RootState> = {
// so we replay automatically on next track append
commit('ended', true)
},
async shuffle ({ dispatch, state }, callback) {
async shuffle ({ dispatch, state }) {
const shuffled = shuffle(state.tracks)
state.tracks.length = 0
const params: { tracks: Track[], callback?: () => unknown } = { tracks: shuffled }
if (callback) {
params.callback = callback
}
await dispatch('appendMany', params)
await dispatch('appendMany', { tracks: shuffled })
await dispatch('currentIndex', 0)
}
}

View File

@ -67,6 +67,7 @@ export interface Track {
copyright?: string
license?: License
tags: string[]
uploads: Upload[]
album?: Album
artist?: Artist
@ -169,6 +170,7 @@ export interface Upload {
filename?: string
source?: string
uuid: string
duration?: number
}
// FileSystem Logs