Add radio support

This commit is contained in:
wvffle 2022-10-28 07:34:24 +00:00 committed by Georg Krause
parent c7f53df4af
commit c828e106b0
No known key found for this signature in database
GPG Key ID: 2970D504B2183D22
15 changed files with 623 additions and 487 deletions

View File

@ -21,5 +21,6 @@ DEBUG=true
# Django Environment Variables # Django Environment Variables
DATABASE_URL=postgresql://postgres@localhost:5432/postgres DATABASE_URL=postgresql://postgres@localhost:5432/postgres
DJANGO_SECRET_KEY=gitpod DJANGO_SECRET_KEY=gitpod
THROTTLING_ENABLED=False
# Gitpod Environment Variables # Gitpod Environment Variables

View File

@ -1,9 +1,8 @@
import type { IAudioContext, IAudioNode } from 'standardized-audio-context' import type { IAudioContext, IAudioNode } from 'standardized-audio-context'
import { createEventHook, refDefault, type EventHookOn, useEventListener } from '@vueuse/core'
import { createAudioSource } from '~/composables/audio/audio-api' import { createAudioSource } from '~/composables/audio/audio-api'
import { reactive, ref, type Ref } from 'vue' import { reactive, ref, type Ref } from 'vue'
import { createEventHook, refDefault, type EventHookOn } from '@vueuse/shared'
import { useEventListener } from '@vueuse/core'
export interface SoundSource { export interface SoundSource {
uuid: string uuid: string

View File

@ -8,6 +8,9 @@ import { useGettext } from 'vue3-gettext'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useStore } from '~/store' import { useStore } from '~/store'
import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue'
import time from '~/utils/time' import time from '~/utils/time'
// import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' // import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
@ -16,18 +19,18 @@ 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 { const {
isPlaying, isPlaying,
currentTime, currentTime,
duration, duration,
progress, progress,
bufferProgress, bufferProgress,
seekTo, seekTo,
loading as isLoadingAudio, loading: isLoadingAudio,
errored errored
} from '~/composables/audio/player' } = usePlayer()
import { const {
hasNext, hasNext,
currentTrack, currentTrack,
currentIndex, currentIndex,
@ -36,8 +39,8 @@ import {
dequeue, dequeue,
playTrack, playTrack,
reorder, reorder,
endsIn as timeLeft endsIn: timeLeft
} from '~/composables/audio/queue' } = useQueue()
const queueModal = ref() const queueModal = ref()
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true }) const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
@ -48,6 +51,7 @@ const store = useStore()
const labels = computed(() => ({ const labels = computed(() => ({
queue: $pgettext('*/*/*', 'Queue'), queue: $pgettext('*/*/*', 'Queue'),
populating: $pgettext('*/*/*', 'Fetching radio track'),
duration: $pgettext('*/*/*', 'Duration'), duration: $pgettext('*/*/*', 'Duration'),
addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'), addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…'),
restart: $pgettext('*/*/*', 'Restart track'), restart: $pgettext('*/*/*', 'Restart track'),
@ -332,32 +336,41 @@ const reorderTracks = async (from: number, to: number) => {
@remove="dequeue" @remove="dequeue"
/> />
</template> </template>
</virtual-list> <template #footer>
<div <div
v-if="$store.state.radios.running" v-if="$store.state.radios.populating"
class="ui info message" class="radio-populating"
>
<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"> <i class="loading spinner icon" />
Stop radio {{ labels.populating }}
</translate> </div>
</button> <div
</div> v-if="$store.state.radios.running"
</div> class="ui info message radio-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>
</template>
</virtual-list>
</div> </div>
</div> </div>
</section> </section>

View File

@ -1,29 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { usePlayer } from '~/composables/audio/player'
LoopingMode, import { useQueue } from '~/composables/audio/queue'
initializeFirstTrack,
isPlaying,
mute,
volume,
toggleLooping,
looping,
seekBy,
seekTo,
currentTime,
duration,
progress,
bufferProgress,
loading as isLoadingAudio
} from '~/composables/audio/player'
import {
playPrevious,
playNext,
queue,
currentIndex,
currentTrack,
shuffle
} from '~/composables/audio/queue'
import { useMouse, useWindowSize } from '@vueuse/core' import { useMouse, useWindowSize } from '@vueuse/core'
import { useGettext } from 'vue3-gettext' import { useGettext } from 'vue3-gettext'
@ -38,6 +15,32 @@ import time from '~/utils/time'
import VolumeControl from './VolumeControl.vue' import VolumeControl from './VolumeControl.vue'
import PlayerControls from './PlayerControls.vue' import PlayerControls from './PlayerControls.vue'
const {
LoopingMode,
initializeFirstTrack,
isPlaying,
mute,
volume,
toggleLooping,
looping,
seekBy,
seekTo,
currentTime,
duration,
progress,
bufferProgress,
loading: isLoadingAudio
} = usePlayer()
const {
playPrevious,
playNext,
queue,
currentIndex,
currentTrack,
shuffle
} = useQueue()
const store = useStore() const store = useStore()
const { $pgettext } = useGettext() const { $pgettext } = useGettext()

View File

@ -2,14 +2,11 @@
import { useGettext } from 'vue3-gettext' import { useGettext } from 'vue3-gettext'
import { computed } from 'vue' import { computed } from 'vue'
import { isPlaying } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import { import { useQueue } from '~/composables/audio/queue'
hasPrevious,
playPrevious, const { hasPrevious, playPrevious, hasNext, playNext, currentTrack } = useQueue()
hasNext, const { isPlaying } = usePlayer()
playNext,
currentTrack
} from '~/composables/audio/queue'
const { $pgettext } = useGettext() const { $pgettext } = useGettext()
const labels = computed(() => ({ const labels = computed(() => ({

View File

@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { volume, mute } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import { useTimeoutFn } from '@vueuse/core' import { useTimeoutFn } from '@vueuse/core'
import { useGettext } from 'vue3-gettext' import { useGettext } from 'vue3-gettext'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
const { volume, mute } = usePlayer()
const expanded = ref(false) const expanded = ref(false)
const { $pgettext } = useGettext() const { $pgettext } = useGettext()

View File

@ -3,7 +3,6 @@ import { useMouse, useCurrentElement, useRafFn, useElementByPoint } from '@vueus
import { ref, watchEffect, reactive } from 'vue' import { ref, watchEffect, reactive } from 'vue'
// @ts-expect-error no typings // @ts-expect-error no typings
// import VirtualList from 'vue3-virtual-scroll-list'
import { RecycleScroller } from 'vue-virtual-scroller' import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
@ -164,7 +163,6 @@ defineExpose({
<div> <div>
<recycle-scroller <recycle-scroller
ref="virtualList" ref="virtualList"
v-slot="{ item, index }"
class="virtual-list drag-container" class="virtual-list drag-container"
:items="list" :items="list"
:item-size="size" :item-size="size"
@ -175,11 +173,21 @@ defineExpose({
@visible="emit('visible')" @visible="emit('visible')"
@hidden="emit('hidden')" @hidden="emit('hidden')"
> >
<slot <template #before>
:class-list="[draggedItem && hoveredIndex === index && `drop-${position}`, 'drag-item']" <slot name="header" />
:item="item" </template>
:index="index"
/> <template #default="{ item, index }">
<slot
:class-list="[draggedItem && hoveredIndex === index && `drop-${position}`, 'drag-item']"
:item="item"
:index="index"
/>
</template>
<template #after>
<slot name="footer" />
</template>
</recycle-scroller> </recycle-scroller>
<div <div

View File

@ -1,51 +1,13 @@
import { tryOnMounted, useIntervalFn, useRafFn, useStorage, useTimeoutFn, whenever } from '@vueuse/core' import { createGlobalState, tryOnMounted, useIntervalFn, useRafFn, useStorage, useTimeoutFn, whenever } from '@vueuse/core'
import { currentTrack, currentIndex, playNext } from '~/composables/audio/queue' import { useTracks } 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'
import store from '~/store' import { useQueue, currentIndex, currentTrack } from './queue'
import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
export const isPlaying = ref(false)
watchEffect(() => {
const sound = currentSound.value
if (!sound) return
if (isPlaying.value) {
sound.play()
return
}
sound.pause()
})
// Create first track when we initalize the page
export const initializeFirstTrack = () => tryOnMounted(() => {
createTrack(currentIndex.value)
})
// Volume
const lastVolume = useStorage('player:last-volume', 0.7)
export const volume: Ref<number> = useStorage('player:volume', 0.7)
watch(volume, (to, from) => setGain(to))
export const mute = () => {
if (volume.value > 0) {
lastVolume.value = volume.value
volume.value = 0
return
}
if (lastVolume.value === 0) {
volume.value = 0.7
return
}
volume.value = lastVolume.value
}
// Looping // Looping
export enum LoopingMode { export enum LoopingMode {
None, None,
@ -61,120 +23,190 @@ export const toggleLooping = () => {
looping.value %= MODE_MAX looping.value %= MODE_MAX
} }
watchEffect(() => { // Is playing
const sound = currentSound.value export const isPlaying = ref(false)
if (!sound) return
sound.looping = looping.value === LoopingMode.LoopTrack
})
watch(currentSound, sound => { // Use Player
sound?.onSoundLoop(() => { export const usePlayer = createGlobalState(() => {
currentTime.value = 0 const { currentSound, createTrack } = useTracks()
const { playNext } = useQueue()
const store = useStore()
watchEffect(() => {
const sound = currentSound.value
if (!sound) return
if (isPlaying.value) {
sound.play()
return
}
sound.pause()
}) })
})
// Duration // Create first track when we initalize the page
export const duration = ref(0) // NOTE: We want to have it called only once, hence we're using createGlobalState
watchEffect(() => { const initializeFirstTrack = createGlobalState(() => tryOnMounted(() => {
const sound = currentSound.value const { initialize } = useTracks()
if (sound?.isLoaded.value === true) { initialize()
duration.value = sound.duration ?? 0
createTrack(currentIndex.value)
}))
// Volume
const lastVolume = useStorage('player:last-volume', 0.7)
const volume: Ref<number> = useStorage('player:volume', 0.7)
watch(volume, (to, from) => setGain(to))
const mute = () => {
if (volume.value > 0) {
lastVolume.value = volume.value
volume.value = 0
return
}
if (lastVolume.value === 0) {
volume.value = 0.7
return
}
volume.value = lastVolume.value
}
watchEffect(() => {
const sound = currentSound.value
if (!sound) return
sound.looping = looping.value === LoopingMode.LoopTrack
})
watch(currentSound, sound => {
sound?.onSoundLoop(() => {
currentTime.value = 0
})
})
// Duration
const duration = ref(0)
watchEffect(() => {
const sound = currentSound.value
if (sound?.isLoaded.value === true) {
duration.value = sound.duration ?? 0
currentTime.value = sound.currentTime
return
}
duration.value = 0
})
// Current time
const currentTime = ref(0)
useIntervalFn(() => {
const sound = currentSound.value
if (!sound) {
currentTime.value = 0
return
}
currentTime.value = sound.currentTime
}, 1000)
// Submit listens
const listenSubmitted = ref(false)
whenever(listenSubmitted, async () => {
console.log('Listening submitted!')
if (!store.state.auth.authenticated) return
if (!currentTrack.value) return
await axios.post('history/listenings/', { track: currentTrack.value.id })
.catch((error) => console.error('Could not record track in history', error))
})
watch(currentTime, (time) => {
const sound = currentSound.value
if (!sound) {
listenSubmitted.value = false
return
}
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html?highlight=half#post--1-submit-listens
listenSubmitted.value = time > Math.min(sound.duration / 2, 4 * 60)
})
// Seeking
const seekBy = async (seconds: number) => {
const sound = currentSound.value
if (!sound) return
await sound.seekBy(seconds)
currentTime.value = sound.currentTime currentTime.value = sound.currentTime
return
} }
duration.value = 0 const seekTo = async (seconds: number) => {
}) const sound = currentSound.value
if (!sound) return
// Current time await sound.seekTo(seconds)
export const currentTime = ref(0) currentTime.value = sound.currentTime
useIntervalFn(() => {
const sound = currentSound.value
if (!sound) {
currentTime.value = 0
return
} }
currentTime.value = sound.currentTime // Buffer progress
}, 1000) const bufferProgress = ref(0)
useIntervalFn(() => {
const sound = currentSound.value
if (!sound) {
bufferProgress.value = 0
return
}
// Submit listens bufferProgress.value = sound.buffered / sound.duration * 100
const listenSubmitted = ref(false) }, 1000)
whenever(listenSubmitted, async () => {
console.log('Listening submitted!')
if (!store.state.auth.authenticated) return
if (!currentTrack.value) return
await axios.post('history/listenings/', { track: currentTrack.value.id }) // Progress
.catch((error) => console.error('Could not record track in history', error)) const progress = ref(0)
}) useRafFn(() => {
const sound = currentSound.value
if (!sound) {
progress.value = 0
return
}
watch(currentTime, (time) => { progress.value = sound.currentTime / sound.duration * 100
const sound = currentSound.value })
if (!sound) {
listenSubmitted.value = false // Loading
return const loading = computed(() => {
const sound = currentSound.value
if (!sound) return false
return !sound.isLoaded.value
})
// Errored
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)
return {
initializeFirstTrack,
isPlaying,
volume,
mute,
looping,
LoopingMode,
toggleLooping,
duration,
currentTime,
seekBy,
seekTo,
bufferProgress,
progress,
loading,
errored
} }
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html?highlight=half#post--1-submit-listens
listenSubmitted.value = time > Math.min(sound.duration / 2, 4 * 60)
}) })
// Seeking
export const seekBy = async (seconds: number) => {
const sound = currentSound.value
if (!sound) return
await sound.seekBy(seconds)
currentTime.value = sound.currentTime
}
export const seekTo = async (seconds: number) => {
const sound = currentSound.value
if (!sound) return
await sound.seekTo(seconds)
currentTime.value = sound.currentTime
}
// Buffer progress
export const bufferProgress = ref(0)
useIntervalFn(() => {
const sound = currentSound.value
if (!sound) {
bufferProgress.value = 0
return
}
bufferProgress.value = sound.buffered / sound.duration * 100
}, 1000)
// Progress
export const progress = ref(0)
useRafFn(() => {
const sound = currentSound.value
if (!sound) {
progress.value = 0
return
}
progress.value = sound.currentTime / sound.duration * 100
})
// Loading
export const loading = computed(() => {
const sound = currentSound.value
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,12 +1,16 @@
import type { Track, Upload } from '~/types' import type { Track, Upload } from '~/types'
import { computedAsync, useNow, useStorage, useTimeAgo } from '@vueuse/core' import { computedAsync, createGlobalState, useNow, useStorage, useTimeAgo } from '@vueuse/core'
import { shuffle as shuffleArray, sum, 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 { computed, watchEffect } from 'vue'
import { useClamp } from '@vueuse/math' import { useClamp } from '@vueuse/math'
import { computed } from 'vue'
import { looping, LoopingMode, isPlaying } from '~/composables/audio/player'
import { useStore } from '~/store'
import axios from 'axios' import axios from 'axios'
import { useTracks } from './tracks'
// import useWebWorker from '~/composables/useWebWorker' // import useWebWorker from '~/composables/useWebWorker'
@ -35,7 +39,11 @@ export interface QueueTrack {
} }
// Queue // Queue
export const tracks = useStorage('queue:tracks', [] as number[]) const tracks = useStorage('queue:tracks', [] as number[])
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
const isShuffled = computed(() => shuffledIds.value.length !== 0)
const tracksById = computedAsync(async () => { const tracksById = computedAsync(async () => {
const trackObjects = await getMany(uniq(tracks.value)) const trackObjects = await getMany(uniq(tracks.value))
return trackObjects.reduce((acc, track) => { return trackObjects.reduce((acc, track) => {
@ -44,7 +52,7 @@ const tracksById = computedAsync(async () => {
}, {}) as Record<number, QueueTrack> }, {}) as Record<number, QueueTrack>
}, {}) }, {})
export const queue = computed(() => { const queue = computed(() => {
const indexedTracks = tracksById.value const indexedTracks = tracksById.value
if (isShuffled.value) { if (isShuffled.value) {
@ -54,179 +62,204 @@ export const queue = computed(() => {
return tracks.value.map(id => indexedTracks[id]).filter(i => i) return tracks.value.map(id => indexedTracks[id]).filter(i => i)
}) })
const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
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,
// TODO (wvffle): i18n
artistName: track.artist?.name ?? 'Unknown artist',
// TODO (wvffle): i18n
albumTitle: track.album?.title ?? 'Unknown album',
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: upload.listen_url
}))
}
}
// Adding tracks
export 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))
}
}
export const enqueue = async (...newTracks: Track[]) => {
return enqueueAt(tracks.value.length, ...newTracks)
}
// 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])
// Play track // Use Queue
export const playTrack = async (trackIndex: number, force = false) => { export const useQueue = createGlobalState(() => {
const { currentSound } = await import('~/composables/audio/tracks') const { currentSound } = useTracks()
const { isPlaying } = await import('~/composables/audio/player') const store = useStore()
if (isPlaying.value) currentSound.value?.pause() const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
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[] }))
if (force && currentIndex.value === trackIndex) { track.uploads = uploads
currentSound.value?.seekTo(0) }
if (isPlaying.value) currentSound.value?.play()
return return {
id: track.id,
title: track.title,
// TODO (wvffle): i18n
artistName: track.artist?.name ?? 'Unknown artist',
// TODO (wvffle): i18n
albumTitle: track.album?.title ?? 'Unknown album',
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: upload.listen_url
}))
}
} }
currentIndex.value = trackIndex // 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]))
// Previous track const ids = queueTracks.map(track => track.id)
export const hasPrevious = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== 0)
export const playPrevious = async (force = false) => {
const { looping, LoopingMode } = await import('~/composables/audio/player')
// Loop entire queue / change track to the next one if (index >= tracks.value.length) {
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === 0 && force !== true) { // we simply push to the end
// Loop track programmatically if it is the only track in the queue tracks.value.push(...ids)
if (tracks.value.length === 1) return playTrack(currentIndex.value, true) } else {
return playTrack(tracks.value.length - 1) // we insert the track at given position
tracks.value.splice(index, 0, ...ids)
}
// Shuffle new tracks
if (isShuffled.value) {
shuffledIds.value.push(...shuffleArray(ids))
}
} }
return playTrack(currentIndex.value - 1) const enqueue = async (...newTracks: Track[]) => {
} return enqueueAt(tracks.value.length, ...newTracks)
// Next track
export const hasNext = computed(() => /* looping.value === LoopingMode.LoopQueue || */ currentIndex.value !== tracks.value.length - 1)
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 && 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)
} }
return playTrack(currentIndex.value + 1) // Removing tracks
} const dequeue = async (index: number) => {
if (currentIndex.value === index) {
await playNext(true)
}
// Reorder tracks.value.splice(index, 1)
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 (index <= currentIndex.value) {
if (current === from) { currentIndex.value -= 1
currentIndex.value = to }
return
// TODO (wvffle): Check if removing last element works well
} }
if (from < current && to >= current) { // Play track
// item before was moved after const playTrack = async (trackIndex: number, force = false) => {
currentIndex.value -= 1 if (isPlaying.value) currentSound.value?.pause()
if (force && currentIndex.value === trackIndex) {
currentSound.value?.seekTo(0)
if (isPlaying.value) currentSound.value?.play()
return
}
currentIndex.value = trackIndex
} }
if (from > current && to <= current) { // Previous track
// item after was moved before const hasPrevious = computed(() => looping.value === LoopingMode.LoopQueue || currentIndex.value !== 0)
currentIndex.value += 1 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)
}
// Shuffle return playTrack(currentIndex.value - 1)
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[])
export const isShuffled = computed(() => shuffledIds.value.length !== 0)
export const shuffle = () => {
if (isShuffled.value) {
shuffledIds.value.length = 0
return
} }
shuffledIds.value = shuffleArray(tracks.value) // Next track
} const hasNext = computed(() => looping.value === LoopingMode.LoopQueue || currentIndex.value !== tracks.value.length - 1)
const playNext = async (force = false) => {
// Loop entire queue / change track to the next one
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)
}
export const reshuffleUpcomingTracks = () => { return playTrack(currentIndex.value + 1)
// 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)) // Reorder
shuffledIds.value = listenedTracks const reorder = (from: number, to: number) => {
} const [id] = tracks.value.splice(from, 1)
tracks.value.splice(to, 0, id)
// Ends in const current = currentIndex.value
const now = useNow() if (current === from) {
export const endsIn = useTimeAgo(computed(() => { currentIndex.value = to
const seconds = sum( return
queue.value }
.slice(currentIndex.value)
.map((track) => track.sources[0]?.duration ?? 0)
)
const date = new Date(now.value) if (from < current && to >= current) {
date.setSeconds(date.getSeconds() + seconds) // item before was moved after
return date currentIndex.value -= 1
})) }
if (from > current && to <= current) {
// item after was moved before
currentIndex.value += 1
}
}
// Shuffle
const shuffle = () => {
if (isShuffled.value) {
shuffledIds.value.length = 0
return
}
shuffledIds.value = shuffleArray(tracks.value)
}
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
}))
// Radio queue populating
watchEffect(() => {
if (store.state.radios.running && currentIndex.value === tracks.value.length - 1) {
console.log('POPULATING QUEUE FROM RADIO')
return store.dispatch('radios/populateQueue')
}
})
return {
tracks,
queue,
enqueueAt,
enqueue,
dequeue,
currentIndex,
currentTrack,
playTrack,
hasPrevious,
hasNext,
playPrevious,
playNext,
isShuffled,
shuffle,
reshuffleUpcomingTracks,
reorder,
endsIn
}
})

