Add radio support
This commit is contained in:
parent
c7f53df4af
commit
c828e106b0
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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(() => ({
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
@ -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))
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -235,6 +235,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.radio-populating {
|
||||||
|
margin-top: 1em;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-message {
|
||||||
|
margin-top: 1em !important;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue