Migrate queue component
This commit is contained in:
parent
bef0d1dec4
commit
ccb905b004
|
@ -16,14 +16,15 @@ export interface Sound {
|
||||||
dispose(): void
|
dispose(): void
|
||||||
|
|
||||||
readonly audioNode: IAudioNode<IAudioContext>
|
readonly audioNode: IAudioNode<IAudioContext>
|
||||||
|
readonly isErrored: Ref<boolean>
|
||||||
readonly isLoaded: Ref<boolean>
|
readonly isLoaded: Ref<boolean>
|
||||||
readonly currentTime: number
|
readonly currentTime: number
|
||||||
readonly duration: number
|
readonly duration: number
|
||||||
readonly buffered: number
|
readonly buffered: number
|
||||||
looping: boolean
|
looping: boolean
|
||||||
|
|
||||||
play(): void | Promise<void>
|
|
||||||
pause(): void | Promise<void>
|
pause(): void | Promise<void>
|
||||||
|
play(): void | Promise<void>
|
||||||
|
|
||||||
seekTo(seconds: number): void | Promise<void>
|
seekTo(seconds: number): void | Promise<void>
|
||||||
seekBy(seconds: number): void | Promise<void>
|
seekBy(seconds: number): void | Promise<void>
|
||||||
|
@ -46,6 +47,7 @@ export class HTMLSound implements Sound {
|
||||||
#soundLoopEventHook = createEventHook<HTMLSound>()
|
#soundLoopEventHook = createEventHook<HTMLSound>()
|
||||||
#soundEndEventHook = createEventHook<HTMLSound>()
|
#soundEndEventHook = createEventHook<HTMLSound>()
|
||||||
|
|
||||||
|
readonly isErrored = ref(false)
|
||||||
readonly isLoaded = ref(false)
|
readonly isLoaded = ref(false)
|
||||||
|
|
||||||
audioNode = createAudioSource(this.#audio)
|
audioNode = createAudioSource(this.#audio)
|
||||||
|
@ -69,6 +71,11 @@ export class HTMLSound implements Sound {
|
||||||
this.isLoaded.value = this.#audio.readyState >= 2
|
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.onSoundLoop = this.#soundLoopEventHook.on
|
||||||
this.onSoundEnd = this.#soundEndEventHook.on
|
this.onSoundEnd = this.#soundEndEventHook.on
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,21 +1,43 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { QueueItemSource } from '~/types'
|
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 { 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 { useGettext } from 'vue3-gettext'
|
||||||
import useQueue from '~/composables/audio/useQueue'
|
import { useRouter } from 'vue-router'
|
||||||
import usePlayer from '~/composables/audio/usePlayer'
|
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 VirtualList from '~/components/vui/list/VirtualList.vue'
|
||||||
import QueueItem from '~/components/QueueItem.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 queueModal = ref()
|
||||||
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
|
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
|
||||||
|
@ -24,34 +46,6 @@ const { $pgettext } = useGettext()
|
||||||
const scrollLock = useScrollLock(document.body)
|
const scrollLock = useScrollLock(document.body)
|
||||||
const store = useStore()
|
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(() => ({
|
const labels = computed(() => ({
|
||||||
queue: $pgettext('*/*/*', 'Queue'),
|
queue: $pgettext('*/*/*', 'Queue'),
|
||||||
duration: $pgettext('*/*/*', 'Duration'),
|
duration: $pgettext('*/*/*', 'Duration'),
|
||||||
|
@ -96,7 +90,7 @@ const scrollLoop = () => {
|
||||||
onMounted(scrollLoop)
|
onMounted(scrollLoop)
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => tracks.value.length === 0,
|
() => queue.value.length === 0,
|
||||||
() => store.commit('ui/queueFocused', null),
|
() => store.commit('ui/queueFocused', null),
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
@ -107,12 +101,12 @@ router.beforeEach(() => store.commit('ui/queueFocused', null))
|
||||||
const progressBar = ref()
|
const progressBar = ref()
|
||||||
const touchProgress = (event: MouseEvent) => {
|
const touchProgress = (event: MouseEvent) => {
|
||||||
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
|
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) => {
|
const play = async (index: number) => {
|
||||||
store.dispatch('queue/currentIndex', index as number)
|
isPlaying.value = true
|
||||||
resume()
|
return playTrack(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueItems = computed(() => queue.value.map((track, 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-container">
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
<img
|
<img
|
||||||
v-if="currentTrack.cover && currentTrack.cover.urls.large_square_crop"
|
|
||||||
ref="cover"
|
ref="cover"
|
||||||
alt=""
|
alt=""
|
||||||
:src="$store.getters['instance/absoluteUrl'](currentTrack.cover.urls.large_square_crop)"
|
:src="$store.getters['instance/absoluteUrl'](currentTrack.coverUrl)"
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -182,17 +163,17 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
<div class="sub header ellipsis">
|
<div class="sub header ellipsis">
|
||||||
<router-link
|
<router-link
|
||||||
class="discrete link artist"
|
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>
|
</router-link>
|
||||||
<template v-if="currentTrack.album">
|
<template v-if="currentTrack.albumId !== -1">
|
||||||
/
|
/
|
||||||
<router-link
|
<router-link
|
||||||
class="discrete link album"
|
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>
|
</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -207,7 +188,7 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
The track cannot be loaded
|
The track cannot be loaded
|
||||||
</translate>
|
</translate>
|
||||||
</h3>
|
</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">
|
<translate translate-context="Sidebar/Player/Error message.Paragraph">
|
||||||
The next track will play automatically in a few seconds…
|
The next track will play automatically in a few seconds…
|
||||||
</translate>
|
</translate>
|
||||||
|
@ -220,7 +201,8 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="additional-controls desktop-and-below">
|
<div class="additional-controls desktop-and-below">
|
||||||
<track-favorite-icon
|
<!-- TODO (wvffle): Update props -->
|
||||||
|
<!-- <track-favorite-icon
|
||||||
v-if="$store.state.auth.authenticated"
|
v-if="$store.state.auth.authenticated"
|
||||||
:track="currentTrack"
|
:track="currentTrack"
|
||||||
/>
|
/>
|
||||||
|
@ -236,7 +218,7 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
|
@click="$store.dispatch('moderation/hide', {type: 'artist', target: currentTrack.artist})"
|
||||||
>
|
>
|
||||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
<i :class="['eye slash outline', 'basic', 'icon']" />
|
||||||
</button>
|
</button> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-wrapper">
|
<div class="progress-wrapper">
|
||||||
<div
|
<div
|
||||||
|
@ -277,8 +259,10 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
:aria-label="labels.restart"
|
:aria-label="labels.restart"
|
||||||
class="left floated timer discrete start"
|
class="left floated timer discrete start"
|
||||||
@click.prevent="currentTime = 0"
|
@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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="left floated timer">00:00</span>
|
<span class="left floated timer">00:00</span>
|
||||||
|
@ -286,49 +270,7 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="player-controls desktop-and-below">
|
<player-controls class="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>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="queue">
|
<div id="queue">
|
||||||
|
@ -345,7 +287,7 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ui right floated basic button danger"
|
class="ui right floated basic button danger"
|
||||||
@click="clear"
|
@click="tracks.length = 0"
|
||||||
>
|
>
|
||||||
<translate translate-context="*/Queue/*/Verb">
|
<translate translate-context="*/Queue/*/Verb">
|
||||||
Clear
|
Clear
|
||||||
|
@ -356,7 +298,7 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
<div>
|
<div>
|
||||||
<translate
|
<translate
|
||||||
translate-context="Sidebar/Queue/Text"
|
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 }
|
Track %{ index } of %{ length }
|
||||||
</translate>
|
</translate>
|
||||||
|
@ -387,7 +329,7 @@ const reorderTracks = async (from: number, to: number) => {
|
||||||
:source="item"
|
:source="item"
|
||||||
:class="[...classList, currentIndex === index && 'active']"
|
:class="[...classList, currentIndex === index && 'active']"
|
||||||
@play="play"
|
@play="play"
|
||||||
@remove="removeTrack"
|
@remove="dequeue"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</virtual-list>
|
</virtual-list>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { QueueItemSource } from '~/types'
|
import type { QueueItemSource } from '~/types'
|
||||||
|
|
||||||
|
import time from '~/utils/time'
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'play', index: number): void
|
(e: 'play', index: number): void
|
||||||
(e: 'remove', index: number): void
|
(e: 'remove', index: number): void
|
||||||
|
@ -47,7 +49,7 @@ defineProps<Props>()
|
||||||
</div>
|
</div>
|
||||||
<div class="duration-cell">
|
<div class="duration-cell">
|
||||||
<template v-if="source.sources.length > 0">
|
<template v-if="source.sources.length > 0">
|
||||||
{{ source.duration }}
|
{{ time.parse(Math.round(source.sources[0].duration ?? 0)) }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
|
|
|
@ -17,9 +17,7 @@ import {
|
||||||
} from '~/composables/audio/player'
|
} from '~/composables/audio/player'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
hasPrevious,
|
|
||||||
playPrevious,
|
playPrevious,
|
||||||
hasNext,
|
|
||||||
playNext,
|
playNext,
|
||||||
queue,
|
queue,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
|
@ -38,6 +36,7 @@ import time from '~/utils/time'
|
||||||
// import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
// import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
// import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
// import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||||
import VolumeControl from './VolumeControl.vue'
|
import VolumeControl from './VolumeControl.vue'
|
||||||
|
import PlayerControls from './PlayerControls.vue'
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const { $pgettext } = useGettext()
|
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 a single track. Click to switch to whole queue looping.')
|
||||||
: $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on whole queue. Click to disable looping.')
|
: $pgettext('Sidebar/Player/Icon.Tooltip', 'Looping on whole queue. Click to disable looping.')
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.value)))
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -233,45 +230,7 @@ const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.va
|
||||||
<i :class="['eye slash outline', 'basic', 'icon']" />
|
<i :class="['eye slash outline', 'basic', 'icon']" />
|
||||||
</button> -->
|
</button> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="player-controls controls queue-not-focused">
|
<player-controls class="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>
|
|
||||||
|
|
||||||
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
|
<div class="controls progress-controls queue-not-focused tablet-and-up small align-left">
|
||||||
<div class="timer">
|
<div class="timer">
|
||||||
<template v-if="!isLoadingAudio">
|
<template v-if="!isLoadingAudio">
|
||||||
|
@ -279,7 +238,7 @@ const currentTimeFormatted = computed(() => time.parse(Math.round(currentTime.va
|
||||||
class="start"
|
class="start"
|
||||||
@click.stop.prevent="seekTo(0)"
|
@click.stop.prevent="seekTo(0)"
|
||||||
>
|
>
|
||||||
{{ currentTimeFormatted }}
|
{{ time.parse(Math.round(currentTime)) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
|
||||||
<span class="total">{{ time.parse(Math.round(duration)) }}</span>
|
<span class="total">{{ time.parse(Math.round(duration)) }}</span>
|
||||||
|
|
|
@ -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>
|
|
@ -1,5 +1,5 @@
|
||||||
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, whenever } from '@vueuse/core'
|
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, useTimeoutFn, whenever } from '@vueuse/core'
|
||||||
import { currentTrack, currentIndex } from '~/composables/audio/queue'
|
import { currentTrack, currentIndex, playNext } from '~/composables/audio/queue'
|
||||||
import { currentSound, createTrack } from '~/composables/audio/tracks'
|
import { currentSound, createTrack } from '~/composables/audio/tracks'
|
||||||
import { computed, ref, watch, watchEffect, type Ref } from 'vue'
|
import { computed, ref, watch, watchEffect, type Ref } from 'vue'
|
||||||
import { setGain } from './audio-api'
|
import { setGain } from './audio-api'
|
||||||
|
@ -168,3 +168,14 @@ export const loading = computed(() => {
|
||||||
if (!sound) return false
|
if (!sound) return false
|
||||||
return !sound.isLoaded.value
|
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)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Track, Upload } from '~/types'
|
import type { Track, Upload } from '~/types'
|
||||||
|
|
||||||
import { computedAsync, useStorage } from '@vueuse/core'
|
import { computedAsync, useNow, useStorage, useTimeAgo } from '@vueuse/core'
|
||||||
import { shuffle as shuffleArray, uniq } from 'lodash-es'
|
import { shuffle as shuffleArray, sum, uniq } from 'lodash-es'
|
||||||
import { getMany, setMany } from 'idb-keyval'
|
import { getMany, setMany } from 'idb-keyval'
|
||||||
import { useClamp } from '@vueuse/math'
|
import { useClamp } from '@vueuse/math'
|
||||||
import { computed } from 'vue'
|
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,
|
?? new URL('~/assets/audio/default-cover.png', import.meta.url).href,
|
||||||
sources: track.uploads.map(upload => ({
|
sources: track.uploads.map(upload => ({
|
||||||
uuid: upload.uuid,
|
uuid: upload.uuid,
|
||||||
|
duration: upload.duration,
|
||||||
mimetype: upload.mimetype,
|
mimetype: upload.mimetype,
|
||||||
bitrate: upload.bitrate,
|
bitrate: upload.bitrate,
|
||||||
url: upload.listen_url
|
url: upload.listen_url
|
||||||
|
@ -83,6 +84,7 @@ const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adding tracks
|
||||||
export const enqueue = async (...newTracks: Track[]) => {
|
export const enqueue = async (...newTracks: Track[]) => {
|
||||||
const queueTracks = await Promise.all(newTracks.map(createQueueTrack))
|
const queueTracks = await Promise.all(newTracks.map(createQueueTrack))
|
||||||
await setMany(queueTracks.map(track => [track.id, track]))
|
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
|
// Current Index
|
||||||
export const currentIndex = useClamp(useStorage('queue:index', 0), 0, () => tracks.value.length)
|
export const currentIndex = useClamp(useStorage('queue:index', 0), 0, () => tracks.value.length)
|
||||||
export const currentTrack = computed(() => queue.value[currentIndex.value])
|
export const currentTrack = computed(() => queue.value[currentIndex.value])
|
||||||
|
@ -119,11 +136,11 @@ export const playTrack = async (trackIndex: number, force = false) => {
|
||||||
|
|
||||||
// Previous track
|
// Previous track
|
||||||
export const hasPrevious = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== 0)
|
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')
|
const { looping, LoopingMode } = await import('~/composables/audio/player')
|
||||||
|
|
||||||
// Loop entire queue / change track to the next one
|
// 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
|
// Loop track programmatically if it is the only track in the queue
|
||||||
if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
|
if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
|
||||||
return playTrack(tracks.value.length - 1)
|
return playTrack(tracks.value.length - 1)
|
||||||
|
@ -134,11 +151,11 @@ export const playPrevious = async () => {
|
||||||
|
|
||||||
// Next track
|
// Next track
|
||||||
export const hasNext = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== tracks.value.length - 1)
|
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')
|
const { looping, LoopingMode } = await import('~/composables/audio/player')
|
||||||
|
|
||||||
// Loop entire queue / change track to the next one
|
// 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
|
// Loop track programmatically if it is the only track in the queue
|
||||||
if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
|
if (tracks.value.length === 1) return playTrack(currentIndex.value, true)
|
||||||
return playTrack(0)
|
return playTrack(0)
|
||||||
|
@ -147,6 +164,28 @@ export const playNext = async () => {
|
||||||
return playTrack(currentIndex.value + 1)
|
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
|
// Shuffle
|
||||||
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
|
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
|
||||||
export const isShuffled = computed(() => shuffledIds.value.length !== 0)
|
export const isShuffled = computed(() => shuffledIds.value.length !== 0)
|
||||||
|
@ -158,3 +197,17 @@ export const shuffle = () => {
|
||||||
|
|
||||||
shuffledIds.value = shuffleArray(tracks.value)
|
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
|
||||||
|
}))
|
||||||
|
|
|
@ -207,6 +207,13 @@
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.handle {
|
.handle {
|
||||||
|
@ -349,4 +356,4 @@
|
||||||
.drag-container:not(.dragging) .hover .queue-item {
|
.drag-container:not(.dragging) .hover .queue-item {
|
||||||
background: rgba(0,0,0,.05);
|
background: rgba(0,0,0,.05);
|
||||||
color: #000000f2;
|
color: #000000f2;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue