mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
a52d005ff5
Refresh the web access cookie after a media load failure and resume from the last observed position. Limit retries so unrelated media errors surface promptly. Closes #1536
1485 lines
56 KiB
Vue
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]"> · {{ 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>
|