mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
a52d005ff5
Refresh the web access cookie after a media load failure and resume from the last observed position. Limit retries so unrelated media errors surface promptly. Closes #1536
275 lines
7.9 KiB
TypeScript
275 lines
7.9 KiB
TypeScript
import { ref } from 'vue'
|
|
import { Howl } from 'howler'
|
|
import { refreshAccessToken } from '@/lib/api'
|
|
|
|
export interface AudioFile {
|
|
assetId: string
|
|
format: string
|
|
durationMs: number | null
|
|
}
|
|
|
|
const RECOVERY_COOLDOWN_MS = 5_000
|
|
|
|
function serveUrl(bookId: number, assetId: string): string {
|
|
return `/api/v1/audiobooks/${bookId}/assets/${assetId}/content`
|
|
}
|
|
|
|
export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (assetId: string) => void) {
|
|
const currentIndex = ref(0)
|
|
const isPlaying = ref(false)
|
|
const currentPosition = ref(0)
|
|
const duration = ref(0)
|
|
const loadError = ref<string | null>(null)
|
|
|
|
// Only the active Howl and its immediate neighbours are kept alive.
|
|
const howls = new Map<string, Howl>()
|
|
|
|
// Pending seek to apply when the current Howl finishes loading.
|
|
// Using a single variable prevents stacking multiple once('load') seek handlers.
|
|
let pendingSeek: number | null = null
|
|
let playRequested = false
|
|
let recoveryPending = false
|
|
let destroyed = false
|
|
let activationEpoch = 0
|
|
let playbackRate = 1
|
|
let playbackVolume = 1
|
|
let lastRecoveryAt: number | null = null
|
|
|
|
function buildHowl(index: number, allowRecovery = true): Howl {
|
|
const file = files[index]!
|
|
const fmt = file.format?.toLowerCase() ?? 'm4b'
|
|
let recovering = false
|
|
const howl = new Howl({
|
|
src: [serveUrl(bookId, file.assetId)],
|
|
format: [fmt],
|
|
html5: true,
|
|
preload: false,
|
|
rate: playbackRate,
|
|
volume: playbackVolume,
|
|
onend() {
|
|
if (recoveryPending || destroyed || howls.get(file.assetId) !== howl) return
|
|
playRequested = false
|
|
onFileEnd(file.assetId)
|
|
},
|
|
onplay() {
|
|
if (recoveryPending || destroyed || howls.get(file.assetId) !== howl) return
|
|
playRequested = true
|
|
isPlaying.value = true
|
|
},
|
|
onpause() {
|
|
isPlaying.value = false
|
|
},
|
|
onstop() {
|
|
isPlaying.value = false
|
|
},
|
|
onload() {
|
|
allowRecovery = true
|
|
if (index === currentIndex.value) {
|
|
duration.value = howl.duration()
|
|
}
|
|
},
|
|
onloaderror(_id: number, err: unknown) {
|
|
if (index !== currentIndex.value || howls.get(file.assetId) !== howl || destroyed || recovering) return
|
|
isPlaying.value = false
|
|
const message = typeof err === 'string' ? err : 'Failed to load audio file'
|
|
if (!allowRecovery || (lastRecoveryAt !== null && Date.now() - lastRecoveryAt < RECOVERY_COOLDOWN_MS)) {
|
|
recoveryPending = false
|
|
playRequested = false
|
|
loadError.value = message
|
|
return
|
|
}
|
|
allowRecovery = false
|
|
lastRecoveryAt = Date.now()
|
|
recovering = true
|
|
recoveryPending = true
|
|
const epoch = activationEpoch
|
|
void refreshAccessToken()
|
|
.then(() => {
|
|
if (destroyed || epoch !== activationEpoch || index !== currentIndex.value || howls.get(file.assetId) !== howl) return
|
|
howl.unload()
|
|
howls.delete(file.assetId)
|
|
const retry = buildHowl(index, false)
|
|
howls.set(file.assetId, retry)
|
|
retry.once('load', () => {
|
|
if (destroyed || index !== currentIndex.value || howls.get(file.assetId) !== retry) return
|
|
const position = currentPosition.value
|
|
retry.seek(position)
|
|
recoveryPending = false
|
|
if (playRequested) retry.play()
|
|
})
|
|
retry.load()
|
|
})
|
|
.catch(() => {
|
|
if (destroyed || epoch !== activationEpoch || index !== currentIndex.value || howls.get(file.assetId) !== howl) return
|
|
recoveryPending = false
|
|
playRequested = false
|
|
loadError.value = message
|
|
})
|
|
.finally(() => {
|
|
recovering = false
|
|
})
|
|
},
|
|
})
|
|
return howl
|
|
}
|
|
|
|
function getOrCreate(index: number): Howl {
|
|
const file = files[index]!
|
|
if (!howls.has(file.assetId)) {
|
|
howls.set(file.assetId, buildHowl(index))
|
|
}
|
|
return howls.get(file.assetId)!
|
|
}
|
|
|
|
function evictDistant(activeIndex: number) {
|
|
for (const [assetId, howl] of howls) {
|
|
const idx = files.findIndex((f) => f.assetId === assetId)
|
|
if (Math.abs(idx - activeIndex) > 1) {
|
|
howl.stop()
|
|
howl.unload()
|
|
howls.delete(assetId)
|
|
}
|
|
}
|
|
}
|
|
|
|
function activateIndex(index: number, positionSeconds = 0) {
|
|
const clamped = Math.max(0, Math.min(index, files.length - 1))
|
|
if (clamped !== currentIndex.value) {
|
|
const prev = howls.get(files[currentIndex.value]!.assetId)
|
|
prev?.stop()
|
|
currentIndex.value = clamped
|
|
pendingSeek = null
|
|
activationEpoch++
|
|
recoveryPending = false
|
|
}
|
|
|
|
const howl = getOrCreate(clamped)
|
|
if (clamped + 1 < files.length) getOrCreate(clamped + 1)
|
|
if (clamped - 1 >= 0) getOrCreate(clamped - 1)
|
|
evictDistant(clamped)
|
|
|
|
loadError.value = null
|
|
duration.value = (files[clamped]!.durationMs ?? 0) / 1000
|
|
currentPosition.value = positionSeconds
|
|
|
|
if (howl.state() === 'loaded') {
|
|
duration.value = howl.duration()
|
|
howl.seek(positionSeconds)
|
|
currentPosition.value = positionSeconds
|
|
} else {
|
|
pendingSeek = positionSeconds
|
|
howl.once('load', () => {
|
|
duration.value = howl.duration()
|
|
const seekTo = pendingSeek ?? positionSeconds
|
|
pendingSeek = null
|
|
howl.seek(seekTo)
|
|
currentPosition.value = seekTo
|
|
})
|
|
howl.load()
|
|
}
|
|
}
|
|
|
|
function currentHowl(): Howl | undefined {
|
|
const assetId = files[currentIndex.value]?.assetId
|
|
return assetId !== undefined ? howls.get(assetId) : undefined
|
|
}
|
|
|
|
function play() {
|
|
const h = currentHowl()
|
|
if (!h) return
|
|
playRequested = true
|
|
if (recoveryPending) return
|
|
if (h.state() === 'unloaded') {
|
|
h.once('load', () => h.play())
|
|
h.load()
|
|
} else {
|
|
h.play()
|
|
}
|
|
}
|
|
|
|
function pause() {
|
|
playRequested = false
|
|
currentHowl()?.pause()
|
|
}
|
|
|
|
function seek(seconds: number) {
|
|
const h = currentHowl()
|
|
if (!h) return
|
|
const fileDurationMs = files[currentIndex.value]?.durationMs
|
|
const fileDur = fileDurationMs === null || fileDurationMs === undefined ? null : fileDurationMs / 1000
|
|
const upper = duration.value || (fileDur != null ? fileDur : Infinity)
|
|
const s = Math.max(0, Math.min(seconds, upper))
|
|
if (h.state() === 'loaded') {
|
|
h.seek(s)
|
|
} else {
|
|
// Store as pending; the activateIndex load handler will apply the latest value.
|
|
pendingSeek = s
|
|
}
|
|
currentPosition.value = s
|
|
}
|
|
|
|
function position(): number {
|
|
if (loadError.value || recoveryPending) return currentPosition.value
|
|
const h = currentHowl()
|
|
if (!h) return currentPosition.value
|
|
const p = h.seek()
|
|
if (typeof p === 'number' && Number.isFinite(p)) currentPosition.value = p
|
|
return currentPosition.value
|
|
}
|
|
|
|
function setSpeed(rate: number) {
|
|
playbackRate = rate
|
|
for (const h of howls.values()) h.rate(rate)
|
|
}
|
|
|
|
function setVolume(vol: number) {
|
|
playbackVolume = vol
|
|
for (const h of howls.values()) h.volume(vol)
|
|
}
|
|
|
|
function goToAsset(assetId: string, positionSeconds = 0) {
|
|
const idx = files.findIndex((f) => f.assetId === assetId)
|
|
if (idx === -1) return
|
|
activateIndex(idx, positionSeconds)
|
|
}
|
|
|
|
function nextFile() {
|
|
if (currentIndex.value < files.length - 1) activateIndex(currentIndex.value + 1, 0)
|
|
}
|
|
|
|
function prevFile() {
|
|
if (currentIndex.value > 0) activateIndex(currentIndex.value - 1, 0)
|
|
}
|
|
|
|
function destroy() {
|
|
destroyed = true
|
|
playRequested = false
|
|
recoveryPending = false
|
|
for (const h of howls.values()) {
|
|
h.stop()
|
|
h.unload()
|
|
}
|
|
howls.clear()
|
|
}
|
|
|
|
return {
|
|
currentIndex,
|
|
isPlaying,
|
|
currentPosition,
|
|
duration,
|
|
loadError,
|
|
files,
|
|
activateIndex,
|
|
play,
|
|
pause,
|
|
seek,
|
|
position,
|
|
setSpeed,
|
|
setVolume,
|
|
goToAsset,
|
|
nextFile,
|
|
prevFile,
|
|
destroy,
|
|
}
|
|
}
|