Files
bookorbit/client/src/features/reader/audiobook/composables/useAudioQueue.ts
T
neon a52d005ff5 fix(reader): recover audiobook playback after media auth failure
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
2026-09-26 05:52:28 +00:00

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,
}
}