funkwhale/front/src/composables/audio/queue.ts

363 wiersze
11 KiB
TypeScript

import type { Track, Upload } from '~/types'
import { createGlobalState, useNow, useStorage, useTimeAgo, whenever } from '@vueuse/core'
import { computed, ref, shallowReactive, watchEffect } from 'vue'
import { shuffle as shuffleArray, sum } from 'lodash-es'
import { useClamp } from '@vueuse/math'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import { looping, LoopingMode, isPlaying } from '~/composables/audio/player'
import { delMany, getMany, setMany } from '~/composables/data/indexedDB'
import { setGain } from '~/composables/audio/audio-api'
import { useTracks } from '~/composables/audio/tracks'
import axios from 'axios'
export interface QueueTrackSource {
uuid: string
mimetype: string
bitrate?: number
url: string
duration?: number
}
export interface QueueTrack {
id: number
title: string
artistName: string
albumTitle: string
position?: number
// TODO: Add urls for those
coverUrl: string
artistId: number
albumId: number
sources: QueueTrackSource[]
}
// Queue
const tracks = useStorage('queue:tracks', [] as number[])
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
const isShuffled = computed(() => shuffledIds.value.length !== 0)
const tracksById = shallowReactive(new Map<number, QueueTrack>())
const fetchingTracks = ref(false)
watchEffect(async () => {
if (fetchingTracks.value) return
const allTracks = new Set(tracks.value)
const removedIds = new Set<number>()
const addedIds = new Set(allTracks)
for (const id of tracksById.keys()) {
if (allTracks.has(id)) {
// Track in queue, so remove it from the new ids set
addedIds.delete(id)
} else {
// Track removed from queue, so remove it from the object and db later
removedIds.add(id)
}
}
if (addedIds.size > 0) {
fetchingTracks.value = true
try {
const trackInfos: QueueTrack[] = await getMany([...addedIds])
for (const track of trackInfos.filter(i => i)) {
tracksById.set(track.id, track)
}
} catch (error) {
console.error(error)
} finally {
fetchingTracks.value = false
}
}
if (removedIds.size > 0) {
await delMany([...removedIds])
for (const id of removedIds) {
tracksById.delete(id)
}
}
})
const queue = computed<QueueTrack[]>(() => {
const ids = isShuffled.value
? shuffledIds.value
: tracks.value
return ids.map(id => tracksById.get(id)).filter((i): i is QueueTrack => !!i)
})
// Current Index
export const currentIndex = useClamp(useStorage('queue:index', 0), 0, () => tracks.value.length - 1)
export const currentTrack = computed(() => queue.value[currentIndex.value])
// Use Queue
export const useQueue = createGlobalState(() => {
const { currentSound } = useTracks()
const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
const { t } = useI18n()
const { default: store } = await import('~/store')
if (track.uploads.length === 0) {
// we don't have any information for this track, we need to fetch it
const { uploads } = await axios.get(`tracks/${track.id}/`)
.then(response => response.data as Track, () => ({ uploads: [] as Upload[] }))
track.uploads = uploads
}
return {
id: track.id,
title: track.title,
artistName: track.artist?.name ?? t('composables.audio.queue.unknownArtist'),
albumTitle: track.album?.title ?? t('composables.audio.queue.unknownAlbum'),
position: track.position,
artistId: track.artist?.id ?? -1,
albumId: track.album?.id ?? -1,
coverUrl: (track.cover?.urls ?? track.album?.cover?.urls ?? track.artist?.cover?.urls)?.original
?? 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: store.getters['instance/absoluteUrl'](upload.listen_url)
}))
}
}
// Adding tracks
const enqueueAt = async (index: number, ...newTracks: Track[]) => {
const queueTracks = await Promise.all(newTracks.map(createQueueTrack))
await setMany(queueTracks.map(track => [track.id, track]))
const ids = queueTracks.map(track => track.id)
if (index >= tracks.value.length) {
// we simply push to the end
tracks.value.push(...ids)
} else {
// we insert the track at given position
tracks.value.splice(index, 0, ...ids)
}
// Shuffle new tracks
if (isShuffled.value) {
shuffledIds.value.push(...shuffleArray(ids))
}
}
const enqueue = async (...newTracks: Track[]) => {
return enqueueAt(tracks.value.length, ...newTracks)
}
// Removing tracks
const dequeue = async (index: number) => {
if (currentIndex.value === index) {
await playNext(true)
}
tracks.value.splice(index, 1)
if (index <= currentIndex.value) {
currentIndex.value -= 1
}
}
// Play track
const playTrack = async (trackIndex: number, forceRestartIfCurrent = false) => {
if (isPlaying.value) currentSound.value?.pause()
if (currentIndex.value !== trackIndex) currentSound.value?.seekTo(0)
const shouldRestart = forceRestartIfCurrent && currentIndex.value === trackIndex
const nextTrackIsTheSame = queue.value[trackIndex]?.id === currentTrack.value?.id
if (shouldRestart || nextTrackIsTheSame) {
currentSound.value?.seekTo(0)
if (isPlaying.value) currentSound.value?.play()
if (shouldRestart) return
}
currentIndex.value = trackIndex
}
// Previous track
const hasPrevious = computed(() => looping.value === LoopingMode.LoopQueue || currentIndex.value !== 0)
const playPrevious = async (force = false) => {
// Loop entire queue / change track to the next one
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)
}
return playTrack(currentIndex.value - 1)
}
// Next track
const hasNext = computed(() => looping.value === LoopingMode.LoopQueue || currentIndex.value !== tracks.value.length - 1)
const playNext = async (force = false) => {
if (currentIndex.value === tracks.value.length - 1) {
// Loop entire queue / change track to the next one
if (looping.value === LoopingMode.LoopQueue && 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)
}
isPlaying.value = false
}
return playTrack(currentIndex.value + 1)
}
// Reorder
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 shuffle = () => {
if (isShuffled.value) {
const id = shuffledIds.value[currentIndex.value]
shuffledIds.value.length = 0
// NOTE: This this looses the correct index when there are multiple tracks with the same id in the queue
// Since we shuffled the queue before, we probably do not even care for the correct index, just the order
currentIndex.value = tracks.value.indexOf(id)
return
}
const ids = [...tracks.value]
const [first] = ids.splice(currentIndex.value, 1)
shuffledIds.value = [first, ...shuffleArray(ids)]
currentIndex.value = 0
}
const reshuffleUpcomingTracks = () => {
// TODO: Test if needed to add 1 to currentIndex
const listenedTracks = shuffledIds.value.slice(0, currentIndex.value)
const upcomingTracks = shuffledIds.value.slice(currentIndex.value)
listenedTracks.push(...shuffleArray(upcomingTracks))
shuffledIds.value = listenedTracks
}
// Ends in
const now = useNow()
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
}))
// Clear
const clearRadio = ref(false)
const clear = async () => {
currentSound.value?.pause()
currentSound.value?.seekTo(0)
currentSound.value?.dispose()
clearRadio.value = true
const lastTracks = [...tracks.value]
tracks.value.length = 0
await delMany(lastTracks)
}
// Radio queue populating
const trackRadioPopulating = () => {
const store = useStore()
watchEffect(() => {
if (store.state.radios.running && currentIndex.value === tracks.value.length - 1) {
console.log('POPULATING QUEUE FROM RADIO')
return store.dispatch('radios/populateQueue')
}
})
whenever(clearRadio, () => {
clearRadio.value = false
if (store.state.radios.running) {
return store.dispatch('radios/stop')
}
})
// TODO: Remove at 1.5.0
// Migrate old queue format to the new one
if (localStorage.queue) {
(async () => {
const { queue: { currentIndex: index, tracks: oldTracks } } = JSON.parse(localStorage.queue) as { queue: { currentIndex: number, tracks: Track[] } }
if (oldTracks.length !== 0) {
tracks.value.length = 0
await enqueue(...oldTracks)
}
currentIndex.value = index
delete localStorage.queue
})().catch((error) => console.error('Could not successfully migrate between queue versions', error))
}
if (localStorage.player) {
try {
const { player: { looping: loopingMode, volume } } = JSON.parse(localStorage.player) as { player: { looping: LoopingMode, volume: number }}
looping.value = loopingMode ?? 0
setGain(volume ?? 0.7)
delete localStorage.player
} catch (error) {
console.error('Could not successfully migrate between player versions', error)
}
}
}
return {
tracks,
queue,
enqueueAt,
enqueue,
dequeue,
currentIndex,
currentTrack,
playTrack,
hasPrevious,
hasNext,
playPrevious,
playNext,
isShuffled,
shuffle,
reshuffleUpcomingTracks,
reorder,
endsIn,
clear,
trackRadioPopulating
}
})