Files
bookorbit/client/src/features/reader/audiobook/AudiobookReaderView.vue
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

1485 lines
56 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import {
Bookmark,
BookmarkCheck,
BookOpen,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Maximize,
Minimize,
Minus,
Moon,
Pause,
Play,
Plus,
RotateCcw,
RotateCw,
Settings,
Trash2,
Volume1,
Volume2,
VolumeX,
X,
} from '@lucide/vue'
import type { AudiobookManifest, AudiobookManifestAsset, AudiobookManifestChapter, BookDetail } from '@bookorbit/types'
import { api } from '@/lib/api'
import CoverFill from '@/features/book/components/CoverFill.vue'
import BookCoverPlaceholder from '@/features/book/components/BookCoverPlaceholder.vue'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { bookCoverPalette } from '@/features/book/lib/book-cover'
import { createCoverFillArtworkUrl } from '@/features/book/lib/cover-fill-artwork'
import { useAudioProgress } from './composables/useAudioProgress'
import { useAudioQueue } from './composables/useAudioQueue'
import { useAudioSettings } from './composables/useAudioSettings'
import { useAudioBookmarks, type AudioBookmark } from './composables/useAudioBookmarks'
import { useReadingSession } from '../shared/composables/useReadingSession'
import { useFullscreen } from '../shared/composables/useFullscreen'
const { t } = useI18n()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
const router = useRouter()
const { coverUrl } = useCoverVersions()
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)
const chaptersTab = ref<'chapters' | 'bookmarks'>('chapters')
const showSettings = ref(false)
const showSleepTimer = ref(false)
const showSpeedPicker = ref(false)
const { isFullscreen, toggleFullscreen } = useFullscreen()
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
const sleepButtonRef = ref<HTMLButtonElement | null>(null)
const speedButtonRef = ref<HTMLButtonElement | null>(null)
const sleepPopoverStyle = ref<Record<string, string>>({})
const speedPopoverStyle = ref<Record<string, string>>({})
// Reset chapters tab to default when sheet closes
watch(showChapters, (val) => {
if (!val) chaptersTab.value = 'chapters'
})
// ── Audio files ───────────────────────────────────────────────────────────────
const audioFiles = computed<AudiobookManifestAsset[]>(() => manifest.value?.assets ?? [])
// ── Progress ──────────────────────────────────────────────────────────────────
const manifestRevision = computed(() => manifest.value?.revision ?? '')
const progress = useAudioProgress(props.bookId, { trackingEnabled, manifestRevision })
// ── Queue (created lazily after files load) ───────────────────────────────────
let queue: ReturnType<typeof useAudioQueue> | null = null
let stopQueuePlayingWatch: WatchStopHandle | null = null
let stopQueueErrorWatch: WatchStopHandle | null = null
const isPlaying = ref(false)
const currentPosition = ref(0)
const currentFileIndex = ref(0)
function onFileEnd(assetId: string) {
if (!queue) return
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)
queue.play()
isPlaying.value = true
session.onActivity()
} else {
isPlaying.value = false
}
}
function initQueue(startAssetId: string, startPosition: number) {
queue = useAudioQueue(
props.bookId,
audioFiles.value.map((f) => ({
assetId: f.assetId,
format: f.format,
durationMs: f.durationMs,
})),
onFileEnd,
)
queue.goToAsset(startAssetId, startPosition)
queue.setSpeed(settings.playbackSpeed.value)
queue.setVolume(settings.volume.value)
stopQueuePlayingWatch?.()
stopQueuePlayingWatch = watch(
queue.isPlaying,
(playing) => {
if (isPlaying.value !== playing) {
isPlaying.value = playing
}
},
{ immediate: true },
)
stopQueueErrorWatch?.()
stopQueueErrorWatch = watch(
queue.loadError,
(message) => {
if (message && !error.value) error.value = message
},
{ immediate: true },
)
syncRefs()
currentPosition.value = startPosition
currentFileIndex.value = queue.currentIndex.value
}
function syncRefs() {
if (!queue) return
isPlaying.value = queue.isPlaying.value
currentPosition.value = queue.position()
currentFileIndex.value = queue.currentIndex.value
if (queue.loadError.value && !error.value) {
error.value = queue.loadError.value
}
}
// ── Settings ──────────────────────────────────────────────────────────────────
const settings = useAudioSettings(
(rate) => queue?.setSpeed(rate),
(vol) => queue?.setVolume(vol),
)
// ── Bookmarks ─────────────────────────────────────────────────────────────────
const audioBookmarks = useAudioBookmarks(props.bookId)
// ── Reading session ───────────────────────────────────────────────────────────
const session = useReadingSession(props.fileId, () => ({ percentage: progressPct.value }), { trackingEnabled })
// ── Ticker (updates position every 500ms while playing) ──────────────────────
let ticker: ReturnType<typeof setInterval> | null = null
let activityTickCount = 0
function startTicker() {
if (ticker) return
ticker = setInterval(() => {
if (!queue) return
const pos = queue.position()
currentPosition.value = pos
currentFileIndex.value = queue.currentIndex.value
if (queue.isPlaying.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++
if (activityTickCount >= 120) {
activityTickCount = 0
session.onActivity()
}
// End-of-chapter sleep: pause when we advance past the chapter that was
// active when the user set the timer.
if (sleepAtChapterEnd.value && sleepChapterStartMs !== null) {
const curStartMs = currentChapter.value?.startMs ?? null
if (curStartMs !== null && curStartMs !== sleepChapterStartMs) {
stopTicker()
queue?.pause()
isPlaying.value = false
sleepAtChapterEnd.value = false
sleepChapterStartMs = null
}
}
}
if (queue.loadError.value && !error.value) {
error.value = queue.loadError.value
}
}, 500)
}
function stopTicker() {
if (ticker) {
clearInterval(ticker)
ticker = null
}
activityTickCount = 0
}
watch(isPlaying, (val) => {
if (val) startTicker()
else {
stopTicker()
progress.flush()
}
updateMediaPlaybackState()
})
let mounted = true
let mediaSessionArtworkUrl: string | null = null
let mediaSessionArtworkRevision = 0
function releaseMediaSessionArtwork() {
mediaSessionArtworkRevision++
if (!mediaSessionArtworkUrl) return
URL.revokeObjectURL(mediaSessionArtworkUrl)
mediaSessionArtworkUrl = null
}
onUnmounted(() => {
mounted = false
releaseMediaSessionArtwork()
stopQueuePlayingWatch?.()
stopQueuePlayingWatch = null
stopQueueErrorWatch?.()
stopQueueErrorWatch = null
queue?.destroy()
stopTicker()
progress.flush()
cancelSleepTimer()
document.removeEventListener('keydown', handleKey)
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', null)
navigator.mediaSession.setActionHandler('pause', null)
navigator.mediaSession.setActionHandler('seekbackward', null)
navigator.mediaSession.setActionHandler('seekforward', null)
navigator.mediaSession.setActionHandler('previoustrack', null)
navigator.mediaSession.setActionHandler('nexttrack', null)
}
})
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
return detail.value.folderPath.split('/').pop() || t('reader.audiobook.untitled')
})
const coverSeed = computed(() => detail.value?.title ?? detail.value?.folderPath.split('/').pop() ?? String(props.bookId))
const coverPalette = computed(() => bookCoverPalette(coverSeed.value))
const coverSrc = computed(() => (detail.value?.coverSource ? coverUrl(props.bookId, 'cover', detail.value.coverVersion, 'audio') : null))
// ── Media Session ─────────────────────────────────────────────────────────────
watch(displayTitle, (newTitle) => {
if ('mediaSession' in navigator && navigator.mediaSession.metadata) {
navigator.mediaSession.metadata.title = newTitle
}
})
function updateMediaPlaybackState() {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = isPlaying.value ? 'playing' : 'paused'
}
}
function updateMediaSessionMetadata(book: BookDetail) {
if (!('mediaSession' in navigator)) return
releaseMediaSessionArtwork()
const revision = mediaSessionArtworkRevision
const src = book.coverSource ? coverUrl(props.bookId, 'cover', book.coverVersion, 'audio') : null
const metadata = (artwork: MediaImage[] = []) =>
new MediaMetadata({
title: displayTitle.value,
artist: book.authors.map((author) => author.name).join(', '),
artwork,
})
navigator.mediaSession.metadata = metadata()
if (!src) return
void createCoverFillArtworkUrl(src).then((artworkUrl) => {
if (!artworkUrl) return
if (!mounted || revision !== mediaSessionArtworkRevision) {
URL.revokeObjectURL(artworkUrl)
return
}
mediaSessionArtworkUrl = artworkUrl
navigator.mediaSession.metadata = metadata([{ src: artworkUrl, sizes: '512x512', type: 'image/jpeg' }])
})
}
// ── Controls ──────────────────────────────────────────────────────────────────
function togglePlay() {
if (!queue) return
if (queue.isPlaying.value) {
queue.pause()
isPlaying.value = false
} else {
queue.play()
isPlaying.value = true
session.onActivity()
}
}
function skipBack() {
if (!queue) return
queue.seek(queue.position() - settings.skipBackSeconds.value)
session.onActivity()
spawnSkipBubble(skipBackBtnRef.value, `-${settings.skipBackSeconds.value}s`)
}
function skipForward() {
if (!queue) return
queue.seek(queue.position() + settings.skipForwardSeconds.value)
session.onActivity()
spawnSkipBubble(skipForwardBtnRef.value, `+${settings.skipForwardSeconds.value}s`)
}
// ── Skip bubbles ──────────────────────────────────────────────────────────────
interface SkipBubble {
id: number
label: string
x: number
y: number
}
const skipBackBtnRef = ref<HTMLButtonElement | null>(null)
const skipForwardBtnRef = ref<HTMLButtonElement | null>(null)
let bubbleIdCounter = 0
const skipBubbles = ref<SkipBubble[]>([])
function spawnSkipBubble(btnEl: HTMLButtonElement | null, label: string) {
if (!btnEl) return
const rect = btnEl.getBoundingClientRect()
const id = ++bubbleIdCounter
skipBubbles.value.push({ id, label, x: rect.left + rect.width / 2, y: rect.top })
setTimeout(() => {
skipBubbles.value = skipBubbles.value.filter((b) => b.id !== id)
}, 700)
}
function prevTrack() {
if (!queue) return
const wasPlaying = isPlaying.value
progress.flush()
queue.prevFile()
if (wasPlaying) {
queue.play()
isPlaying.value = true
}
syncRefs()
session.onActivity()
}
function nextTrack() {
if (!queue) return
const wasPlaying = isPlaying.value
progress.flush()
queue.nextFile()
if (wasPlaying) {
queue.play()
isPlaying.value = true
}
syncRefs()
session.onActivity()
}
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]!.durationMs ?? 0) / 1000
if (i === audioFiles.value.length - 1 || offset + fileDur > targetSecs) {
return { fileIndex: i, posInFile: Math.max(0, targetSecs - offset) }
}
offset += fileDur
}
return null
}
// Shared seek: jumps to an absolute book position in seconds.
// closeSheet=true dismisses the chapter/bookmark sheet after seeking.
function seekToAbsoluteSeconds(absoluteSecs: number, closeSheet = false) {
if (!queue || audioFiles.value.length === 0) return
const result = fileAndOffsetForSeconds(absoluteSecs)
if (!result) return
const { fileIndex, posInFile } = result
const wasPlaying = isPlaying.value
if (fileIndex !== queue.currentIndex.value) {
queue.activateIndex(fileIndex, posInFile)
if (wasPlaying) {
queue.play()
isPlaying.value = true
}
} else {
queue.seek(posInFile)
}
currentPosition.value = posInFile
currentFileIndex.value = fileIndex
if (closeSheet) showChapters.value = false
session.onActivity()
}
function handleVolumeChange(event: Event) {
const val = parseFloat((event.target as HTMLInputElement).value)
settings.setVolume(val)
}
const volumeTrackStyle = computed(() => ({ '--volume-pct': Math.round(settings.volume.value * 100) + '%' }))
function selectSpeed(speed: number) {
settings.setPlaybackSpeed(speed)
showSpeedPicker.value = false
}
function speedDown() {
const next = Math.round((settings.playbackSpeed.value - 0.05) * 100) / 100
settings.setPlaybackSpeed(Math.max(0.5, next))
}
function speedUp() {
const next = Math.round((settings.playbackSpeed.value + 0.05) * 100) / 100
settings.setPlaybackSpeed(Math.min(3.0, next))
}
function openSleepTimer() {
if (sleepButtonRef.value) {
const rect = sleepButtonRef.value.getBoundingClientRect()
const popoverWidth = 172
const left = Math.max(12, Math.min(window.innerWidth - popoverWidth - 12, rect.left + rect.width / 2 - popoverWidth / 2))
sleepPopoverStyle.value = {
top: rect.top + 'px',
left: left + 'px',
width: popoverWidth + 'px',
transform: 'translateY(calc(-100% - 8px))',
}
}
showSleepTimer.value = !showSleepTimer.value
}
function openSpeedPicker() {
if (speedButtonRef.value) {
const rect = speedButtonRef.value.getBoundingClientRect()
const popoverWidth = 156
const left = Math.max(12, Math.min(window.innerWidth - popoverWidth - 12, rect.left + rect.width / 2 - popoverWidth / 2))
speedPopoverStyle.value = {
top: rect.top + 'px',
left: left + 'px',
width: popoverWidth + 'px',
transform: 'translateY(calc(-100% - 8px))',
}
}
showSpeedPicker.value = !showSpeedPicker.value
}
function seekToChapter(chapter: AudiobookManifestChapter) {
seekToAbsoluteSeconds(chapter.startMs / 1000, true)
}
function seekToBookmark(bm: AudioBookmark) {
seekToAbsoluteSeconds(bm.positionMs / 1000, true)
}
function deleteBookmark(id: string) {
audioBookmarks.remove(id)
}
// ── Bookmarks toggle ──────────────────────────────────────────────────────────
async function toggleBookmark() {
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, currentChapter.value?.id)
}
}
// ── Volume mute toggle ────────────────────────────────────────────────────────
const preMuteVolume = ref<number | null>(null)
function toggleMute() {
if (settings.volume.value > 0) {
preMuteVolume.value = settings.volume.value
settings.setVolume(0)
} else {
settings.setVolume(preMuteVolume.value ?? 1)
preMuteVolume.value = null
}
}
// ── Sleep timer ───────────────────────────────────────────────────────────────
const sleepTimerRemaining = ref(0)
const sleepAtChapterEnd = ref(false)
let sleepChapterStartMs: number | null = null
let sleepTimerInterval: ReturnType<typeof setInterval> | null = null
const sleepTimerDisplay = computed(() => {
if (sleepAtChapterEnd.value) return t('reader.audiobook.chapterAbbrev')
const secs = sleepTimerRemaining.value
if (secs <= 0) return null
const m = Math.floor(secs / 60)
const s = secs % 60
return m > 0 ? `${m}m` : `${s}s`
})
const sleepTimerActive = computed(() => sleepAtChapterEnd.value || sleepTimerRemaining.value > 0)
function setSleepTimer(minutes: number) {
showSleepTimer.value = false
if (sleepTimerInterval) clearInterval(sleepTimerInterval)
sleepAtChapterEnd.value = false
sleepChapterStartMs = null
sleepTimerRemaining.value = minutes * 60
sleepTimerInterval = setInterval(() => {
sleepTimerRemaining.value--
if (sleepTimerRemaining.value <= 0) {
clearInterval(sleepTimerInterval!)
sleepTimerInterval = null
queue?.pause()
}
}, 1000)
}
function setEndOfChapterSleep() {
// If there are no chapters to watch, fall back to a 30-minute timer.
if (!manifest.value?.chapters.length) {
setSleepTimer(30)
return
}
showSleepTimer.value = false
if (sleepTimerInterval) {
clearInterval(sleepTimerInterval)
sleepTimerInterval = null
}
sleepTimerRemaining.value = 0
sleepAtChapterEnd.value = true
sleepChapterStartMs = currentChapter.value?.startMs ?? null
}
function extendSleepTimer() {
sleepTimerRemaining.value += 15 * 60
}
function cancelSleepTimer() {
if (sleepTimerInterval) {
clearInterval(sleepTimerInterval)
sleepTimerInterval = null
}
sleepTimerRemaining.value = 0
sleepAtChapterEnd.value = false
sleepChapterStartMs = null
showSleepTimer.value = false
}
// ── Scrubber drag ─────────────────────────────────────────────────────────────
const scrubberEl = ref<HTMLDivElement | null>(null)
const scrubberHoverSeconds = ref<number | null>(null)
const isDragging = ref(false)
const dragPositionPct = ref<number | null>(null)
const scrubberProgressPct = computed(() => (dragPositionPct.value !== null ? dragPositionPct.value : progressPct.value))
function computeScrubberPct(clientX: number): number {
if (!scrubberEl.value) return 0
const rect = scrubberEl.value.getBoundingClientRect()
return Math.max(0, Math.min((clientX - rect.left) / rect.width, 1))
}
function handleScrubberPointerDown(e: PointerEvent) {
if (!scrubberEl.value || !totalBookDuration.value) return
e.preventDefault()
isDragging.value = true
scrubberEl.value.setPointerCapture(e.pointerId)
dragPositionPct.value = computeScrubberPct(e.clientX) * 100
scrubberHoverSeconds.value = (dragPositionPct.value / 100) * totalBookDuration.value
}
function handleScrubberPointerMove(e: PointerEvent) {
if (!scrubberEl.value || !totalBookDuration.value) return
const pct = computeScrubberPct(e.clientX)
scrubberHoverSeconds.value = pct * totalBookDuration.value
if (isDragging.value) dragPositionPct.value = pct * 100
}
function handleScrubberPointerUp(e: PointerEvent) {
if (!isDragging.value || !scrubberEl.value || !totalBookDuration.value) return
isDragging.value = false
const pct = computeScrubberPct(e.clientX)
dragPositionPct.value = null
seekToAbsoluteSeconds(pct * totalBookDuration.value)
}
function handleScrubberLeave() {
if (!isDragging.value) scrubberHoverSeconds.value = null
}
async function startTrackedReading() {
const query = { ...route.query }
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
const assetId = audioFiles.value[currentFileIndex.value]?.assetId
if (assetId) {
progress.update(assetId, currentPosition.value)
progress.flush()
}
session.onActivity()
}
// ── Computed helpers ──────────────────────────────────────────────────────────
const absolutePositionSeconds = computed(() => {
let offset = 0
for (let i = 0; i < currentFileIndex.value; i++) {
offset += (audioFiles.value[i]!.durationMs ?? 0) / 1000
}
return offset + currentPosition.value
})
const absolutePositionMs = computed(() => absolutePositionSeconds.value * 1000)
const totalBookDuration = computed(() => {
return (manifest.value?.totalDurationMs ?? 0) / 1000
})
const progressPct = computed(() => {
const total = totalBookDuration.value
if (!total) return 0
return Math.min((absolutePositionSeconds.value / total) * 100, 100)
})
const chapterTicks = computed(() => {
if (!manifest.value?.chapters.length || !totalBookDuration.value) return []
return manifest.value.chapters
.filter((ch) => ch.startMs > 0)
.map((ch) => ({
startMs: ch.startMs,
title: ch.title,
pct: Math.min((ch.startMs / 1000 / totalBookDuration.value) * 100, 100),
}))
})
const bookmarkTicks = computed(() => {
const total = totalBookDuration.value
if (!total) return []
return audioBookmarks.bookmarks.value.map((bm) => ({
id: bm.id,
pct: Math.min((bm.positionMs / 1000 / total) * 100, 100),
}))
})
const chapterDurations = computed<number[]>(() => {
const chapters = manifest.value?.chapters
if (!chapters?.length) return []
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<AudiobookManifestChapter | null>(() => {
const chapters = manifest.value?.chapters
if (!chapters?.length) return null
const pos = absolutePositionMs.value
let current: AudiobookManifestChapter | null = null
for (const ch of chapters) {
if (ch.startMs <= pos) current = ch
else break
}
return current
})
const scrubberHoverChapter = computed<AudiobookManifestChapter | null>(() => {
if (scrubberHoverSeconds.value === null) return null
const chapters = manifest.value?.chapters
if (!chapters?.length) return null
const posMs = scrubberHoverSeconds.value * 1000
let cur: AudiobookManifestChapter | null = null
for (const ch of chapters) {
if (ch.startMs <= posMs) cur = ch
else break
}
return cur
})
const isNearBookmark = computed(() => audioBookmarks.bookmarks.value.some((b) => Math.abs(b.positionMs / 1000 - absolutePositionSeconds.value) < 5))
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
function handleKey(e: KeyboardEvent) {
const target = (e.composedPath?.()[0] || e.target) as HTMLElement | null
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable) return
if (showChapters.value || showSettings.value || showSleepTimer.value || showSpeedPicker.value) return
if (e.key === ' ' || e.key === 'k') {
e.preventDefault()
togglePlay()
} else if (e.key === 'ArrowLeft' || e.key === 'j') {
e.preventDefault()
skipBack()
} else if (e.key === 'ArrowRight' || e.key === 'l') {
e.preventDefault()
skipForward()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
settings.setVolume(Math.min(1, settings.volume.value + 0.1))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
settings.setVolume(Math.max(0, settings.volume.value - 0.1))
}
}
// ── Formatting helpers ────────────────────────────────────────────────────────
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}
// ── Init ──────────────────────────────────────────────────────────────────────
onMounted(async () => {
document.addEventListener('keydown', handleKey)
try {
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
manifest.value = manifestRes
if ('mediaSession' in navigator) {
updateMediaSessionMetadata(detailRes)
navigator.mediaSession.setActionHandler('play', togglePlay)
navigator.mediaSession.setActionHandler('pause', togglePlay)
navigator.mediaSession.setActionHandler('seekbackward', skipBack)
navigator.mediaSession.setActionHandler('seekforward', skipForward)
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
navigator.mediaSession.playbackState = 'none'
}
} catch (e) {
if (!mounted) return
error.value = e instanceof Error ? e.message : t('reader.audiobook.loadError')
loading.value = false
return
}
loading.value = false
if (!audioFiles.value.length) {
error.value = t('reader.audiobook.noAudioFiles')
return
}
let startAssetId = audioFiles.value[0]!.assetId
let startPosition = 0
if (progress.loaded.value && progress.resumeAssetId.value !== null) {
startAssetId = progress.resumeAssetId.value
startPosition = progress.resumePosition.value
} else {
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(startAssetId, startPosition)
void audioBookmarks.load()
})
</script>
<template>
<div class="fixed inset-0 overflow-hidden select-none">
<!-- Blurred cover backdrop -->
<div class="absolute inset-0">
<div
v-if="coverSrc"
class="absolute inset-0 scale-110"
:style="{ backgroundImage: `url(${coverSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }"
/>
<div v-else class="absolute inset-0" :style="{ background: coverPalette.gradient }" />
<div class="absolute inset-0 backdrop-blur-3xl bg-black/60" />
</div>
<!-- Loading state -->
<div v-if="loading" class="relative z-10 flex h-full items-center justify-center">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 rounded-full border-2 border-white/40 border-t-white animate-spin" />
<p class="text-sm text-white/60">{{ t('reader.audiobook.loading') }}</p>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="relative z-10 flex h-full items-center justify-center p-8">
<div class="text-center max-w-sm">
<p class="text-sm font-medium text-white mb-2">{{ t('reader.audiobook.loadError') }}</p>
<p class="text-xs text-white/50 mb-4">{{ error }}</p>
<button class="text-sm text-white/80 underline" @click="router.back">{{ t('reader.header.goBack') }}</button>
</div>
</div>
<template v-else-if="detail">
<!-- Content layer -->
<div class="relative z-10 flex flex-col h-full text-white">
<!-- Header -->
<div class="flex items-center gap-2 px-3 py-3 shrink-0">
<button class="p-2 rounded-full hover:bg-white/10 transition-colors" @click="router.back">
<ChevronLeft class="w-5 h-5" />
</button>
<div class="flex-1 min-w-0 px-1">
<p class="text-sm font-semibold truncate">{{ displayTitle }}</p>
<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">
<span class="hidden text-[11px] font-medium sm:inline">{{ t('reader.peek.badge') }}</span>
<button class="h-6 rounded-sm bg-white px-2 text-[11px] font-semibold text-black hover:bg-white/90" @click="startTrackedReading">
{{ t('reader.peek.startReading') }}
</button>
</div>
<!-- Bookmark toggle -->
<button
class="p-2 rounded-full hover:bg-white/10 transition-colors"
:class="isNearBookmark ? 'text-amber-400' : 'text-white/65'"
@click="toggleBookmark"
>
<BookmarkCheck v-if="isNearBookmark" class="w-5 h-5" />
<Bookmark v-else class="w-5 h-5" />
</button>
<button
class="p-2 rounded-full hover:bg-white/10 transition-colors text-white/65"
:aria-label="fullscreenLabel"
:title="fullscreenLabel"
@click="toggleFullscreen"
>
<Minimize v-if="isFullscreen" class="w-5 h-5" />
<Maximize v-else class="w-5 h-5" />
</button>
<button class="p-2 rounded-full hover:bg-white/10 transition-colors text-white/65" @click="showSettings = !showSettings">
<Settings class="w-5 h-5" />
</button>
</div>
<!-- Main area -->
<div class="flex-1 flex flex-col items-center justify-center gap-4 px-6 py-4 overflow-hidden">
<!-- Cover art -->
<div class="relative shrink-0 h-[min(42vh,22rem)] w-[min(42vh,22rem)]">
<!-- Ambient glow halo -->
<div
class="absolute -inset-4 rounded-2xl blur-3xl pointer-events-none transition-opacity duration-700"
:class="isPlaying ? 'opacity-50' : 'opacity-15'"
:style="
coverSrc
? { backgroundImage: `url(${coverSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { background: coverPalette.gradient }
"
/>
<!-- Cover -->
<div class="absolute inset-0 rounded-2xl overflow-hidden ring-1 ring-white/10 shadow-2xl">
<CoverFill v-if="coverSrc" :src="coverSrc" :alt="displayTitle" loading="eager" />
<BookCoverPlaceholder
v-else
:title="detail.title"
:author-line="detail.authors.map((a) => a.name).join(', ') || null"
:is-audio="true"
:seed="coverSeed"
/>
</div>
</div>
<!-- Title / author / chapter -->
<div class="text-center max-w-xs w-full">
<p class="font-semibold text-lg leading-tight truncate">{{ displayTitle }}</p>
<p v-if="detail.authors.length" class="text-sm text-white/60 mt-0.5 truncate">
{{ detail.authors.map((a) => a.name).join(', ') }}
</p>
<p v-if="currentChapter" class="text-xs text-white/45 mt-1 truncate font-medium uppercase tracking-widest">
{{ currentChapter.title }}
</p>
</div>
<!-- Progress bar with chapter ticks, bookmark ticks, and hover tooltip -->
<div class="w-full max-w-sm">
<div
ref="scrubberEl"
class="relative py-3 scrubber-rail"
@pointerdown="handleScrubberPointerDown"
@pointermove="handleScrubberPointerMove"
@pointerup="handleScrubberPointerUp"
@pointerleave="handleScrubberLeave"
>
<!-- Hover tooltip -->
<div
v-if="scrubberHoverSeconds !== null"
class="absolute -top-8 pointer-events-none flex flex-col items-center"
:style="{ left: (scrubberHoverSeconds / (totalBookDuration || 1)) * 100 + '%', transform: 'translateX(-50%)' }"
>
<span class="text-xs font-semibold text-white bg-black/70 rounded px-1.5 py-0.5 whitespace-nowrap">
{{ formatTime(scrubberHoverSeconds) }}
</span>
<span v-if="scrubberHoverChapter" class="text-[10px] text-white/60 truncate max-w-[10rem] text-center">
{{ scrubberHoverChapter.title }}
</span>
</div>
<!-- Track background + fill -->
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[3px] rounded-full bg-white/20 pointer-events-none">
<div class="absolute inset-y-0 left-0 rounded-full bg-white" :style="{ width: scrubberProgressPct + '%' }" />
</div>
<!-- Chapter tick marks -->
<div
v-for="tick in chapterTicks"
:key="tick.startMs"
class="absolute top-1/2 w-[1.5px] h-[7px] -translate-y-1/2 -translate-x-1/2 bg-white/35 rounded-full pointer-events-none"
:style="{ left: tick.pct + '%' }"
/>
<!-- Bookmark tick marks -->
<div
v-for="tick in bookmarkTicks"
:key="tick.id"
class="absolute top-1/2 w-[1.5px] h-[7px] -translate-y-1/2 -translate-x-1/2 bg-amber-400/80 rounded-full pointer-events-none"
:style="{ left: tick.pct + '%' }"
/>
<!-- Thumb -->
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 rounded-full bg-white pointer-events-none scrubber-thumb"
:class="{ 'scrubber-thumb-dragging': isDragging }"
:style="{ left: scrubberProgressPct + '%' }"
/>
</div>
<div class="flex justify-between text-xs text-white/45 -mt-1 tabular-nums">
<span>{{ formatTime(absolutePositionSeconds) }}</span>
<span>-{{ formatTime(Math.max(0, totalBookDuration - absolutePositionSeconds)) }}</span>
</div>
</div>
<!-- Transport controls -->
<div class="flex items-center gap-3">
<!-- Prev track -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full transition-colors"
:class="currentFileIndex === 0 ? 'opacity-25 cursor-not-allowed' : 'hover:bg-white/10'"
:disabled="currentFileIndex === 0"
@click="prevTrack"
>
<ChevronLeft class="w-6 h-6" />
</button>
<!-- Skip back with seconds overlay -->
<button
ref="skipBackBtnRef"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
@click="skipBack"
>
<span class="relative inline-flex items-center justify-center w-7 h-7">
<RotateCcw class="w-full h-full" />
<span class="absolute text-[8px] font-bold leading-none mt-0.5">{{ settings.skipBackSeconds.value }}</span>
</span>
</button>
<!-- Play / Pause with pulse ring -->
<div class="relative w-16 h-16 flex items-center justify-center shrink-0">
<div v-if="isPlaying" class="absolute inset-0 rounded-full bg-white/20 play-pulse-ring pointer-events-none" />
<button
class="relative w-16 h-16 rounded-full bg-white text-black flex items-center justify-center shadow-2xl hover:scale-105 active:scale-95 transition-transform duration-150 z-10"
@click="togglePlay"
>
<Pause v-if="isPlaying" class="w-6 h-6" />
<Play v-else class="w-6 h-6 ml-0.5" />
</button>
</div>
<!-- Skip forward with seconds overlay -->
<button
ref="skipForwardBtnRef"
class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
@click="skipForward"
>
<span class="relative inline-flex items-center justify-center w-7 h-7">
<RotateCw class="w-full h-full" />
<span class="absolute text-[8px] font-bold leading-none mt-0.5">{{ settings.skipForwardSeconds.value }}</span>
</span>
</button>
<!-- Next track -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full transition-colors"
:class="currentFileIndex >= audioFiles.length - 1 ? 'opacity-25 cursor-not-allowed' : 'hover:bg-white/10'"
:disabled="currentFileIndex >= audioFiles.length - 1"
@click="nextTrack"
>
<ChevronRight class="w-6 h-6" />
</button>
</div>
<!-- Utility row: speed · chapters · volume · sleep -->
<div class="flex items-center justify-around w-full max-w-sm">
<!-- Speed picker button -->
<button
ref="speedButtonRef"
class="flex flex-col items-center gap-1 px-4 py-2 rounded-2xl hover:bg-white/10 transition-colors"
:class="showSpeedPicker ? 'bg-white/15 text-white' : 'text-white/65'"
@click="openSpeedPicker"
>
<span class="flex items-center gap-1 text-sm font-bold leading-5">
{{ settings.playbackSpeed.value }}x
<ChevronUp v-if="showSpeedPicker" class="w-3 h-3 text-white/55" />
<ChevronDown v-else class="w-3 h-3 text-white/55" />
</span>
<span class="text-[10px] font-medium tracking-wide">{{ t('reader.audiobook.speed') }}</span>
</button>
<!-- Chapters / Bookmarks -->
<button
class="flex flex-col items-center gap-1 px-4 py-2 rounded-2xl hover:bg-white/10 transition-colors text-white/65"
@click="showChapters = !showChapters"
>
<BookOpen class="w-5 h-5" />
<span class="text-[10px] font-medium tracking-wide">{{ t('reader.audiobook.chapters') }}</span>
</button>
<!-- Volume mute toggle -->
<button
class="flex flex-col items-center gap-1 px-4 py-2 rounded-2xl hover:bg-white/10 transition-colors"
:class="settings.volume.value === 0 ? 'text-white' : 'text-white/65'"
@click="toggleMute"
>
<VolumeX v-if="settings.volume.value === 0" class="w-5 h-5" />
<Volume1 v-else-if="settings.volume.value < 0.5" class="w-5 h-5" />
<Volume2 v-else class="w-5 h-5" />
<span class="text-[10px] font-medium tracking-wide">{{
settings.volume.value === 0 ? t('reader.audiobook.muted') : t('reader.audiobook.volume')
}}</span>
</button>
<!-- Sleep timer -->
<div class="relative">
<button
ref="sleepButtonRef"
class="flex flex-col items-center gap-1 px-4 py-2 rounded-2xl transition-colors"
:class="sleepTimerActive ? 'bg-white/15 text-white' : 'hover:bg-white/10 text-white/65'"
@click="openSleepTimer"
>
<Moon class="w-5 h-5" />
<span class="text-[10px] font-medium tracking-wide">
{{ sleepTimerDisplay ? sleepTimerDisplay : t('reader.audiobook.sleep') }}
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Sleep timer picker -->
<Transition name="fade">
<div v-if="showSleepTimer" class="absolute inset-0 z-30" @click="showSleepTimer = false">
<div class="absolute bg-black/85 backdrop-blur-xl border border-white/10 rounded-2xl p-3 shadow-2xl" :style="sleepPopoverStyle" @click.stop>
<p class="text-[10px] font-semibold text-white/50 uppercase tracking-widest px-2 mb-2">{{ t('reader.audiobook.sleepTimer') }}</p>
<div class="flex flex-col gap-0.5">
<button
v-for="mins in [15, 30, 45, 60]"
:key="mins"
class="text-sm text-left px-3 py-2 rounded-lg transition-colors hover:bg-white/10 text-white"
@click="setSleepTimer(mins)"
>
{{ t('reader.audiobook.minutes', { count: mins }) }}
</button>
<button
class="text-sm text-left px-3 py-2 rounded-lg transition-colors text-white"
: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') }}
</button>
<template v-if="sleepTimerActive">
<div class="border-t border-white/10 my-1" />
<button
v-if="sleepTimerRemaining > 0"
class="text-sm text-left px-3 py-2 rounded-lg transition-colors hover:bg-white/10 text-white/70 w-full"
@click="extendSleepTimer"
>
{{ t('reader.audiobook.extend15') }}
</button>
<button
class="text-sm text-left px-3 py-2 rounded-lg transition-colors hover:bg-white/10 text-red-400 w-full"
@click="cancelSleepTimer"
>
{{ t('common.cancel') }}
<span v-if="sleepTimerDisplay && !sleepAtChapterEnd">{{ t('reader.audiobook.timeLeft', { time: sleepTimerDisplay }) }}</span>
</button>
</template>
</div>
</div>
</div>
</Transition>
<!-- Speed picker popover -->
<Transition name="fade">
<div v-if="showSpeedPicker" class="absolute inset-0 z-30" @click="showSpeedPicker = false">
<div class="absolute bg-black/85 backdrop-blur-xl border border-white/10 rounded-2xl p-3 shadow-2xl" :style="speedPopoverStyle" @click.stop>
<p class="text-[10px] font-semibold text-white/50 uppercase tracking-widest px-2 mb-2">{{ t('reader.audiobook.playbackSpeed') }}</p>
<!-- Fine-grained stepper -->
<div class="flex items-center justify-between px-2 mb-2 gap-2">
<button
class="w-7 h-7 flex items-center justify-center rounded-full transition-colors text-white disabled:opacity-30 bg-primary/15 hover:bg-primary/30"
:disabled="settings.playbackSpeed.value <= 0.5"
@click="speedDown"
>
<Minus class="w-3.5 h-3.5" />
</button>
<span class="text-sm font-bold text-white tabular-nums w-10 text-center">{{ settings.playbackSpeed.value }}x</span>
<button
class="w-7 h-7 flex items-center justify-center rounded-full transition-colors text-white disabled:opacity-30 bg-primary/15 hover:bg-primary/30"
:disabled="settings.playbackSpeed.value >= 3.0"
@click="speedUp"
>
<Plus class="w-3.5 h-3.5" />
</button>
</div>
<div class="border-t border-white/10 mb-1.5" />
<!-- Preset buttons -->
<div class="flex flex-col gap-0.5">
<button
v-for="speed in SPEEDS"
:key="speed"
class="text-sm text-left px-3 py-2 rounded-lg transition-colors font-medium"
:class="settings.playbackSpeed.value === speed ? 'bg-primary/25 text-primary font-semibold' : 'hover:bg-white/10 text-white/70'"
@click="selectSpeed(speed)"
>
{{ speed }}x
</button>
</div>
</div>
</div>
</Transition>
<!-- Chapter sheet backdrop -->
<Transition name="fade">
<div v-if="showChapters" class="absolute inset-0 z-20 bg-black/40" @click="showChapters = false" />
</Transition>
<!-- Chapter / Bookmarks sheet (slide up) -->
<Transition name="slide-up">
<div
v-if="showChapters"
class="absolute inset-x-0 bottom-0 z-30 mx-auto bg-black/80 backdrop-blur-2xl rounded-t-2xl max-h-[70vh] flex flex-col border-t border-white/10 md:max-w-lg md:mb-6 md:rounded-2xl md:border"
>
<!-- Sheet header with tabs -->
<div class="flex items-center justify-between px-5 py-4 shrink-0">
<div class="flex gap-1">
<button
class="px-3 py-1.5 rounded-full text-xs font-semibold transition-colors"
:class="chaptersTab === 'chapters' ? 'bg-primary/25 text-primary' : 'text-white/50 hover:text-white/80 hover:bg-white/10'"
@click="chaptersTab = 'chapters'"
>
{{ t('reader.audiobook.chapters') }}
</button>
<button
class="px-3 py-1.5 rounded-full text-xs font-semibold transition-colors"
:class="chaptersTab === 'bookmarks' ? 'bg-primary/25 text-primary' : 'text-white/50 hover:text-white/80 hover:bg-white/10'"
@click="chaptersTab = 'bookmarks'"
>
{{ t('reader.audiobook.bookmarks') }}
</button>
</div>
<button class="p-1.5 hover:bg-white/10 rounded-full transition-colors" @click="showChapters = false">
<X class="w-4 h-4" />
</button>
</div>
<!-- Chapters list -->
<div v-if="chaptersTab === 'chapters'" class="flex-1 overflow-y-auto">
<div v-if="manifest?.chapters.length">
<button
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'"
@click="seekToChapter(chapter)"
>
<span class="block truncate">{{ chapter.title }}</span>
<span class="text-xs text-white/35 tabular-nums">
{{ formatTime(chapter.startMs / 1000) }}
<span v-if="chapterDurations[i]"> &middot; {{ formatTime(chapterDurations[i]) }}</span>
</span>
</button>
</div>
<p v-else class="text-sm text-white/45 px-5 py-6 text-center">{{ t('reader.audiobook.noChapters') }}</p>
</div>
<!-- Bookmarks list -->
<div v-else class="flex-1 overflow-y-auto">
<div v-if="audioBookmarks.bookmarks.value.length">
<div
v-for="bm in audioBookmarks.bookmarks.value"
:key="bm.id"
class="flex items-center gap-3 px-5 py-3 hover:bg-white/10 transition-colors group"
>
<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.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"
@click="deleteBookmark(bm.id)"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<div v-else class="px-5 py-8 text-center">
<p class="text-sm text-white/45 mb-3">{{ t('reader.audiobook.noBookmarks') }}</p>
<p class="text-xs text-white/30">{{ t('reader.audiobook.noBookmarksHint') }}</p>
</div>
</div>
</div>
</Transition>
<!-- Settings sheet backdrop -->
<Transition name="fade">
<div v-if="showSettings" class="absolute inset-0 z-20 bg-black/40" @click="showSettings = false" />
</Transition>
<!-- Settings sheet (slide up) -->
<Transition name="slide-up">
<div
v-if="showSettings"
class="absolute inset-x-0 bottom-0 z-30 mx-auto bg-black/80 backdrop-blur-2xl rounded-t-2xl border-t border-white/10 shadow-2xl p-5 md:max-w-lg md:mb-6 md:rounded-2xl md:border"
>
<div class="flex items-center justify-between mb-5">
<p class="font-semibold text-sm">{{ t('reader.audiobook.playerSettings') }}</p>
<button class="p-1.5 hover:bg-white/10 rounded-full transition-colors" @click="showSettings = false">
<X class="w-4 h-4" />
</button>
</div>
<div class="mb-4">
<p class="text-[10px] font-semibold text-white/45 uppercase tracking-widest mb-2.5">{{ t('reader.audiobook.skipBack') }}</p>
<div class="flex gap-2">
<button
v-for="secs in [5, 10, 15, 30]"
:key="secs"
class="h-8 px-3.5 text-xs rounded-full transition-colors font-medium"
:class="settings.skipBackSeconds.value === secs ? 'bg-white text-black' : 'bg-white/10 text-white/65 hover:bg-white/20'"
@click="() => settings.setSkipBackSeconds(secs)"
>
{{ secs }}s
</button>
</div>
</div>
<div class="mb-4">
<p class="text-[10px] font-semibold text-white/45 uppercase tracking-widest mb-2.5">{{ t('reader.audiobook.skipForward') }}</p>
<div class="flex gap-2">
<button
v-for="secs in [10, 15, 30, 60]"
:key="secs"
class="h-8 px-3.5 text-xs rounded-full transition-colors font-medium"
:class="settings.skipForwardSeconds.value === secs ? 'bg-white text-black' : 'bg-white/10 text-white/65 hover:bg-white/20'"
@click="() => settings.setSkipForwardSeconds(secs)"
>
{{ secs }}s
</button>
</div>
</div>
<div>
<p class="text-[10px] font-semibold text-white/45 uppercase tracking-widest mb-2.5">{{ t('reader.audiobook.volume') }}</p>
<div class="flex items-center gap-3">
<VolumeX v-if="settings.volume.value === 0" class="w-4 h-4 text-white/55 shrink-0" />
<Volume1 v-else-if="settings.volume.value < 0.5" class="w-4 h-4 text-white/55 shrink-0" />
<Volume2 v-else class="w-4 h-4 text-white/55 shrink-0" />
<input
type="range"
min="0"
max="1"
step="0.05"
:value="settings.volume.value"
class="volume-slider flex-1"
:style="volumeTrackStyle"
@input="handleVolumeChange"
/>
<span class="text-xs text-white/45 tabular-nums w-8 text-right">{{ Math.round(settings.volume.value * 100) }}%</span>
</div>
</div>
</div>
</Transition>
</template>
<!-- Skip bubbles -->
<div
v-for="bubble in skipBubbles"
:key="bubble.id"
class="fixed pointer-events-none z-50 select-none skip-bubble"
:style="{ left: bubble.x + 'px', top: bubble.y + 'px' }"
>
{{ bubble.label }}
</div>
</div>
</template>
<style scoped>
/* ── Play button pulse ring ──────────────────────────────── */
@keyframes pulse-ring {
0% {
transform: scale(0.95);
opacity: 0.6;
}
70% {
transform: scale(1.35);
opacity: 0;
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
.play-pulse-ring {
animation: pulse-ring 1.8s ease-out infinite;
}
/* ── Scrubber (custom drag) ───────────────────────────────── */
.scrubber-rail {
cursor: pointer;
touch-action: none;
user-select: none;
}
.scrubber-thumb {
width: 14px;
height: 14px;
box-shadow:
0 0 8px rgba(255, 255, 255, 0.5),
0 2px 4px rgba(0, 0, 0, 0.4);
opacity: 0;
transition:
opacity 0.15s,
width 0.1s,
height 0.1s;
}
.scrubber-rail:hover .scrubber-thumb,
.scrubber-thumb-dragging {
opacity: 1;
}
.scrubber-thumb-dragging {
width: 18px !important;
height: 18px !important;
box-shadow:
0 0 12px rgba(255, 255, 255, 0.7),
0 2px 6px rgba(0, 0, 0, 0.5) !important;
}
@media (hover: none) {
.scrubber-thumb {
opacity: 1;
}
}
/* ── Skip bubble animation ────────────────────────────────── */
@keyframes skip-fly {
0% {
opacity: 1;
transform: translate(-50%, -100%);
}
100% {
opacity: 0;
transform: translate(-50%, calc(-100% - 40px));
}
}
.skip-bubble {
animation: skip-fly 0.6s ease-out forwards;
font-size: 0.8rem;
font-weight: 700;
color: white;
text-shadow:
0 1px 4px rgba(0, 0, 0, 0.6),
0 0 8px rgba(0, 0, 0, 0.4);
white-space: nowrap;
}
/* ── Volume slider ────────────────────────────────────────── */
.volume-slider {
-webkit-appearance: none;
appearance: none;
background: transparent;
height: 20px;
cursor: pointer;
}
.volume-slider::-webkit-slider-runnable-track {
height: 3px;
background: linear-gradient(to right, white var(--volume-pct, 50%), rgba(255, 255, 255, 0.25) var(--volume-pct, 50%));
border-radius: 9999px;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
margin-top: -4.5px;
cursor: pointer;
}
.volume-slider::-moz-range-progress {
background: white;
height: 3px;
border-radius: 9999px;
}
.volume-slider::-moz-range-track {
height: 3px;
background: rgba(255, 255, 255, 0.25);
border-radius: 9999px;
}
.volume-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: white;
border: none;
cursor: pointer;
}
/* ── Transitions ─────────────────────────────────────────── */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>