View File

@ -2,11 +2,12 @@ import type { QueueTrack, QueueTrackSource } from '~/composables/audio/queue'
import type { Sound } from '~/api/player' import type { Sound } from '~/api/player'
import { soundImplementation } from '~/api/player' import { soundImplementation } from '~/api/player'
import { computed, watchEffect } from 'vue' import { createGlobalState, syncRef } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
import { playNext, queue, currentTrack, currentIndex } from '~/composables/audio/queue' import { useQueue } from '~/composables/audio/queue'
import { connectAudioSource } from '~/composables/audio/audio-api' import { connectAudioSource } from '~/composables/audio/audio-api'
import { isPlaying } from '~/composables/audio/player' import { usePlayer } from '~/composables/audio/player'
import useLRUCache from '~/composables/useLRUCache' import useLRUCache from '~/composables/useLRUCache'
import store from '~/store' import store from '~/store'
@ -41,67 +42,87 @@ const getTrackSources = (track: QueueTrack): QueueTrackSource[] => {
return sources return sources
} }
export const createSound = async (track: QueueTrack): Promise<Sound> => { // Use Tracks
if (soundCache.has(track.id)) { export const useTracks = createGlobalState(() => {
return soundCache.get(track.id) as Sound const createSound = async (track: QueueTrack): Promise<Sound> => {
if (soundCache.has(track.id)) {
return soundCache.get(track.id) as Sound
}
if (soundPromises.has(track.id)) {
return soundPromises.get(track.id) as Promise<Sound>
}
const createSoundPromise = async () => {
const sources = getTrackSources(track)
const { playNext, currentIndex } = useQueue()
const SoundImplementation = soundImplementation.value
const sound = new SoundImplementation(sources)
sound.onSoundEnd(() => {
console.log('TRACK ENDED, PLAYING NEXT')
createTrack(currentIndex.value + 1)
// NOTE: We push it to the end of the job queue
setTimeout(playNext, 0)
})
soundCache.set(track.id, sound)
soundPromises.delete(track.id)
return sound
}
const soundPromise = createSoundPromise()
soundPromises.set(track.id, soundPromise)
return soundPromise
} }
if (soundPromises.has(track.id)) { // Create track from queue
return soundPromises.get(track.id) as Promise<Sound> const createTrack = async (index: number) => {
const { queue, currentIndex } = useQueue()
if (queue.value.length <= index || index === -1) return
console.log('LOADING TRACK', index)
const track = queue.value[index]
if (!soundPromises.has(track.id) && !soundCache.has(track.id)) {
// TODO (wvffle): Resolve race condition - is it still here after adding soundPromises?
console.log('NO TRACK IN CACHE, CREATING')
}
const sound = await createSound(track)
console.log('CONNECTING NODE')
sound.audioNode.disconnect()
connectAudioSource(sound.audioNode)
const { isPlaying } = usePlayer()
if (isPlaying.value && index === currentIndex.value) {
await sound.play()
}
// NOTE: Preload next track
if (index === currentIndex.value && index + 1 < queue.value.length) {
setTimeout(async () => {
const sound = await createSound(queue.value[index + 1])
await sound.preload()
}, 100)
}
} }
const createSoundPromise = async () => { const currentTrack = ref<QueueTrack>()
const sources = getTrackSources(track)
const SoundImplementation = soundImplementation.value // NOTE: We want to have it called only once, hence we're using createGlobalState
const sound = new SoundImplementation(sources) const initialize = createGlobalState(() => {
sound.onSoundEnd(() => { const { currentTrack: track, currentIndex } = useQueue()
console.log('TRACK ENDED, PLAYING NEXT') watchEffect(async () => createTrack(currentIndex.value))
createTrack(currentIndex.value + 1) syncRef(currentTrack, track)
})
// NOTE: We push it to the end of the job queue const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1))
setTimeout(playNext, 0)
}) return {
soundCache.set(track.id, sound) initialize,
soundPromises.delete(track.id) createSound,
return sound createTrack,
currentSound
} }
})
const soundPromise = createSoundPromise()
soundPromises.set(track.id, soundPromise)
return soundPromise
}
// Create track from queue
export const createTrack = async (index: number) => {
if (queue.value.length <= index || index === -1) return
console.log('LOADING TRACK', index)
const track = queue.value[index]
if (!soundPromises.has(track.id) && !soundCache.has(track.id)) {
// TODO (wvffle): Resolve race condition - is it still here after adding soundPromises?
console.log('NO TRACK IN CACHE, CREATING')
}
const sound = await createSound(track)
console.log('CONNECTING NODE')
sound.audioNode.disconnect()
connectAudioSource(sound.audioNode)
if (isPlaying.value && index === currentIndex.value) {
await sound.play()
}
// NOTE: Preload next track
if (index === currentIndex.value && index + 1 < queue.value.length) {
setTimeout(async () => {
const sound = await createSound(queue.value[index + 1])
await sound.preload()
}, 100)
}
}
watchEffect(async () => createTrack(currentIndex.value))
export const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1))

View File

@ -6,12 +6,12 @@ import { computed, markRaw, ref } from 'vue'
import { useGettext } from 'vue3-gettext' import { useGettext } from 'vue3-gettext'
import { useStore } from '~/store' import { useStore } from '~/store'
import { usePlayer } from '~/composables/audio/player'
import { useQueue } from '~/composables/audio/queue'
import axios from 'axios' import axios from 'axios'
import jQuery from 'jquery' import jQuery from 'jquery'
import { enqueue as addToQueue, currentTrack, playNext, currentIndex, enqueueAt, queue } from '~/composables/audio/queue'
import { isPlaying } from '~/composables/audio/player'
export interface PlayOptionsProps { export interface PlayOptionsProps {
isPlayable?: boolean isPlayable?: boolean
tracks?: Track[] tracks?: Track[]
@ -25,6 +25,8 @@ export interface PlayOptionsProps {
} }
export default (props: PlayOptionsProps) => { export default (props: PlayOptionsProps) => {
const { enqueue: addToQueue, currentTrack, playNext, currentIndex, enqueueAt, queue, tracks, playTrack } = useQueue()
const { isPlaying } = usePlayer()
const store = useStore() const store = useStore()
const playable = computed(() => { const playable = computed(() => {
@ -155,7 +157,6 @@ export default (props: PlayOptionsProps) => {
} }
const replacePlay = async () => { const replacePlay = async () => {
const { tracks, playTrack } = await import('~/composables/audio/queue')
tracks.value.length = 0 tracks.value.length = 0
jQuery(el.value).find('.ui.dropdown').dropdown('hide') jQuery(el.value).find('.ui.dropdown').dropdown('hide')

View File

@ -62,12 +62,12 @@ export const install: InitModule = ({ store }) => {
}) })
}) })
useWebSocketHandler('Listen', (event) => { useWebSocketHandler('Listen', async (event) => {
if (store.state.radios.current && store.state.radios.running) { if (store.state.radios.current && store.state.radios.running) {
const { current } = store.state.radios const { current } = store.state.radios
if (current.clientOnly) { if (current.clientOnly) {
CLIENT_RADIOS[current.type].handleListen(current, event, store) await CLIENT_RADIOS[current.type].handleListen(current, event)
} }
} }
}) })

View File

@ -1,13 +1,19 @@
import type { Dispatch, Module } from 'vuex'
import type { RootState } from '~/store/index' import type { RootState } from '~/store/index'
import type { Module } from 'vuex'
import type { Track } from '~/types'
import { useQueue } from '~/composables/audio/queue'
import { usePlayer } from '~/composables/audio/player'
import { CLIENT_RADIOS } from '~/utils/clientRadios'
import axios from 'axios' import axios from 'axios'
import { CLIENT_RADIOS } from '~/utils/clientRadios'
import useLogger from '~/composables/useLogger' import useLogger from '~/composables/useLogger'
export interface State { export interface State {
current: null | CurrentRadio current: null | CurrentRadio
running: boolean running: boolean
populating: boolean
} }
export interface ObjectId { export interface ObjectId {
@ -26,19 +32,14 @@ export interface CurrentRadio {
export type RadioConfig = { type: 'tag', names: string[] } | { type: 'artist', ids: string[] } export type RadioConfig = { type: 'tag', names: string[] } | { type: 'artist', ids: string[] }
export interface PopulateQueuePayload {
current: CurrentRadio
playNow: boolean
dispatch: Dispatch
}
const logger = useLogger() const logger = useLogger()
const store: Module<State, RootState> = { const store: Module<State, RootState> = {
namespaced: true, namespaced: true,
state: { state: {
current: null, current: null,
running: false running: false,
populating: false
}, },
getters: { getters: {
types: () => { types: () => {
@ -70,6 +71,7 @@ const store: Module<State, RootState> = {
reset (state) { reset (state) {
state.running = false state.running = false
state.current = null state.current = null
state.populating = false
}, },
current: (state, value) => { current: (state, value) => {
state.current = value state.current = value
@ -107,37 +109,45 @@ const store: Module<State, RootState> = {
if (state.current?.clientOnly) { if (state.current?.clientOnly) {
CLIENT_RADIOS[state.current.type].stop() CLIENT_RADIOS[state.current.type].stop()
} }
commit('current', null) commit('current', null)
commit('running', false) commit('running', false)
}, },
async populateQueue ({ commit, rootState, state, dispatch }, playNow) { async populateQueue ({ commit, state }, playNow) {
if (!state.running) { if (!state.running || state.populating) {
return return
} }
if (rootState.player.errorCount >= rootState.player.maxConsecutiveErrors - 1) { state.populating = true
return
} const { enqueue, playTrack, tracks } = useQueue()
const { isPlaying } = usePlayer()
const params = { session: state.current?.session } const params = { session: state.current?.session }
if (state.current?.clientOnly) {
return CLIENT_RADIOS[state.current.type].populateQueue({ current: state.current, dispatch, playNow })
}
try { try {
const response = await axios.post('radios/tracks/', params)
logger.info('Adding track to queue from radio') logger.info('Adding track to queue from radio')
await dispatch('queue/append', { track: response.data.track }, { root: true })
const track = state.current?.clientOnly
? await CLIENT_RADIOS[state.current.type].fetchNextTrack(state.current)
: await axios.post('radios/tracks/', params).then(response => response.data.track as Track)
if (track === undefined) {
isPlaying.value = false
return
}
await enqueue(track)
if (playNow) { if (playNow) {
await dispatch('queue/last', null, { root: true }) await playTrack(tracks.value.length - 1)
await dispatch('player/resumePlayback', null, { root: true }) isPlaying.value = true
} }
} catch (error) { } catch (error) {
logger.error('Error while adding track to queue from radio', error) logger.error('Error while adding track to queue from radio', error)
commit('reset') commit('reset')
} finally {
state.populating = false
} }
} }
} }

View File

@ -235,6 +235,16 @@
} }
} }
.radio-populating {
margin-top: 1em;
color: #333;
text-align: center;
}
.radio-message {
margin-top: 1em !important;
margin-right: 1em;
}
} }

View File

@ -1,7 +1,8 @@
import type { CurrentRadio, PopulateQueuePayload } from '~/store/radios'
import type { ListenWS } from '~/composables/useWebSocketHandler' import type { ListenWS } from '~/composables/useWebSocketHandler'
import type { RootState } from '~/store' import type { CurrentRadio } from '~/store/radios'
import type { Store } from 'vuex' import type { Track } from '~/types'
import { useQueue } from '~/composables/audio/queue'
import axios from 'axios' import axios from 'axios'
@ -14,39 +15,45 @@ export const CLIENT_RADIOS = {
// method by hand // method by hand
account: { account: {
offset: 1, offset: 1,
populateQueue ({ current, dispatch, playNow }: PopulateQueuePayload) { async fetchNextTrack (current: CurrentRadio) {
const params = { scope: `actor:${current.objectId?.fullUsername}`, ordering: '-creation_date', page_size: 1, page: this.offset } const params = {
axios.get('history/listenings', { params }).then(async (response) => { scope: `actor:${current.objectId?.fullUsername}`,
const latest = response.data.results[0] ordering: '-creation_date',
if (!latest) { page_size: 1,
logger.error('No more tracks') page: this.offset
await dispatch('stop') }
}
this.offset += 1 // NOTE: This is unhandled as we want to pass the exception further down
const append = dispatch('queue/append', { track: latest.track }, { root: true }) const response = await axios.get('history/listenings', { params })
if (playNow) {
append.then(() => dispatch('queue/last', null, { root: true })) const latest = response.data.results[0]
} if (!latest) {
}, async (error) => { logger.error('No more tracks')
logger.error('Error while fetching listenings', error) return undefined
await dispatch('stop') }
})
this.offset += 1
return latest.track as Track
}, },
stop () { stop () {
this.offset = 1 this.offset = 1
}, },
handleListen (current: CurrentRadio, event: ListenWS, store: Store<RootState>) {
async handleListen (current: CurrentRadio, event: ListenWS) {
// TODO: handle actors from other pods // TODO: handle actors from other pods
if (event.actor.local_id === current.objectId?.username) { if (event.actor.local_id === current.objectId?.username) {
axios.get(`tracks/${event.object.local_id}`).then(async (response) => { try {
const response = await axios.get(`tracks/${event.object.local_id}`)
if (response.data.uploads.length > 0) { if (response.data.uploads.length > 0) {
await store.dispatch('queue/append', { track: response.data }) const { enqueue } = useQueue()
await enqueue(response.data as Track)
this.offset += 1 this.offset += 1
} }
}, (error) => { } catch (error) {
logger.error('Cannot retrieve track info', error) logger.error('Cannot retrieve track info', error)
}) }
} }
} }
} }