feat(reader): add dedicated audiobook playback API

This commit is contained in:
neon
2026-09-11 05:33:12 +00:00
parent 7ecc52ee30
commit ceda7428b0
41 changed files with 19933 additions and 523 deletions
@@ -186,7 +186,7 @@ async function loadProgress() {
try {
const [progressRes, audioRes] = await Promise.all([
api(`/api/v1/books/${bookId}/progress`).catch(() => null),
hasAudio ? api(`/api/v1/books/${bookId}/audio-progress`).catch(() => null) : Promise.resolve(null),
hasAudio ? api(`/api/v1/audiobooks/${bookId}/playback-state`).catch(() => null) : Promise.resolve(null),
])
if (bookId !== props.book.id) return
@@ -708,7 +708,7 @@ function cancelReadingDateEdit(field: 'startedAt' | 'finishedAt') {
}
const fileProgressById = ref<Record<number, FileProgress>>({})
const audiobookProgress = ref<{ percentage: number; currentFileId: number; positionSeconds: number; updatedAt: string | null } | null>(null)
const audiobookProgress = ref<{ percentage: number; assetId: string; positionMs: number; capturedAt: string; revision: number } | null>(null)
const collections = ref<CollectionMembership[]>([])
const koboState = ref<BookKoboState | null>(null)
const supplementalLoading = ref(false)
@@ -780,8 +780,7 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
}
if (audiobookProgress.value && audiobookProgress.value.percentage > 0) {
const audioFile = props.book.files.find((f) => f.id === audiobookProgress.value!.currentFileId)
const format = audioFile?.format ?? 'audio'
const format = 'audio'
const color = getFormatColor(format)
rows.push({
label: format.toUpperCase(),
@@ -789,7 +788,7 @@ const leftColumnProgressRows = computed<ProgressRow[]>(() => {
color,
badgeStyle: { color, borderColor: `${color}66`, backgroundColor: `${color}1a` },
finished: audiobookProgress.value.percentage >= 100,
resetFileId: audiobookProgress.value.currentFileId,
resetFileId: -props.book.id,
})
}
const koboPercent = koboState.value?.readingState?.progressPercent
@@ -1074,7 +1073,10 @@ async function handleResetFileProgress(row: ProgressRow) {
setFileResetting(fileId, true)
try {
const res = await api(`/api/v1/books/files/${fileId}/progress`, { method: 'DELETE' })
const res =
fileId < 0
? await api(`/api/v1/audiobooks/${props.book.id}/playback-state`, { method: 'DELETE' })
: await api(`/api/v1/books/files/${fileId}/progress`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to reset file progress')
await loadSupplemental()
} finally {
@@ -1090,7 +1092,7 @@ async function loadSupplemental() {
const hasAudio = props.book.files.some((f) => f.format && FORMAT_TO_GROUP[f.format] === 'audio')
try {
const progressPromise = api(`/api/v1/books/${props.book.id}/progress`).catch(() => null)
const audioProgressPromise = hasAudio ? api(`/api/v1/books/${props.book.id}/audio-progress`).catch(() => null) : Promise.resolve(null)
const audioProgressPromise = hasAudio ? api(`/api/v1/audiobooks/${props.book.id}/playback-state`).catch(() => null) : Promise.resolve(null)
const collectionsPromise = api('/api/v1/collections/membership', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1127,9 +1129,10 @@ async function loadSupplemental() {
audiobookProgress.value = data
? {
percentage: data.percentage,
currentFileId: data.currentFileId,
positionSeconds: data.positionSeconds,
updatedAt: data.updatedAt ?? null,
assetId: data.assetId,
positionMs: data.positionMs,
capturedAt: data.capturedAt,
revision: data.revision,
}
: null
} else {
@@ -158,7 +158,7 @@ describe('DetailsTab cover surface', () => {
mocks.api.mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/metadata-score/weights')) return response({})
if (url.includes('/audio-progress')) return response(null)
if (url.includes('/playback-state')) return response(null)
if (url.includes('/collections/membership')) return response([])
if (url.includes('/kobo-state')) {
return response({
@@ -425,7 +425,7 @@ describe('DetailsTab cover surface', () => {
mocks.api.mockImplementation(async (input) => {
const url = String(input)
if (url.includes('/metadata-score/weights')) return response({})
if (url.includes('/audio-progress')) return response(null)
if (url.includes('/playback-state')) return response(null)
if (url.includes('/collections/membership')) return response([])
if (url.includes('/kobo-state')) {
return response({
@@ -656,7 +656,7 @@ describe('DetailsTab cover surface', () => {
const url = String(input)
if (url.endsWith('/added-at')) return response(updated)
if (url.includes('/metadata-score/weights')) return response({})
if (url.includes('/audio-progress')) return response(null)
if (url.includes('/playback-state')) return response(null)
if (url.includes('/collections/membership')) return response([])
if (url.includes('/kobo-state')) return response({ eligibleForKoboSync: false, syncCollections: [], readingState: null, snapshots: [] })
if (url.includes('/koreader/books/')) return response(null)
@@ -26,7 +26,7 @@ import {
VolumeX,
X,
} from '@lucide/vue'
import type { AudiobookChapter, BookDetail, BookDetailFile } from '@bookorbit/types'
import type { AudiobookManifest, AudiobookManifestAsset, AudiobookManifestChapter, BookDetail } from '@bookorbit/types'
import { api } from '@/lib/api'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import { bookCoverPalette } from '@/features/book/lib/book-cover'
@@ -45,6 +45,7 @@ const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const detail = ref<BookDetail | null>(null)
const manifest = ref<AudiobookManifest | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const showChapters = ref(false)
@@ -67,15 +68,12 @@ watch(showChapters, (val) => {
// ── Audio files ───────────────────────────────────────────────────────────────
const audioFiles = computed<BookDetailFile[]>(() => {
if (!detail.value) return []
const AUDIO_EXTS = new Set(['m4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'])
return detail.value.files.filter((f) => f.format && AUDIO_EXTS.has(f.format.toLowerCase()))
})
const audioFiles = computed<AudiobookManifestAsset[]>(() => manifest.value?.assets ?? [])
// ── Progress ──────────────────────────────────────────────────────────────────
const progress = useAudioProgress(props.bookId, { trackingEnabled })
const manifestRevision = computed(() => manifest.value?.revision ?? '')
const progress = useAudioProgress(props.bookId, { trackingEnabled, manifestRevision })
// ── Queue (created lazily after files load) ───────────────────────────────────
@@ -85,9 +83,9 @@ const isPlaying = ref(false)
const currentPosition = ref(0)
const currentFileIndex = ref(0)
function onFileEnd(fileId: number) {
function onFileEnd(assetId: string) {
if (!queue) return
const endedIdx = audioFiles.value.findIndex((f) => f.id === fileId)
const endedIdx = audioFiles.value.findIndex((f) => f.assetId === assetId)
const nextIdx = (endedIdx >= 0 ? endedIdx : queue.currentIndex.value) + 1
if (nextIdx < audioFiles.value.length) {
queue.activateIndex(nextIdx, 0)
@@ -99,16 +97,17 @@ function onFileEnd(fileId: number) {
}
}
function initQueue(startFileId: number, startPosition: number) {
function initQueue(startAssetId: string, startPosition: number) {
queue = useAudioQueue(
props.bookId,
audioFiles.value.map((f) => ({
id: f.id,
assetId: f.assetId,
format: f.format,
durationSeconds: f.durationSeconds,
durationMs: f.durationMs,
})),
onFileEnd,
)
queue.goToFile(startFileId, startPosition)
queue.goToAsset(startAssetId, startPosition)
queue.setSpeed(settings.playbackSpeed.value)
queue.setVolume(settings.volume.value)
stopQueuePlayingWatch?.()
@@ -165,8 +164,8 @@ function startTicker() {
currentFileIndex.value = queue.currentIndex.value
if (queue.isPlaying.value) {
const fileId = audioFiles.value[queue.currentIndex.value]?.id
if (fileId) progress.update(fileId, pos, progressPct.value)
const assetId = audioFiles.value[queue.currentIndex.value]?.assetId
if (assetId) progress.update(assetId, pos)
// Keep the reading session alive during continuous playback (every ~60s)
activityTickCount++
@@ -235,9 +234,8 @@ onUnmounted(() => {
const displayTitle = computed(() => {
if (!detail.value) return t('reader.audiobook.untitled')
if (manifest.value?.book.title) return manifest.value.book.title
if (detail.value.title) return detail.value.title
const currentFile = audioFiles.value[currentFileIndex.value]
if (currentFile?.filename) return currentFile.filename
return detail.value.folderPath.split('/').pop() || t('reader.audiobook.untitled')
})
@@ -339,7 +337,7 @@ function nextTrack() {
function fileAndOffsetForSeconds(targetSecs: number): { fileIndex: number; posInFile: number } | null {
let offset = 0
for (let i = 0; i < audioFiles.value.length; i++) {
const fileDur = audioFiles.value[i]!.durationSeconds ?? 0
const fileDur = (audioFiles.value[i]!.durationMs ?? 0) / 1000
if (i === audioFiles.value.length - 1 || offset + fileDur > targetSecs) {
return { fileIndex: i, posInFile: Math.max(0, targetSecs - offset) }
}
@@ -423,29 +421,29 @@ function openSpeedPicker() {
showSpeedPicker.value = !showSpeedPicker.value
}
function seekToChapter(chapter: AudiobookChapter) {
function seekToChapter(chapter: AudiobookManifestChapter) {
seekToAbsoluteSeconds(chapter.startMs / 1000, true)
}
function seekToBookmark(bm: AudioBookmark) {
seekToAbsoluteSeconds(bm.positionSeconds, true)
seekToAbsoluteSeconds(bm.positionMs / 1000, true)
}
function deleteBookmark(id: number) {
function deleteBookmark(id: string) {
audioBookmarks.remove(id)
}
// ── Bookmarks toggle ──────────────────────────────────────────────────────────
async function toggleBookmark() {
const nearby = audioBookmarks.bookmarks.value.find((b) => Math.abs(b.positionSeconds - absolutePositionSeconds.value) < 5)
const nearby = audioBookmarks.bookmarks.value.find((b) => Math.abs(b.positionMs / 1000 - absolutePositionSeconds.value) < 5)
if (nearby) {
await audioBookmarks.remove(nearby.id)
} else {
const title = currentChapter.value?.title
? `${currentChapter.value.title} - ${formatTime(absolutePositionSeconds.value)}`
: formatTime(absolutePositionSeconds.value)
await audioBookmarks.add(absolutePositionSeconds.value, title)
await audioBookmarks.add(absolutePositionSeconds.value, title, currentChapter.value?.id)
}
}
@@ -499,7 +497,7 @@ function setSleepTimer(minutes: number) {
function setEndOfChapterSleep() {
// If there are no chapters to watch, fall back to a 30-minute timer.
if (!detail.value?.audioMetadata?.chapters?.length) {
if (!manifest.value?.chapters.length) {
setSleepTimer(30)
return
}
@@ -575,9 +573,9 @@ async function startTrackedReading() {
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
const fileId = audioFiles.value[currentFileIndex.value]?.id
if (fileId) {
progress.update(fileId, currentPosition.value, progressPct.value)
const assetId = audioFiles.value[currentFileIndex.value]?.assetId
if (assetId) {
progress.update(assetId, currentPosition.value)
progress.flush()
}
session.onActivity()
@@ -588,7 +586,7 @@ async function startTrackedReading() {
const absolutePositionSeconds = computed(() => {
let offset = 0
for (let i = 0; i < currentFileIndex.value; i++) {
offset += audioFiles.value[i]!.durationSeconds ?? 0
offset += (audioFiles.value[i]!.durationMs ?? 0) / 1000
}
return offset + currentPosition.value
})
@@ -596,13 +594,7 @@ const absolutePositionSeconds = computed(() => {
const absolutePositionMs = computed(() => absolutePositionSeconds.value * 1000)
const totalBookDuration = computed(() => {
const durationFromFiles = audioFiles.value.reduce((sum, f) => sum + (f.durationSeconds ?? 0), 0)
if (durationFromFiles === 0) {
return detail.value?.audioMetadata?.durationSeconds ?? 0
}
return durationFromFiles
return (manifest.value?.totalDurationMs ?? 0) / 1000
})
const progressPct = computed(() => {
@@ -612,8 +604,8 @@ const progressPct = computed(() => {
})
const chapterTicks = computed(() => {
if (!detail.value?.audioMetadata?.chapters?.length || !totalBookDuration.value) return []
return detail.value.audioMetadata.chapters
if (!manifest.value?.chapters.length || !totalBookDuration.value) return []
return manifest.value.chapters
.filter((ch) => ch.startMs > 0)
.map((ch) => ({
startMs: ch.startMs,
@@ -627,26 +619,25 @@ const bookmarkTicks = computed(() => {
if (!total) return []
return audioBookmarks.bookmarks.value.map((bm) => ({
id: bm.id,
pct: Math.min((bm.positionSeconds / total) * 100, 100),
pct: Math.min((bm.positionMs / 1000 / total) * 100, 100),
}))
})
const chapterDurations = computed<number[]>(() => {
const chapters = detail.value?.audioMetadata?.chapters
const chapters = manifest.value?.chapters
if (!chapters?.length) return []
return chapters.map((ch, i) => {
const nextStartMs = chapters[i + 1]?.startMs ?? totalBookDuration.value * 1000
return Math.max(0, (nextStartMs - ch.startMs) / 1000)
return chapters.map((ch) => {
return Math.max(0, (ch.endMs - ch.startMs) / 1000)
})
})
const SPEEDS = [0.75, 1.0, 1.25, 1.5, 2.0] as const
const currentChapter = computed<AudiobookChapter | null>(() => {
const chapters = detail.value?.audioMetadata?.chapters
const currentChapter = computed<AudiobookManifestChapter | null>(() => {
const chapters = manifest.value?.chapters
if (!chapters?.length) return null
const pos = absolutePositionMs.value
let current: AudiobookChapter | null = null
let current: AudiobookManifestChapter | null = null
for (const ch of chapters) {
if (ch.startMs <= pos) current = ch
else break
@@ -654,12 +645,12 @@ const currentChapter = computed<AudiobookChapter | null>(() => {
return current
})
const scrubberHoverChapter = computed<AudiobookChapter | null>(() => {
const scrubberHoverChapter = computed<AudiobookManifestChapter | null>(() => {
if (scrubberHoverSeconds.value === null) return null
const chapters = detail.value?.audioMetadata?.chapters
const chapters = manifest.value?.chapters
if (!chapters?.length) return null
const posMs = scrubberHoverSeconds.value * 1000
let cur: AudiobookChapter | null = null
let cur: AudiobookManifestChapter | null = null
for (const ch of chapters) {
if (ch.startMs <= posMs) cur = ch
else break
@@ -667,7 +658,7 @@ const scrubberHoverChapter = computed<AudiobookChapter | null>(() => {
return cur
})
const isNearBookmark = computed(() => audioBookmarks.bookmarks.value.some((b) => Math.abs(b.positionSeconds - absolutePositionSeconds.value) < 5))
const isNearBookmark = computed(() => audioBookmarks.bookmarks.value.some((b) => Math.abs(b.positionMs / 1000 - absolutePositionSeconds.value) < 5))
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
@@ -709,16 +700,15 @@ onMounted(async () => {
document.addEventListener('keydown', handleKey)
try {
const [detailRes] = await Promise.all([
const [detailRes, manifestRes] = await Promise.all([
api(`/api/v1/books/${props.bookId}`).then((r) => r.json() as Promise<BookDetail>),
api(`/api/v1/audiobooks/${props.bookId}/manifest`).then((r) => r.json() as Promise<AudiobookManifest>),
progress.load(),
settings.init(),
])
if (!mounted) return
detail.value = detailRes
if (detailRes.audioMetadata?.chapters) {
detailRes.audioMetadata.chapters.sort((a, b) => a.startMs - b.startMs)
}
manifest.value = manifestRes
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
@@ -748,18 +738,20 @@ onMounted(async () => {
return
}
let startFileId = props.fileId
let startAssetId = audioFiles.value[0]!.assetId
let startPosition = 0
if (progress.loaded.value && progress.resumeFileId.value !== null) {
startFileId = progress.resumeFileId.value
if (progress.loaded.value && progress.resumeAssetId.value !== null) {
startAssetId = progress.resumeAssetId.value
startPosition = progress.resumePosition.value
} else {
const isAudio = audioFiles.value.some((f) => f.id === startFileId)
if (!isAudio) startFileId = audioFiles.value[0]!.id
const detailAudioFiles =
detail.value?.files.filter((file) => file.format && ['m4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac'].includes(file.format.toLowerCase())) ?? []
const detailIndex = detailAudioFiles.findIndex((file) => file.id === props.fileId)
startAssetId = audioFiles.value[Math.max(0, detailIndex)]?.assetId ?? startAssetId
}
initQueue(startFileId, startPosition)
initQueue(startAssetId, startPosition)
void audioBookmarks.load()
})
</script>
@@ -804,8 +796,8 @@ onMounted(async () => {
</button>
<div class="flex-1 min-w-0 px-1">
<p class="text-sm font-semibold truncate">{{ displayTitle }}</p>
<p v-if="detail.audioMetadata?.narrators.length" class="text-xs text-white/55 truncate">
{{ detail.audioMetadata.narrators.map((n) => n.name).join(', ') }}
<p v-if="manifest?.book.narrators.length" class="text-xs text-white/55 truncate">
{{ manifest.book.narrators.join(', ') }}
</p>
</div>
<div v-if="props.peekMode" class="flex h-8 items-center gap-1 rounded-md border border-white/20 bg-white/10 px-1.5 text-white">
@@ -1063,9 +1055,9 @@ onMounted(async () => {
</button>
<button
class="text-sm text-left px-3 py-2 rounded-lg transition-colors text-white"
:class="detail?.audioMetadata?.chapters?.length ? 'hover:bg-white/10' : 'opacity-40 cursor-not-allowed'"
:disabled="!detail?.audioMetadata?.chapters?.length"
:title="!detail?.audioMetadata?.chapters?.length ? t('reader.audiobook.noChaptersAvailable') : undefined"
:class="manifest?.chapters.length ? 'hover:bg-white/10' : 'opacity-40 cursor-not-allowed'"
:disabled="!manifest?.chapters.length"
:title="!manifest?.chapters.length ? t('reader.audiobook.noChaptersAvailable') : undefined"
@click="setEndOfChapterSleep"
>
{{ t('reader.audiobook.endOfChapter') }}
@@ -1168,9 +1160,9 @@ onMounted(async () => {
<!-- Chapters list -->
<div v-if="chaptersTab === 'chapters'" class="flex-1 overflow-y-auto">
<div v-if="detail.audioMetadata?.chapters?.length">
<div v-if="manifest?.chapters.length">
<button
v-for="(chapter, i) in detail.audioMetadata.chapters"
v-for="(chapter, i) in manifest.chapters"
:key="chapter.startMs"
class="w-full text-left px-5 py-3 hover:bg-white/10 transition-colors text-sm"
:class="currentChapter?.startMs === chapter.startMs ? 'text-white font-semibold' : 'text-white/65'"
@@ -1196,7 +1188,7 @@ onMounted(async () => {
>
<button class="flex-1 text-left min-w-0" @click="seekToBookmark(bm)">
<span class="block text-sm text-white truncate">{{ bm.title }}</span>
<span class="text-xs text-white/35 tabular-nums">{{ formatTime(bm.positionSeconds) }}</span>
<span class="text-xs text-white/35 tabular-nums">{{ formatTime(bm.positionMs / 1000) }}</span>
</button>
<button
class="opacity-0 group-hover:opacity-100 p-1.5 rounded-full hover:bg-white/10 text-white/50 hover:text-red-400 transition-all shrink-0"
@@ -1,38 +1,33 @@
import { ref } from 'vue'
import type { AudiobookBookmark } from '@bookorbit/types'
import { api } from '@/lib/api'
export interface AudioBookmark {
id: number
bookId: number
title: string
positionSeconds: number
createdAt: string
}
export type AudioBookmark = AudiobookBookmark
export function useAudioBookmarks(bookId: number) {
const bookmarks = ref<AudioBookmark[]>([])
async function load() {
const res = await api(`/api/v1/books/${bookId}/bookmarks`)
const res = await api(`/api/v1/audiobooks/${bookId}/bookmarks`)
if (!res.ok) return
const data: { id: number; bookId: number; title: string; positionSeconds: number | null; createdAt: string }[] = await res.json()
bookmarks.value = data.filter((b): b is AudioBookmark => b.positionSeconds != null).sort((a, b) => a.positionSeconds - b.positionSeconds)
const data: AudioBookmark[] = await res.json()
bookmarks.value = data.sort((a, b) => a.positionMs - b.positionMs)
}
async function add(positionSeconds: number, title: string): Promise<AudioBookmark | null> {
const res = await api(`/api/v1/books/${bookId}/bookmarks`, {
async function add(positionSeconds: number, title: string, chapterId?: string): Promise<AudioBookmark | null> {
const res = await api(`/api/v1/audiobooks/${bookId}/bookmarks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ positionSeconds, title }),
body: JSON.stringify({ clientId: crypto.randomUUID(), positionMs: Math.round(positionSeconds * 1000), title, chapterId }),
})
if (!res.ok) return null
const created: AudioBookmark = await res.json()
bookmarks.value = [...bookmarks.value, created].sort((a, b) => a.positionSeconds - b.positionSeconds)
bookmarks.value = [...bookmarks.value, created].sort((a, b) => a.positionMs - b.positionMs)
return created
}
async function remove(bookmarkId: number) {
const res = await api(`/api/v1/books/${bookId}/bookmarks/${bookmarkId}`, { method: 'DELETE' })
async function remove(bookmarkId: string) {
const res = await api(`/api/v1/audiobooks/${bookId}/bookmarks/${bookmarkId}`, { method: 'DELETE' })
if (res.ok) {
bookmarks.value = bookmarks.value.filter((b) => b.id !== bookmarkId)
}
@@ -5,42 +5,44 @@ const SAVE_THROTTLE_MS = 5_000
export interface AudioProgressOptions {
trackingEnabled?: MaybeRef<boolean>
manifestRevision: MaybeRef<string>
}
export function useAudioProgress(bookId: number, options: AudioProgressOptions = {}) {
const resumeFileId = ref<number | null>(null)
export function useAudioProgress(bookId: number, options: AudioProgressOptions) {
const resumeAssetId = ref<string | null>(null)
const resumePosition = ref(0)
const revision = ref(0)
const loaded = ref(false)
const trackingEnabled = options.trackingEnabled ?? true
let pendingFileId: number | null = null
let pendingAssetId: string | null = null
let pendingPosition = 0
let pendingPercentage = 0
let saveTimer: ReturnType<typeof setTimeout> | null = null
let dirty = false
let saving = false
async function load() {
if (!unref(trackingEnabled)) {
loaded.value = true
return
}
const res = await api(`/api/v1/books/${bookId}/audio-progress`)
const res = await api(`/api/v1/audiobooks/${bookId}/playback-state`)
// Mark loaded regardless of response so callers can distinguish
// "load attempted" from "load not yet called".
loaded.value = true
if (!res.ok) return
const data = await res.json()
if (data) {
resumeFileId.value = data.currentFileId ?? null
resumePosition.value = data.positionSeconds ?? 0
resumeAssetId.value = data.assetId ?? null
resumePosition.value = (data.positionMs ?? 0) / 1000
revision.value = data.revision ?? 0
}
}
function update(fileId: number, positionSeconds: number, percentage: number) {
function update(assetId: string, positionSeconds: number) {
if (!unref(trackingEnabled)) return
pendingFileId = fileId
pendingAssetId = assetId
pendingPosition = positionSeconds
pendingPercentage = percentage
dirty = true
if (!saveTimer) {
@@ -51,22 +53,47 @@ export function useAudioProgress(bookId: number, options: AudioProgressOptions =
}
}
function flushIfDirty() {
async function flushIfDirty() {
if (!unref(trackingEnabled)) return
if (!dirty || pendingFileId === null) return
if (!dirty || pendingAssetId === null || saving) return
const assetId = pendingAssetId
const positionMs = Math.max(0, Math.round(pendingPosition * 1000))
const body = JSON.stringify({
percentage: pendingPercentage,
currentFileId: pendingFileId,
positionSeconds: pendingPosition,
assetId,
positionMs,
capturedAt: new Date().toISOString(),
operationId: crypto.randomUUID(),
baseRevision: revision.value,
manifestRevision: unref(options.manifestRevision),
})
dirty = false
api(`/api/v1/books/${bookId}/audio-progress`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body,
}).catch(() => {
saving = true
try {
const res = await api(`/api/v1/audiobooks/${bookId}/playback-state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body,
})
if (!res.ok) {
dirty = true
if (res.status === 409) await load()
return
}
const state = await res.json()
revision.value = state.revision
resumeAssetId.value = state.assetId
resumePosition.value = state.positionMs / 1000
} catch {
dirty = true
})
} finally {
saving = false
if (dirty && !saveTimer) {
saveTimer = setTimeout(() => {
saveTimer = null
void flushIfDirty()
}, SAVE_THROTTLE_MS)
}
}
}
function flush() {
@@ -75,10 +102,10 @@ export function useAudioProgress(bookId: number, options: AudioProgressOptions =
clearTimeout(saveTimer)
saveTimer = null
}
flushIfDirty()
void flushIfDirty()
}
onUnmounted(flush)
return { resumeFileId, resumePosition, loaded, load, update, flush }
return { resumeAssetId, resumePosition, revision, loaded, load, update, flush }
}
@@ -2,16 +2,16 @@ import { ref } from 'vue'
import { Howl } from 'howler'
export interface AudioFile {
id: number
format: string | null
durationSeconds: number | null
assetId: string
format: string
durationMs: number | null
}
function serveUrl(fileId: number): string {
return `/api/v1/books/files/${fileId}/serve`
function serveUrl(bookId: number, assetId: string): string {
return `/api/v1/audiobooks/${bookId}/assets/${assetId}/content`
}
export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) => void) {
export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (assetId: string) => void) {
const currentIndex = ref(0)
const isPlaying = ref(false)
const currentPosition = ref(0)
@@ -19,7 +19,7 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
const loadError = ref<string | null>(null)
// Only the active Howl and its immediate neighbours are kept alive.
const howls = new Map<number, Howl>()
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.
@@ -29,12 +29,12 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
const file = files[index]!
const fmt = file.format?.toLowerCase() ?? 'm4b'
const howl = new Howl({
src: [serveUrl(file.id)],
src: [serveUrl(bookId, file.assetId)],
format: [fmt],
html5: true,
preload: false,
onend() {
onFileEnd(file.id)
onFileEnd(file.assetId)
},
onplay() {
isPlaying.value = true
@@ -61,19 +61,19 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
function getOrCreate(index: number): Howl {
const file = files[index]!
if (!howls.has(file.id)) {
howls.set(file.id, buildHowl(index))
if (!howls.has(file.assetId)) {
howls.set(file.assetId, buildHowl(index))
}
return howls.get(file.id)!
return howls.get(file.assetId)!
}
function evictDistant(activeIndex: number) {
for (const [fileId, howl] of howls) {
const idx = files.findIndex((f) => f.id === fileId)
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(fileId)
howls.delete(assetId)
}
}
}
@@ -81,7 +81,7 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
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]!.id)
const prev = howls.get(files[currentIndex.value]!.assetId)
prev?.stop()
currentIndex.value = clamped
pendingSeek = null
@@ -93,7 +93,7 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
evictDistant(clamped)
loadError.value = null
duration.value = files[clamped]!.durationSeconds ?? 0
duration.value = (files[clamped]!.durationMs ?? 0) / 1000
if (howl.state() === 'loaded') {
duration.value = howl.duration()
@@ -113,8 +113,8 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
}
function currentHowl(): Howl | undefined {
const id = files[currentIndex.value]?.id
return id !== undefined ? howls.get(id) : undefined
const assetId = files[currentIndex.value]?.assetId
return assetId !== undefined ? howls.get(assetId) : undefined
}
function play() {
@@ -135,7 +135,8 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
function seek(seconds: number) {
const h = currentHowl()
if (!h) return
const fileDur = files[currentIndex.value]?.durationSeconds
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') {
@@ -162,8 +163,8 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
for (const h of howls.values()) h.volume(vol)
}
function goToFile(fileId: number, positionSeconds = 0) {
const idx = files.findIndex((f) => f.id === fileId)
function goToAsset(assetId: string, positionSeconds = 0) {
const idx = files.findIndex((f) => f.assetId === assetId)
if (idx === -1) return
activateIndex(idx, positionSeconds)
}
@@ -198,7 +199,7 @@ export function useAudioQueue(files: AudioFile[], onFileEnd: (fileId: number) =>
position,
setSpeed,
setVolume,
goToFile,
goToAsset,
nextFile,
prevFile,
destroy,