202 lines
5.1 KiB
TypeScript
202 lines
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)
|