funkwhale/front/src/api/player.ts

202 wiersze
5.1 KiB
TypeScript

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 { effectScope, reactive, ref, type Ref } from 'vue'
import useLogger from '~/composables/useLogger'
const logger = useLogger()
export interface SoundSource {
uuid: string
mimetype: string
url: string
}
export interface Sound {
preload(): Promise<void>
dispose(): Promise<void>
readonly audioNode: IAudioNode<IAudioContext>
readonly isErrored: Ref<boolean>
readonly isLoaded: Ref<boolean>
readonly isDisposed: Ref<boolean>
readonly currentTime: number
readonly playable: boolean
readonly duration: number
readonly buffered: number
looping: boolean
pause(): Promise<void>
play(): Promise<void>
seekTo(seconds: number): Promise<void>
seekBy(seconds: number): Promise<void>
onSoundLoop: EventHookOn<Sound>
onSoundEnd: EventHookOn<Sound>
}
export const soundImplementations = reactive(new Set<Constructor<Sound>>())
export const registerSoundImplementation = <T extends Constructor<Sound>>(implementation: T) => {
soundImplementations.add(implementation)
return implementation
}
// Default Sound implementation
@registerSoundImplementation
export class HTMLSound implements Sound {
#audio = new Audio()
#soundLoopEventHook = createEventHook<HTMLSound>()
#soundEndEventHook = createEventHook<HTMLSound>()
#ignoreError = false
#scope = effectScope()
readonly isErrored = ref(false)
readonly isLoaded = ref(false)
readonly isDisposed = ref(false)
audioNode = createAudioSource(this.#audio)
onSoundLoop: EventHookOn<HTMLSound>
onSoundEnd: EventHookOn<HTMLSound>
constructor (sources: SoundSource[]) {
this.onSoundLoop = this.#soundLoopEventHook.on
this.onSoundEnd = this.#soundEndEventHook.on
// TODO: Quality picker
const source = sources[0]?.url
if (!source) {
this.isLoaded.value = true
return
}
this.#audio.crossOrigin = 'anonymous'
this.#audio.src = source
this.#audio.preload = 'auto'
logger.log('CREATED SOUND INSTANCE', this)
this.#scope.run(() => {
useEventListener(this.#audio, 'ended', () => this.#soundEndEventHook.trigger(this))
useEventListener(this.#audio, 'timeupdate', () => {
if (this.#audio.currentTime === 0) {
this.#soundLoopEventHook.trigger(this)
}
})
useEventListener(this.#audio, 'waiting', () => {
logger.log('>> AUDIO WAITING', this)
})
useEventListener(this.#audio, 'playing', () => {
logger.log('>> AUDIO PLAYING', this)
})
useEventListener(this.#audio, 'stalled', () => {
logger.log('>> AUDIO STALLED', this)
})
useEventListener(this.#audio, 'suspend', () => {
logger.log('>> AUDIO SUSPEND', this)
})
useEventListener(this.#audio, 'loadeddata', () => {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
this.isLoaded.value = this.#audio.readyState >= 2
})
useEventListener(this.#audio, 'error', (err) => {
if (this.#ignoreError) return
logger.error('>> AUDIO ERRORED', err, this)
this.isErrored.value = true
this.isLoaded.value = true
})
})
}
async preload () {
this.isDisposed.value = false
this.isErrored.value = false
logger.log('CALLING PRELOAD ON', this)
this.#audio.load()
}
async dispose () {
if (this.isDisposed.value) return
// Remove all event listeners
this.#scope.stop()
// Stop audio playback
this.audioNode.disconnect()
this.#audio.pause()
// Cancel any request downloading the source
this.#audio.src = ''
this.#audio.load()
this.isDisposed.value = true
}
async play () {
try {
await this.#audio.play()
} catch (err) {
logger.error('>> AUDIO PLAY ERROR', err, this)
this.isErrored.value = true
}
}
async pause () {
return this.#audio.pause()
}
async seekTo (seconds: number) {
this.#audio.currentTime = seconds
}
async seekBy (seconds: number) {
this.#audio.currentTime += seconds
}
get playable () {
return this.#audio.src !== '' || this.isErrored.value
}
get duration () {
const { duration } = this.#audio
return isNaN(duration) ? 0 : duration
}
get buffered () {
// https://developer.mozilla.org/en-US/docs/Web/Guide/Audio_and_video_delivery/buffering_seeking_time_ranges#creating_our_own_buffering_feedback
if (this.duration > 0) {
const { length } = this.#audio.buffered
for (let i = 0; i < length; i++) {
if (this.#audio.buffered.start(length - 1 - i) < this.#audio.currentTime) {
return this.#audio.buffered.end(length - 1 - i)
}
}
}
return 0
}
get currentTime () {
return this.#audio.currentTime
}
get looping () {
return this.#audio.loop
}
set looping (value: boolean) {
this.#audio.loop = value
}
}
export const soundImplementation = refDefault(ref<Constructor<Sound>>(), HTMLSound)