Migrate queue component

This commit is contained in:
wvffle 2022-10-25 19:07:36 +00:00 committed by Georg Krause
parent bef0d1dec4
commit ccb905b004
No known key found for this signature in database
GPG Key ID: 2970D504B2183D22
8 changed files with 210 additions and 167 deletions

View File

@ -16,14 +16,15 @@ export interface Sound {
dispose(): void
readonly audioNode: IAudioNode<IAudioContext>
readonly isErrored: Ref<boolean>
readonly isLoaded: Ref<boolean>
readonly currentTime: number
readonly duration: number
readonly buffered: number
looping: boolean
play(): void | Promise<void>
pause(): void | Promise<void>
play(): void | Promise<void>
seekTo(seconds: number): void | Promise<void>
seekBy(seconds: number): void | Promise<void>
@ -46,6 +47,7 @@ export class HTMLSound implements Sound {
#soundLoopEventHook = createEventHook<HTMLSound>()
#soundEndEventHook = createEventHook<HTMLSound>()
readonly isErrored = ref(false)
readonly isLoaded = ref(false)
audioNode = createAudioSource(this.#audio)
@ -69,6 +71,11 @@ export class HTMLSound implements Sound {
this.isLoaded.value = this.#audio.readyState >= 2
})
useEventListener(this.#audio, 'error', () => {
this.isErrored.value = true
this.isLoaded.value = true
})
this.onSoundLoop = this.#soundLoopEventHook.on
this.onSoundEnd = this.#soundEndEventHook.on
}

View File

@ -1,21 +1,43 @@
<script setup lang="ts">
import type { QueueItemSource } from '~/types'
import { useStore } from '~/store'
import { nextTick, ref, computed, watchEffect, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import { whenever, watchDebounced, useCurrentElement, useScrollLock } from '@vueuse/core'
import { nextTick, ref, computed, watchEffect, onMounted } from 'vue'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import { useGettext } from 'vue3-gettext'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import { useRouter } from 'vue-router'
import { useStore } from '~/store'
import time from '~/utils/time'
// import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
// import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import PlayerControls from '~/components/audio/PlayerControls.vue'
import VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue'
import { queue } from '~/composables/audio/queue'
import {
isPlaying,
currentTime,
duration,
progress,
bufferProgress,
seekTo,
loading as isLoadingAudio,
errored
} from '~/composables/audio/player'
import {
hasNext,
currentTrack,
currentIndex,
queue,
tracks,
dequeue,
playTrack,
reorder,
endsIn as timeLeft
} from '~/composables/audio/queue'
const queueModal = ref()
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
@ -24,34 +46,6 @@ const { $pgettext } = useGettext()
const scrollLock = useScrollLock(document.body)
const store = useStore()
const {
playing,
loading: isLoadingAudio,
errored,
duration,
durationFormatted,
currentTimeFormatted,
progress,
bufferProgress,
currentTime,
pause,
resume
} = usePlayer()
const {
currentTrack,
hasNext,
isEmpty: emptyQueue,
tracks,
reorder,
endsIn: timeLeft,
currentIndex,
removeTrack,
clear,
next,
previous
} = useQueue()
const labels = computed(() => ({
queue: $pgettext('*/*/*', 'Queue'),
duration: $pgettext('*/*/*', 'Duration'),
@ -96,7 +90,7 @@ const scrollLoop = () => {
onMounted(scrollLoop)
whenever(
() => tracks.value.length === 0,
() => queue.value.length === 0,
() => store.commit('ui/queueFocused', null),
{ immediate: true }
)
@ -107,12 +101,12 @@ router.beforeEach(() => store.commit('ui/queueFocused', null))
const progressBar = ref()
const touchProgress = (event: MouseEvent) => {
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
currentTime.value = time
seekTo(time)
}
const play = (index: unknown) => {
store.dispatch('queue/currentIndex', index as number)
resume()
const play = async (index: number) => {
isPlaying.value = true
return playTrack(index)
}
const queueItems = computed(() => queue.value.map((track, index) => ({
@ -152,22 +146,9 @@ const reorderTracks = async (from: number, to: number) => {
<div class="cover-container">
<div class="cover">
<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"
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
>
</div>
</div>
@ -182,17 +163,17 @@ const reorderTracks = async (from: number, to: number) => {
<div class="sub header ellipsis">
<router-link
class="discrete link artist"
:to="{name: 'library.artists.detail', params: {id: currentTrack.artist.id }}"
:to="{name: 'library.artists.detail', params: {id: currentTrack.artistId }}"
>
{{ currentTrack.artist.name }}
{{ currentTrack.artistName }}
</router-link>
<template v-if="currentTrack.album">
<template v-if="currentTrack.albumId !== -1">
/
<router-link
class="discrete link album"
:to="{name: 'library.albums.detail', params: {id: currentTrack.album.id }}"
:to="{name: 'library.albums.detail', params: {id: currentTrack.albumId }}"
>
{{ currentTrack.album.title }}
{{ currentTrack.albumTitle }}
</router-link>
</template>
</div>
@ -207,7 +188,7 @@ const reorderTracks = async (from: number, to: number) => {
The track cannot be loaded
</translate>
</h3>
<p v-if="hasNext && playing && $store.state.player.errorCount < $store.state.player.maxConsecutiveErrors">
<p v-if="hasNext && isPlaying && $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>
@ -220,7 +201,8 @@ const reorderTracks = async (from: number, to: number) => {
</p>
</div>
<div class="additional-controls desktop-and-below">
<track-favorite-icon
<!-- TODO (wvffle): Update props -->
<!-- <track-favorite-icon
v-if="$store.state.auth.authenticated"
:track="currentTrack"
/>
@ -236,7 +218,7 @@ const reorderTracks = async (from: number, to: number) => {
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
>
<i :class="['eye slash outline', 'basic', 'icon']" />
</button>
</button> -->
</div>
<div class="progress-wrapper">
<div
@ -277,8 +259,10 @@ const reorderTracks = async (from: number, to: number) => {
:aria-label="labels.restart"
class="left floated timer discrete start"
@click.prevent="currentTime = 0"
>{{ currentTimeFormatted }}</a>
<span class="right floated timer total">{{ durationFormatted }}</span>
>
{{ time.parse(Math.round(currentTime)) }}
</a>
<span class="right floated timer total">{{ time.parse(Math.round(duration)) }}</span>
</template>
<template v-else>
<span class="left floated timer">00:00</span>
@ -286,49 +270,7 @@ const reorderTracks = async (from: number, to: number) => {
</template>
</div>
</div>
<div class="player-controls desktop-and-below">
<span
role="button"
:title="labels.previous"
:aria-label="labels.previous"
class="control"
:disabled="emptyQueue || null"
@click.prevent.stop="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="resume"
>
<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="pause"
>
<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="next"
>
<i :class="['ui', {'disabled': !hasNext}, 'forward step', 'icon']" />
</span>
</div>
<player-controls class="desktop-and-below" />
</template>
</div>
<div id="queue">
@ -345,7 +287,7 @@ const reorderTracks = async (from: number, to: number) => {
</button>
<button
class="ui right floated basic button danger"
@click="clear"
@click="tracks.length = 0"
>
<translate translate-context="*/Queue/*/Verb">
Clear
@ -356,7 +298,7 @@ const reorderTracks = async (from: number, to: number) => {
<div>
<translate
translate-context="Sidebar/Queue/Text"
:translate-params="{index: currentIndex + 1, length: tracks.length}"
:translate-params="{index: currentIndex + 1, length: queue.length}"
>
Track %{ index } of %{ length }
</translate>
@ -387,7 +329,7 @@ const reorderTracks = async (from: number, to: number) => {
:source="item"
:class="[...classList, currentIndex === index && 'active']"
@play="play"
@remove="removeTrack"
@remove="dequeue"
/>
</template>
</virtual-list>

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import type { QueueItemSource } from '~/types'
import time from '~/utils/time'
interface Events {
(e: 'play', index: number): void
(e: 'remove', index: number): void
@ -47,7 +49,7 @@ defineProps<Props>()
</div>
<div class="duration-cell">
<template v-if="source.sources.length > 0">
{{ source.duration }}
{{ time.parse(Math.round(source.sources[0].duration ?? 0)) }}
</template>
</div>
<div class="controls">

View File

@ -17,9 +17,7 @@ import {
} from '~/composables/audio/player'
import {
hasPrevious,
playPrevious,
hasNext,
playNext,
queue,
currentIndex,
@ -38,6 +36,7 @@ import time from '~/utils/time'
// import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
// import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import VolumeControl from './VolumeControl.vue'
import PlayerControls from './PlayerControls.vue'
const store = useStore()
const { $pgettext } = useGettext()
@ -104,8 +103,6 @@ const loopingTitle = computed(() => {
? $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on a single track. Click to switch to whole queue looping.')
: $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on whole queue. Click to disable looping.')
})
const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.value)))
</script>
<template>
@ -233,45 +230,7 @@ const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.va
<i :class="['eye slash outline', 'basic', 'icon']" />
</button> -->
</div>
<div class="player-controls controls queue-not-focused">
<button
:title="labels.previous"
:aria-label="labels.previous"
:disabled="!hasPrevious"
class="circular button control tablet-and-up"
@click.prevent.stop="playPrevious"
>
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" />
</button>
<button
v-if="!isPlaying"
:title="labels.play"
:aria-label="labels.play"
class="circular button control"
@click.prevent.stop="isPlaying = true"
>
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
v-else
:title="labels.pause"
:aria-label="labels.pause"
class="circular button control"
@click.prevent.stop="isPlaying = false"
>
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
:title="labels.next"
:aria-label="labels.next"
:disabled="!hasNext"
class="circular button control"
@click.prevent.stop="playNext"
>
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
</button>
</div>
<player-controls class="controls queue-not-focused" />
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
<div class="timer">
<template v-if="!isLoadingAudio">
@ -279,7 +238,7 @@ const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.va
class="start"
@click.stop.prevent="seekTo(0)"
>
{{ currentTimeFormatted }}
{{ time.parse(Math.round(currentTime)) }}
</span>
|
<span class="total">{{ time.parse(Math.round(duration)) }}</span>

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import { useGettext } from 'vue3-gettext'
import { computed } from 'vue'
import { isPlaying } from '~/composables/audio/player'
import {
hasPrevious,
playPrevious,
hasNext,
playNext,
currentTrack
} from '~/composables/audio/queue'
const { $pgettext } = useGettext()
const labels = computed(() => ({
previous: $pgettext('Sidebar/Player/Icon.Tooltip', 'Previous track'),
play: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Play'),
pause: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Pause'),
next: $pgettext('Sidebar/Player/Icon.Tooltip', 'Next track')
}))
</script>
<template>
<div class="player-controls">
<button
:title="labels.previous"
:aria-label="labels.previous"
:disabled="!hasPrevious"
class="circular button control tablet-and-up"
@click.prevent.stop="playPrevious()"
>
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" />
</button>
<button
v-if="!isPlaying"
:title="labels.play"
:aria-label="labels.play"
class="circular button control"
@click.prevent.stop="isPlaying = true"
>
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
v-else
:title="labels.pause"
:aria-label="labels.pause"
class="circular button control"
@click.prevent.stop="isPlaying = false"
>
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
</button>
<button
:title="labels.next"
:aria-label="labels.next"
:disabled="!hasNext"
class="circular button control"
@click.prevent.stop="playNext()"
>
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
</button>
</div>
</template>

View File

@ -1,5 +1,5 @@
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, whenever } from '@vueuse/core'
import { currentTrack, currentIndex } from '~/composables/audio/queue'
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, useTimeoutFn, whenever } from '@vueuse/core'
import { currentTrack, currentIndex, playNext } from '~/composables/audio/queue'
import { currentSound, createTrack } from '~/composables/audio/tracks'
import { computed, ref, watch, watchEffect, type Ref } from 'vue'
import { setGain } from './audio-api'
@ -168,3 +168,14 @@ export const loading = computed(() => {
if (!sound) return false
return !sound.isLoaded.value
})
// Errored
export const errored = computed(() => {
const sound = currentSound.value
if (!sound) return false
return sound.isErrored.value
})
const { start, stop } = useTimeoutFn(() => playNext(), 3000, { immediate: false })
watch(currentIndex, stop)
whenever(errored, start)

View File

@ -1,7 +1,7 @@
import type { Track, Upload } from '~/types'
import { computedAsync, useStorage } from '@vueuse/core'
import { shuffle as shuffleArray, uniq } from 'lodash-es'
import { computedAsync, useNow, useStorage, useTimeAgo } from '@vueuse/core'
import { shuffle as shuffleArray, sum, uniq } from 'lodash-es'
import { getMany, setMany } from 'idb-keyval'
import { useClamp } from '@vueuse/math'
import { computed } from 'vue'
@ -76,6 +76,7 @@ const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
?? new URL('~/assets/audio/default-cover.png', import.meta.url).href,
sources: track.uploads.map(upload => ({
uuid: upload.uuid,
duration: upload.duration,
mimetype: upload.mimetype,
bitrate: upload.bitrate,
url: upload.listen_url
@ -83,6 +84,7 @@ const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
}
}
// Adding tracks
export const enqueue = async (...newTracks: Track[]) => {
const queueTracks = await Promise.all(newTracks.map(createQueueTrack))
await setMany(queueTracks.map(track => [track.id, track]))
@ -96,6 +98,21 @@ export const enqueue = async (...newTracks: Track[]) => {
}
}
// Removing tracks
export const dequeue = async (index: number) => {
if (currentIndex.value === index) {
await playNext(true)
}
tracks.value.splice(index, 1)
if (index <= currentIndex.value) {
currentIndex.value -= 1
}
// TODO (wvffle): Check if removing last element works well
}
// Current Index
export const currentIndex = useClamp(useStorage('queue:index', 0), 0, () => tracks.value.length)
export const currentTrack = computed(() => queue.value[currentIndex.value])
@ -119,11 +136,11 @@ export const playTrack = async (trackIndex: number, force = false) => {
// Previous track
export const hasPrevious = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== 0)
export const playPrevious = async () => {
export const playPrevious = async (force = false) => {
const { looping, LoopingMode } = await import('~/composables/audio/player')
// Loop entire queue / change track to the next one
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === 0) {
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === 0 && force !== true) {
// Loop track programmatically if it is the only track in the queue
if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
return playTrack(tracks.value.length - 1)
@ -134,11 +151,11 @@ export const playPrevious = async () => {
// Next track
export const hasNext = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== tracks.value.length - 1)
export const playNext = async () => {
export const playNext = async (force = false) => {
const { looping, LoopingMode } = await import('~/composables/audio/player')
// Loop entire queue / change track to the next one
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === tracks.value.length - 1) {
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === tracks.value.length - 1 && force !== true) {
// Loop track programmatically if it is the only track in the queue
if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
return playTrack(0)
@ -147,6 +164,28 @@ export const playNext = async () => {
return playTrack(currentIndex.value + 1)
}
// Reorder
export const reorder = (from: number, to: number) => {
const [id] = tracks.value.splice(from, 1)
tracks.value.splice(to, 0, id)
const current = currentIndex.value
if (current === from) {
currentIndex.value = to
return
}
if (from < current && to >= current) {
// item before was moved after
currentIndex.value -= 1
}
if (from > current && to <= current) {
// item after was moved before
currentIndex.value += 1
}
}
// Shuffle
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
export const isShuffled = computed(() => shuffledIds.value.length !== 0)
@ -158,3 +197,17 @@ export const shuffle = () => {
shuffledIds.value = shuffleArray(tracks.value)
}
// Ends in
const now = useNow()
export const endsIn = useTimeAgo(computed(() => {
const seconds = sum(
queue.value
.slice(currentIndex.value)
.map((track) => track.sources[0]?.duration ?? 0)
)
const date = new Date(now.value)
date.setSeconds(date.getSeconds() + seconds)
return date
}))

View File

@ -207,6 +207,13 @@
.icon {
font-size: 1.1em;
}
button {
padding: 0;
border: none;
background-color: transparent;
color: inherit;
}
}
.handle {