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,10 +336,17 @@ const reorderTracks = async (from: number, to: number) => {
@remove="dequeue" @remove="dequeue"
/> />
</template> </template>
</virtual-list> <template #footer>
<div
v-if="$store.state.radios.populating"
class="radio-populating"
>
<i class="loading spinner icon" />
{{ labels.populating }}
</div>
<div <div
v-if="$store.state.radios.running" v-if="$store.state.radios.running"
class="ui info message" class="ui info message radio-message"
> >
<div class="content"> <div class="content">
<h3 class="header"> <h3 class="header">
@ -358,6 +369,8 @@ const reorderTracks = async (from: number, to: number) => {
</button> </button>
</div> </div>
</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')"
> >
<template #before>
<slot name="header" />
</template>
<template #default="{ item, index }">
<slot <slot
:class-list="[draggedItem && hoveredIndex === index && `drop-${position}`, 'drag-item']" :class-list="[draggedItem && hoveredIndex === index && `drop-${position}`, 'drag-item']"
:item="item" :item="item"
:index="index" :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,21 +23,72 @@ export const toggleLooping = () => {
looping.value %= MODE_MAX looping.value %= MODE_MAX
} }
watchEffect(() => { // Is playing
export const isPlaying = ref(false)
// Use Player
export const usePlayer = createGlobalState(() => {
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()
})
// Create first track when we initalize the page
// NOTE: We want to have it called only once, hence we're using createGlobalState
const initializeFirstTrack = createGlobalState(() => tryOnMounted(() => {
const { initialize } = useTracks()
initialize()
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 const sound = currentSound.value
if (!sound) return if (!sound) return
sound.looping = looping.value === LoopingMode.LoopTrack sound.looping = looping.value === LoopingMode.LoopTrack
}) })
watch(currentSound, sound => { watch(currentSound, sound => {
sound?.onSoundLoop(() => { sound?.onSoundLoop(() => {
currentTime.value = 0 currentTime.value = 0
}) })
}) })
// Duration // Duration
export const duration = ref(0) const duration = ref(0)
watchEffect(() => { watchEffect(() => {
const sound = currentSound.value const sound = currentSound.value
if (sound?.isLoaded.value === true) { if (sound?.isLoaded.value === true) {
duration.value = sound.duration ?? 0 duration.value = sound.duration ?? 0
@ -84,11 +97,11 @@ watchEffect(() => {
} }
duration.value = 0 duration.value = 0
}) })
// Current time // Current time
export const currentTime = ref(0) const currentTime = ref(0)
useIntervalFn(() => { useIntervalFn(() => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) { if (!sound) {
currentTime.value = 0 currentTime.value = 0
@ -96,20 +109,20 @@ useIntervalFn(() => {
} }
currentTime.value = sound.currentTime currentTime.value = sound.currentTime
}, 1000) }, 1000)
// Submit listens // Submit listens
const listenSubmitted = ref(false) const listenSubmitted = ref(false)
whenever(listenSubmitted, async () => { whenever(listenSubmitted, async () => {
console.log('Listening submitted!') console.log('Listening submitted!')
if (!store.state.auth.authenticated) return if (!store.state.auth.authenticated) return
if (!currentTrack.value) return if (!currentTrack.value) return
await axios.post('history/listenings/', { track: currentTrack.value.id }) await axios.post('history/listenings/', { track: currentTrack.value.id })
.catch((error) => console.error('Could not record track in history', error)) .catch((error) => console.error('Could not record track in history', error))
}) })
watch(currentTime, (time) => { watch(currentTime, (time) => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) { if (!sound) {
listenSubmitted.value = false listenSubmitted.value = false
@ -118,28 +131,28 @@ watch(currentTime, (time) => {
// https://listenbrainz.readthedocs.io/en/latest/users/api/core.html?highlight=half#post--1-submit-listens // 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) listenSubmitted.value = time > Math.min(sound.duration / 2, 4 * 60)
}) })
// Seeking // Seeking
export const seekBy = async (seconds: number) => { const seekBy = async (seconds: number) => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) return if (!sound) return
await sound.seekBy(seconds) await sound.seekBy(seconds)
currentTime.value = sound.currentTime currentTime.value = sound.currentTime
} }
export const seekTo = async (seconds: number) => { const seekTo = async (seconds: number) => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) return if (!sound) return
await sound.seekTo(seconds) await sound.seekTo(seconds)
currentTime.value = sound.currentTime currentTime.value = sound.currentTime
} }
// Buffer progress // Buffer progress
export const bufferProgress = ref(0) const bufferProgress = ref(0)
useIntervalFn(() => { useIntervalFn(() => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) { if (!sound) {
bufferProgress.value = 0 bufferProgress.value = 0
@ -147,11 +160,11 @@ useIntervalFn(() => {
} }
bufferProgress.value = sound.buffered / sound.duration * 100 bufferProgress.value = sound.buffered / sound.duration * 100
}, 1000) }, 1000)
// Progress // Progress
export const progress = ref(0) const progress = ref(0)
useRafFn(() => { useRafFn(() => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) { if (!sound) {
progress.value = 0 progress.value = 0
@ -159,22 +172,41 @@ useRafFn(() => {
} }
progress.value = sound.currentTime / sound.duration * 100 progress.value = sound.currentTime / sound.duration * 100
}) })
// Loading // Loading
export const loading = computed(() => { const loading = computed(() => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) return false if (!sound) return false
return !sound.isLoaded.value return !sound.isLoaded.value
}) })
// Errored // Errored
export const errored = computed(() => { const errored = computed(() => {
const sound = currentSound.value const sound = currentSound.value
if (!sound) return false if (!sound) return false
return sound.isErrored.value return sound.isErrored.value
}) })
const { start, stop } = useTimeoutFn(() => playNext(), 3000, { immediate: false }) const { start, stop } = useTimeoutFn(() => playNext(), 3000, { immediate: false })
watch(currentIndex, stop) watch(currentIndex, stop)
whenever(errored, start) whenever(errored, start)
return {
initializeFirstTrack,
isPlaying,
volume,
mute,
looping,
LoopingMode,
toggleLooping,
duration,
currentTime,
seekBy,
seekTo,
bufferProgress,
progress,
loading,
errored
}
})

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,7 +62,16 @@ 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> => { // Current Index
export const currentIndex = useClamp(useStorage('queue:index', 0), 0, () => tracks.value.length)
export const currentTrack = computed(() => queue.value[currentIndex.value])
// Use Queue
export const useQueue = createGlobalState(() => {
const { currentSound } = useTracks()
const store = useStore()
const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
if (track.uploads.length === 0) { if (track.uploads.length === 0) {
// we don't have any information for this track, we need to fetch it // we don't have any information for this track, we need to fetch it
const { uploads } = await axios.get(`tracks/${track.id}/`) const { uploads } = await axios.get(`tracks/${track.id}/`)
@ -82,10 +99,10 @@ const createQueueTrack = async (track: Track): Promise<QueueTrack> => {
url: upload.listen_url url: upload.listen_url
})) }))
} }
} }
// Adding tracks // Adding tracks
export const enqueueAt = async (index: number, ...newTracks: Track[]) => { const enqueueAt = async (index: number, ...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]))
@ -103,14 +120,14 @@ export const enqueueAt = async (index: number, ...newTracks: Track[]) => {
if (isShuffled.value) { if (isShuffled.value) {
shuffledIds.value.push(...shuffleArray(ids)) shuffledIds.value.push(...shuffleArray(ids))
} }
} }
export const enqueue = async (...newTracks: Track[]) => { const enqueue = async (...newTracks: Track[]) => {
return enqueueAt(tracks.value.length, ...newTracks) return enqueueAt(tracks.value.length, ...newTracks)
} }
// Removing tracks // Removing tracks
export const dequeue = async (index: number) => { const dequeue = async (index: number) => {
if (currentIndex.value === index) { if (currentIndex.value === index) {
await playNext(true) await playNext(true)
} }
@ -122,17 +139,10 @@ export const dequeue = async (index: number) => {
} }
// TODO (wvffle): Check if removing last element works well // 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])
// Play track
export const playTrack = async (trackIndex: number, force = false) => {
const { currentSound } = await import('~/composables/audio/tracks')
const { isPlaying } = await import('~/composables/audio/player')
// Play track
const playTrack = async (trackIndex: number, force = false) => {
if (isPlaying.value) currentSound.value?.pause() if (isPlaying.value) currentSound.value?.pause()
if (force && currentIndex.value === trackIndex) { if (force && currentIndex.value === trackIndex) {
@ -142,13 +152,11 @@ export const playTrack = async (trackIndex: number, force = false) => {
} }
currentIndex.value = trackIndex currentIndex.value = trackIndex
} }
// Previous track
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')
// 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 // Loop entire queue / change track to the next one
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === 0 && force !== true) { 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
@ -157,13 +165,11 @@ export const playPrevious = async (force = false) => {
} }
return playTrack(currentIndex.value - 1) return playTrack(currentIndex.value - 1)
} }
// 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')
// 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 // Loop entire queue / change track to the next one
if (looping.value === LoopingMode.LoopQueue && currentIndex.value === tracks.value.length - 1 && force !== true) { 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
@ -172,10 +178,10 @@ export const playNext = async (force = false) => {
} }
return playTrack(currentIndex.value + 1) return playTrack(currentIndex.value + 1)
} }
// Reorder // Reorder
export const reorder = (from: number, to: number) => { const reorder = (from: number, to: number) => {
const [id] = tracks.value.splice(from, 1) const [id] = tracks.value.splice(from, 1)
tracks.value.splice(to, 0, id) tracks.value.splice(to, 0, id)
@ -194,32 +200,30 @@ export const reorder = (from: number, to: number) => {
// item after was moved before // item after was moved before
currentIndex.value += 1 currentIndex.value += 1
} }
} }
// Shuffle // Shuffle
const shuffledIds = useStorage('queue:tracks:shuffled', [] as number[]) const shuffle = () => {
export const isShuffled = computed(() => shuffledIds.value.length !== 0)
export const shuffle = () => {
if (isShuffled.value) { if (isShuffled.value) {
shuffledIds.value.length = 0 shuffledIds.value.length = 0
return return
} }
shuffledIds.value = shuffleArray(tracks.value) shuffledIds.value = shuffleArray(tracks.value)
} }
export const reshuffleUpcomingTracks = () => { const reshuffleUpcomingTracks = () => {
// TODO: Test if needed to add 1 to currentIndex // TODO: Test if needed to add 1 to currentIndex
const listenedTracks = shuffledIds.value.slice(0, currentIndex.value) const listenedTracks = shuffledIds.value.slice(0, currentIndex.value)
const upcomingTracks = shuffledIds.value.slice(currentIndex.value) const upcomingTracks = shuffledIds.value.slice(currentIndex.value)
listenedTracks.push(...shuffleArray(upcomingTracks)) listenedTracks.push(...shuffleArray(upcomingTracks))
shuffledIds.value = listenedTracks shuffledIds.value = listenedTracks
} }
// Ends in // Ends in
const now = useNow() const now = useNow()
export const endsIn = useTimeAgo(computed(() => { const endsIn = useTimeAgo(computed(() => {
const seconds = sum( const seconds = sum(
queue.value queue.value
.slice(currentIndex.value) .slice(currentIndex.value)
@ -229,4 +233,33 @@ export const endsIn = useTimeAgo(computed(() => {
const date = new Date(now.value) const date = new Date(now.value)
date.setSeconds(date.getSeconds() + seconds) date.setSeconds(date.getSeconds() + seconds)
return date 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,7 +42,9 @@ const getTrackSources = (track: QueueTrack): QueueTrackSource[] => {
return sources return sources
} }
export const createSound = async (track: QueueTrack): Promise<Sound> => { // Use Tracks
export const useTracks = createGlobalState(() => {
const createSound = async (track: QueueTrack): Promise<Sound> => {
if (soundCache.has(track.id)) { if (soundCache.has(track.id)) {
return soundCache.get(track.id) as Sound return soundCache.get(track.id) as Sound
} }
@ -52,6 +55,7 @@ export const createSound = async (track: QueueTrack): Promise<Sound> => {
const createSoundPromise = async () => { const createSoundPromise = async () => {
const sources = getTrackSources(track) const sources = getTrackSources(track)
const { playNext, currentIndex } = useQueue()
const SoundImplementation = soundImplementation.value const SoundImplementation = soundImplementation.value
const sound = new SoundImplementation(sources) const sound = new SoundImplementation(sources)
@ -70,10 +74,11 @@ export const createSound = async (track: QueueTrack): Promise<Sound> => {
const soundPromise = createSoundPromise() const soundPromise = createSoundPromise()
soundPromises.set(track.id, soundPromise) soundPromises.set(track.id, soundPromise)
return soundPromise return soundPromise
} }
// Create track from queue // Create track from queue
export const createTrack = async (index: number) => { const createTrack = async (index: number) => {
const { queue, currentIndex } = useQueue()
if (queue.value.length <= index || index === -1) return if (queue.value.length <= index || index === -1) return
console.log('LOADING TRACK', index) console.log('LOADING TRACK', index)
@ -89,6 +94,7 @@ export const createTrack = async (index: number) => {
sound.audioNode.disconnect() sound.audioNode.disconnect()
connectAudioSource(sound.audioNode) connectAudioSource(sound.audioNode)
const { isPlaying } = usePlayer()
if (isPlaying.value && index === currentIndex.value) { if (isPlaying.value && index === currentIndex.value) {
await sound.play() await sound.play()
} }
@ -100,8 +106,23 @@ export const createTrack = async (index: number) => {
await sound.preload() await sound.preload()
}, 100) }, 100)
} }
} }
watchEffect(async () => createTrack(currentIndex.value)) const currentTrack = ref<QueueTrack>()
export const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1)) // NOTE: We want to have it called only once, hence we're using createGlobalState
const initialize = createGlobalState(() => {
const { currentTrack: track, currentIndex } = useQueue()
watchEffect(async () => createTrack(currentIndex.value))
syncRef(currentTrack, track)
})
const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1))
return {
initialize,
createSound,
createTrack,
currentSound
}
})

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) { try {
return CLIENT_RADIOS[state.current.type].populateQueue({ current: state.current, dispatch, playNow }) logger.info('Adding track to queue from radio')
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
} }
try { await enqueue(track)
const response = await axios.post('radios/tracks/', params)
logger.info('Adding track to queue from radio')
await dispatch('queue/append', { track: response.data.track }, { root: true })
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}`,
ordering: '-creation_date',
page_size: 1,
page: this.offset
}
// NOTE: This is unhandled as we want to pass the exception further down
const response = await axios.get('history/listenings', { params })
const latest = response.data.results[0] const latest = response.data.results[0]
if (!latest) { if (!latest) {
logger.error('No more tracks') logger.error('No more tracks')
await dispatch('stop') return undefined
} }
this.offset += 1 this.offset += 1
const append = dispatch('queue/append', { track: latest.track }, { root: true }) return latest.track as Track
if (playNow) {
append.then(() => dispatch('queue/last', null, { root: true }))
}
}, async (error) => {
logger.error('Error while fetching listenings', error)
await dispatch('stop')
})
}, },
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)
}) }
} }
} }
} }