mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(reader): add dedicated audiobook playback API
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user