feat(reader): support peeking books without tracking progress

Implement a 'peek mode' that allows users to open and read books without updating their reading progress or creating reading sessions. Add a 'Peeking' banner in the reader and disable API calls in 'useReaderProgress' and 'useReadingSession' when tracking is disabled.

Closes #161
This commit is contained in:
neon
2026-06-02 05:34:05 +00:00
parent 3a29ac7679
commit 383506fc07
18 changed files with 429 additions and 110 deletions
@@ -8,6 +8,7 @@ import {
BookOpen,
Check,
Download,
Eye,
ExternalLink,
FolderPlus,
Image,
@@ -181,14 +182,19 @@ function handleArtworkError() {
coverImageRatio.value = null
}
function openFile(file: BookFileRef) {
function openFile(file: BookFileRef, mode?: 'peek') {
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: file.id },
query: { format: file.format ?? 'epub' },
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
})
}
function peekPrimaryFile() {
if (!primaryFile.value || isMissing.value) return
openFile(primaryFile.value, 'peek')
}
function openBookDetails() {
void router.push({ name: 'book-detail', params: { bookId: props.book.id } })
}
@@ -534,12 +540,12 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
Open
Read
</DropdownMenuItem>
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
<DropdownMenuSubTrigger>
<BookOpen class="size-4 mr-2" />
Open
Read
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
@@ -549,6 +555,10 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem v-if="primaryFile && !isMissing" @click="peekPrimaryFile">
<Eye class="size-4 mr-2" />
Peek
</DropdownMenuItem>
<!-- Download submenu -->
<DropdownMenuItem
@@ -665,12 +675,12 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
<DropdownMenuContent align="end">
<DropdownMenuItem v-if="openableFiles.length <= 1 && primaryFile && !isMissing" @click="openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
Open
Read
</DropdownMenuItem>
<DropdownMenuSub v-else-if="openableFiles.length > 1 && !isMissing">
<DropdownMenuSubTrigger>
<BookOpen class="size-4 mr-2" />
Open
Read
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="openFile(file)">
@@ -680,6 +690,10 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem v-if="primaryFile && !isMissing" @click="peekPrimaryFile">
<Eye class="size-4 mr-2" />
Peek
</DropdownMenuItem>
<DropdownMenuItem
v-if="hasPermission('library_download') && openableFiles.length === 1 && primaryFile"
@@ -9,6 +9,7 @@ import { useRouter } from 'vue-router'
import {
BookOpen,
Check,
Eye,
ExternalLink,
FolderPlus,
Loader2,
@@ -98,14 +99,19 @@ const coverSrc = computed(() => coverUrl(props.book.id))
const { refreshing, refreshWithFeedback } = useRefreshMetadata()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
function openFile(file: BookFileRef) {
function openFile(file: BookFileRef, mode?: 'peek') {
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: file.id },
query: { format: file.format ?? 'epub' },
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
})
}
function peekPrimaryFile() {
if (!primaryFile.value || isMissing.value) return
openFile(primaryFile.value, 'peek')
}
function openAuthorBrowse() {
if (!authorQuery.value) return
void router.push({ name: 'authors', query: { q: authorQuery.value } })
@@ -229,7 +235,11 @@ function openAuthorBrowse() {
<DropdownMenuContent align="end">
<DropdownMenuItem :disabled="!primaryFile || isMissing" @click="primaryFile && !isMissing && openFile(primaryFile)">
<BookOpen class="size-4 mr-2" />
Open
Read
</DropdownMenuItem>
<DropdownMenuItem :disabled="!primaryFile || isMissing" @click="peekPrimaryFile">
<Eye class="size-4 mr-2" />
Peek
</DropdownMenuItem>
<DropdownMenuItem @click="emit('action', 'quick-view')">
<PanelRight class="size-4 mr-2" />
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from 'lucide-vue-next'
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from 'lucide-vue-next'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
@@ -195,16 +195,24 @@ function handleCoverClick() {
if (coverLoaded.value && !coverFailed.value) coverLightboxOpen.value = true
}
function openBook() {
function openBookWithMode(mode?: 'peek') {
if (!primaryFile.value || !detail.value) return
router.push({
name: 'reader',
params: { bookId: detail.value.id, fileId: primaryFile.value.id },
query: { format: primaryFile.value.format ?? 'epub' },
query: mode === 'peek' ? { format: primaryFile.value.format ?? 'epub', mode } : { format: primaryFile.value.format ?? 'epub' },
})
emit('update:open', false)
}
function openBook() {
openBookWithMode()
}
function peekBook() {
openBookWithMode('peek')
}
function editMetadata() {
if (!detail.value) return
router.push({ name: 'book-detail', params: { bookId: detail.value.id }, query: { tab: 'edit' } })
@@ -455,6 +463,15 @@ function handleDelete() {
<BookOpen v-else class="size-4" />
<span class="hidden sm:inline">{{ isPrimaryAudio ? 'Listen' : 'Read' }}</span>
</button>
<button
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md border border-input bg-background text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
:disabled="!primaryFile"
aria-label="Peek"
@click="peekBook"
>
<Eye class="size-4" />
<span class="hidden sm:inline">Peek</span>
</button>
<button
class="flex flex-1 items-center justify-center text-primary-foreground gap-2 h-9 rounded-md bg-sky-600 text-sm font-medium hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-600 transition-colors"
aria-label="Details"
@@ -272,6 +272,19 @@ describe('BookTableReadButtonCell', () => {
})
})
it('opens the primary readable file in peek mode from the peek button', async () => {
routerPush.mockReset()
const wrapper = mountReadButton(makeBook([{ id: 1, format: 'epub', role: 'primary', sizeBytes: null }]))
const peekButton = wrapper.findAll('button').find((button) => button.attributes('aria-label') === 'Peek EPUB')
expect(peekButton).toBeTruthy()
await peekButton!.trigger('click')
expect(routerPush).toHaveBeenCalledWith({
name: 'reader',
params: { bookId: 42, fileId: 1 },
query: { format: 'epub', mode: 'peek' },
})
})
it('shows play action for audio formats and allows selecting alternate format', async () => {
routerPush.mockReset()
const wrapper = mountReadButton(
@@ -5,6 +5,7 @@ import {
BookOpen,
Check,
ChevronDown,
Eye,
Library,
Headphones,
Lock,
@@ -749,22 +750,30 @@ function openEditCover() {
router.push({ name: 'book-detail', params: { bookId: props.book.id }, query: { tab: 'edit' } })
}
function openBook() {
function openBookWithMode(mode?: 'peek') {
if (!primaryFile.value) return
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: primaryFile.value.id },
query: { format: primaryFile.value.format ?? 'epub' },
query: mode === 'peek' ? { format: primaryFile.value.format ?? 'epub', mode } : { format: primaryFile.value.format ?? 'epub' },
})
}
function openBookFile(file: BookDetail['files'][number]) {
function openBook() {
openBookWithMode()
}
function peekBook() {
openBookWithMode('peek')
}
function openBookFile(file: BookDetail['files'][number], mode?: 'peek') {
readMenuOpen.value = false
mobileReadMenuOpen.value = false
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: file.id },
query: { format: file.format ?? 'epub' },
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
})
}
@@ -1075,6 +1084,19 @@ watch(
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
</button>
<Tooltip>
<TooltipTrigger as-child>
<button
class="flex items-center justify-center h-9 w-12 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
:disabled="!primaryFile"
aria-label="Peek"
@click="peekBook"
>
<Eye class="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Peek</TooltipContent>
</Tooltip>
<div v-if="hasPermission('library_download')" class="w-12 shrink-0">
<BookDownloadButton :files="book.files" :book-id="book.id" />
</div>
@@ -1143,58 +1165,74 @@ watch(
</BookCoverSurface>
<div class="mt-4 space-y-2">
<!-- Read/Play button: split when multiple files, plain when single -->
<div v-if="hasMultipleFiles" class="flex w-full h-9 rounded-md overflow-hidden">
<div class="flex gap-2">
<!-- Read/Play button: split when multiple files, plain when single -->
<div v-if="hasMultipleFiles" class="flex flex-1 h-9 rounded-md overflow-hidden">
<button
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<BookOpen v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
</button>
<div class="w-px bg-primary-foreground/20 shrink-0" />
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
<PopoverTrigger as-child>
<button
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
title="Choose format"
>
<ChevronDown class="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent class="w-52 p-1" align="end">
<button
v-for="file in openableFiles"
:key="file.id"
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
@click="openBookFile(file)"
>
<span
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
:style="formatBadgeStyle(file.format ?? '?')"
>{{ file.format ?? '?' }}</span
>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</template>
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">Primary</span>
</button>
</PopoverContent>
</Popover>
</div>
<button
class="flex flex-1 items-center justify-center gap-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
v-else
class="flex flex-1 items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<BookOpen v-if="isPrimaryAudio" class="size-4" />
<Headphones v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
</button>
<div class="w-px bg-primary-foreground/20 shrink-0" />
<Popover :open="readMenuOpen" @update:open="(v) => (readMenuOpen = v)">
<PopoverTrigger as-child>
<Tooltip>
<TooltipTrigger as-child>
<button
class="w-8 shrink-0 flex items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
title="Choose format"
class="flex items-center justify-center h-9 w-12 shrink-0 rounded-md border border-input bg-background hover:bg-muted transition-colors disabled:opacity-50"
:disabled="!primaryFile"
aria-label="Peek"
@click="peekBook"
>
<ChevronDown class="size-3.5" />
<Eye class="size-4" />
</button>
</PopoverTrigger>
<PopoverContent class="w-52 p-1" align="end">
<button
v-for="file in openableFiles"
:key="file.id"
class="flex w-full items-center gap-2.5 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors"
@click="openBookFile(file)"
>
<span
class="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded border shrink-0"
:style="formatBadgeStyle(file.format ?? '?')"
>{{ file.format ?? '?' }}</span
>
<span class="flex-1 text-left text-muted-foreground text-xs truncate">
<template v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</template>
<template v-else>{{ formatFileSize(file.sizeBytes) }}</template>
</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary font-medium shrink-0">Primary</span>
</button>
</PopoverContent>
</Popover>
</TooltipTrigger>
<TooltipContent>Peek</TooltipContent>
</Tooltip>
</div>
<button
v-else
class="flex w-full items-center justify-center gap-2 h-9 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="!primaryFile"
@click="openBook"
>
<Headphones v-if="isPrimaryAudio" class="size-4" />
<BookOpen v-else class="size-4" />
{{ isPrimaryAudio ? 'Listen' : 'Read' }}
</button>
<div class="flex gap-2">
<div v-if="hasPermission('library_download')" class="flex-1">
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, Download, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
import { BookOpen, Download, Eye, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
import { READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -102,11 +102,11 @@ function formatRelative(iso: string): string {
return `${d}d ago`
}
function openFile(file: BookDetailFile) {
function openFile(file: BookDetailFile, mode?: 'peek') {
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: file.id },
query: { format: file.format ?? 'epub' },
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
})
}
@@ -299,6 +299,14 @@ async function toggleWriteLog() {
<Headphones class="size-3.5" />
Play
</button>
<button
v-if="READER_OPENABLE_FORMATS.has(file.format ?? '')"
class="flex items-center gap-1.5 h-7 px-2.5 rounded border border-input bg-background text-xs font-medium hover:bg-muted transition-colors"
@click="openFile(file, 'peek')"
>
<Eye class="size-3.5" />
Peek
</button>
<Tooltip v-if="hasPermission('library_download')">
<TooltipTrigger as-child>
<button
@@ -331,6 +339,10 @@ async function toggleWriteLog() {
<Headphones class="mr-2 size-4" />
Play
</DropdownMenuItem>
<DropdownMenuItem v-if="READER_OPENABLE_FORMATS.has(file.format ?? '')" @click="openFile(file, 'peek')">
<Eye class="mr-2 size-4" />
Peek
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_download')" @click="downloadFile(file)">
<Download class="mr-2 size-4" />
Download
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ChevronDown, Play } from 'lucide-vue-next'
import { BookOpen, ChevronDown, Eye, Play } from 'lucide-vue-next'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { BookCard, BookFileRef } from '@bookorbit/types'
@@ -87,14 +87,22 @@ function formatBadgeStyle(format: string) {
}
}
function openFile(file: BookFileRef | null) {
function openFile(file: BookFileRef | null, mode?: 'peek') {
if (!file || props.book.status === 'missing') return
router.push({
name: 'reader',
params: { bookId: props.book.id, fileId: file.id },
query: { format: file.format ?? 'epub' },
query: mode === 'peek' ? { format: file.format ?? 'epub', mode } : { format: file.format ?? 'epub' },
})
}
function openPrimaryFile() {
openFile(primaryFile.value)
}
function peekPrimaryFile() {
openFile(primaryFile.value, 'peek')
}
</script>
<template>
@@ -105,7 +113,7 @@ function openFile(file: BookFileRef | null) {
class="inline-flex h-7 w-7 items-center justify-center rounded-l-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
:aria-label="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
:title="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
@click.stop="openFile(primaryFile)"
@click.stop="openPrimaryFile"
>
<Play v-if="primaryIsAudio" :size="13" class="text-sky-500" />
<BookOpen v-else :size="13" class="text-emerald-500" />
@@ -133,21 +141,36 @@ function openFile(file: BookFileRef | null) {
<span class="flex-1 truncate text-xs">{{ actionVerb(file) }} {{ formatLabel(file) }}</span>
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="text-[10px] text-primary">Primary</span>
</DropdownMenuItem>
<DropdownMenuItem v-for="file in openableFiles" :key="`peek-${file.id}`" class="gap-2" @select="openFile(file, 'peek')">
<Eye :size="13" class="text-primary" />
<span class="flex-1 truncate text-xs">Peek {{ formatLabel(file) }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<button
v-else
type="button"
class="mr-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
:aria-label="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
:title="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
@click.stop="openFile(primaryFile)"
>
<Play v-if="isAudioFile(primaryFile)" :size="13" class="text-sky-500" />
<BookOpen v-else :size="13" class="text-emerald-500" />
</button>
<div v-else class="mr-auto inline-flex h-7 items-center overflow-hidden rounded-md">
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-l-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
:aria-label="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
:title="`${actionVerb(primaryFile)} ${formatLabel(primaryFile)}`"
@click.stop="openPrimaryFile"
>
<Play v-if="isAudioFile(primaryFile)" :size="13" class="text-sky-500" />
<BookOpen v-else :size="13" class="text-emerald-500" />
</button>
<div class="w-px shrink-0 bg-border/80" />
<button
type="button"
class="inline-flex h-7 w-7 items-center justify-center rounded-r-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
:aria-label="`Peek ${formatLabel(primaryFile)}`"
:title="`Peek ${formatLabel(primaryFile)}`"
@click.stop="peekPrimaryFile"
>
<Eye :size="13" class="text-primary" />
</button>
</div>
</div>
<span v-else class="text-xs text-muted-foreground/40">-</span>
+29 -9
View File
@@ -43,6 +43,11 @@ const fileFormat = (route.query.format as string) || 'epub'
const isAudioFormat = getFormatGroup(fileFormat) === 'audio'
const isPdfFormat = fileFormat === 'pdf'
const isComicFormat = fileFormat === 'cbz' || fileFormat === 'cbr' || fileFormat === 'cb7'
const isPeekMode = computed(() => {
const mode = route.query.mode
return (Array.isArray(mode) ? mode[0] : mode) === 'peek'
})
const trackingEnabled = computed(() => !isPeekMode.value)
const containerRef = ref<HTMLElement | null>(null)
const showSidebar = ref(false)
@@ -82,13 +87,17 @@ const {
const customFonts = useCustomFonts()
const { onActivity, elapsedMinutes } = useReadingSession(fileId, () => ({
percentage: progress.percentage.value,
cfi: progress.cfi.value,
pageNumber: progress.pageNumber.value,
}))
const { onActivity, elapsedMinutes } = useReadingSession(
fileId,
() => ({
percentage: progress.percentage.value,
cfi: progress.cfi.value,
pageNumber: progress.pageNumber.value,
}),
{ trackingEnabled },
)
const progress = useReaderProgress(bookId, fileId, elapsedMinutes)
const progress = useReaderProgress(bookId, fileId, elapsedMinutes, 0, { trackingEnabled })
const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal, footerMode, cycleFooterMode, updateHeadsFeet } = progress
const visibility = useVisibility()
@@ -140,6 +149,15 @@ function toggleHelpModal() {
showHelpModal.value = !showHelpModal.value
}
async function startTrackedReading() {
const query = { ...route.query }
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
await progress.save()
onActivity()
}
const chapterStartFraction = computed(() => {
const fracs = sectionFractions.value
const idx = sectionIndex.value
@@ -498,9 +516,9 @@ watch(
</script>
<template>
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" />
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" />
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" />
<PdfV4ReaderView v-if="isPdfFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
<CbzReaderView v-else-if="isComicFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
<AudiobookReaderView v-else-if="isAudioFormat" :bookId="bookId" :fileId="fileId" :peek-mode="isPeekMode" />
<div
v-else
class="fixed inset-0 overflow-hidden"
@@ -513,6 +531,7 @@ watch(
:isBookmarked="bookmarks.isCurrentCfiBookmarked.value"
:settings-open="showSettings"
:footerMode="footerMode"
:peek-mode="isPeekMode"
class="transition-all duration-300"
:class="headerVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-full pointer-events-none'"
@back="router.back()"
@@ -523,6 +542,7 @@ watch(
@toggleFullscreen="toggleFullscreen"
@toggleHelp="toggleHelpModal"
@cycleFooterMode="cycleFooterMode"
@startReading="startTrackedReading"
>
<template #settingsPanel>
<ReaderSettingsPanel :state="state" :customFonts="customFonts" @update="applyUpdate" />
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, type WatchStopHandle } from 'vue'
import { useRouter } from 'vue-router'
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type WatchStopHandle } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Bookmark,
BookmarkCheck,
@@ -33,8 +33,10 @@ import { useAudioSettings } from './composables/useAudioSettings'
import { useAudioBookmarks, type AudioBookmark } from './composables/useAudioBookmarks'
import { useReadingSession } from '../shared/composables/useReadingSession'
const props = defineProps<{ bookId: number; fileId: number }>()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const detail = ref<BookDetail | null>(null)
const loading = ref(true)
@@ -65,7 +67,7 @@ const audioFiles = computed<BookDetailFile[]>(() => {
// ── Progress ──────────────────────────────────────────────────────────────────
const progress = useAudioProgress(props.bookId)
const progress = useAudioProgress(props.bookId, { trackingEnabled })
// ── Queue (created lazily after files load) ───────────────────────────────────
@@ -139,7 +141,7 @@ const audioBookmarks = useAudioBookmarks(props.bookId)
// ── Reading session ───────────────────────────────────────────────────────────
const session = useReadingSession(props.fileId, () => ({ percentage: progressPct.value }))
const session = useReadingSession(props.fileId, () => ({ percentage: progressPct.value }), { trackingEnabled })
// ── Ticker (updates position every 500ms while playing) ──────────────────────
@@ -560,6 +562,19 @@ 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 fileId = audioFiles.value[currentFileIndex.value]?.id
if (fileId) {
progress.update(fileId, currentPosition.value, progressPct.value)
progress.flush()
}
session.onActivity()
}
// ── Computed helpers ──────────────────────────────────────────────────────────
const absolutePositionSeconds = computed(() => {
@@ -776,6 +791,12 @@ onMounted(async () => {
{{ detail.audioMetadata.narrators.map((n) => n.name).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">Peeking</span>
<button class="h-6 rounded-sm bg-white px-2 text-[11px] font-semibold text-black hover:bg-white/90" @click="startTrackedReading">
Start reading
</button>
</div>
<!-- Bookmark toggle -->
<button
class="p-2 rounded-full hover:bg-white/10 transition-colors"
@@ -1,12 +1,17 @@
import { onUnmounted, ref } from 'vue'
import { onUnmounted, ref, unref, type MaybeRef } from 'vue'
import { api } from '@/lib/api'
const SAVE_THROTTLE_MS = 5_000
export function useAudioProgress(bookId: number) {
export interface AudioProgressOptions {
trackingEnabled?: MaybeRef<boolean>
}
export function useAudioProgress(bookId: number, options: AudioProgressOptions = {}) {
const resumeFileId = ref<number | null>(null)
const resumePosition = ref(0)
const loaded = ref(false)
const trackingEnabled = options.trackingEnabled ?? true
let pendingFileId: number | null = null
let pendingPosition = 0
@@ -15,6 +20,10 @@ export function useAudioProgress(bookId: number) {
let dirty = false
async function load() {
if (!unref(trackingEnabled)) {
loaded.value = true
return
}
const res = await api(`/api/v1/books/${bookId}/audio-progress`)
// Mark loaded regardless of response so callers can distinguish
// "load attempted" from "load not yet called".
@@ -28,6 +37,7 @@ export function useAudioProgress(bookId: number) {
}
function update(fileId: number, positionSeconds: number, percentage: number) {
if (!unref(trackingEnabled)) return
pendingFileId = fileId
pendingPosition = positionSeconds
pendingPercentage = percentage
@@ -42,6 +52,7 @@ export function useAudioProgress(bookId: number) {
}
function flushIfDirty() {
if (!unref(trackingEnabled)) return
if (!dirty || pendingFileId === null) return
const body = JSON.stringify({
percentage: pendingPercentage,
@@ -59,6 +70,7 @@ export function useAudioProgress(bookId: number) {
}
function flush() {
if (!unref(trackingEnabled)) return
if (saveTimer) {
clearTimeout(saveTimer)
saveTimer = null
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import {
AlignJustify,
ArrowDownUp,
@@ -37,16 +37,22 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/compon
const TWO_PAGE_BREAKPOINT = 900
const props = defineProps<{ bookId: number; fileId: number }>()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = useVisibility()
const { onActivity, elapsedMinutes } = useReadingSession(props.fileId, () => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}))
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes)
const { onActivity, elapsedMinutes } = useReadingSession(
props.fileId,
() => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}),
{ trackingEnabled },
)
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
const { pageCount, bookTitle, loading, error, pageUrl, load } = useCbz(props.fileId, props.bookId)
const { fitMode, viewMode, scrollMode, direction, spreadAlignment, forceTwoPage, widePageSingletonMode, bgColor, bgValue, imgFitClass } =
useCbzSettings()
@@ -537,6 +543,17 @@ function onResize() {
viewportWidth.value = window.innerWidth
}
async function startTrackedReading() {
const query = { ...route.query }
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
progress.pageNumber.value = progressPageIndex.value + 1
progress.percentage.value = progressPercent.value
await progress.save()
onActivity()
}
// ── Mount / unmount ────────────────────────────────────────────────────────────
onMounted(async () => {
viewportWidth.value = window.innerWidth
@@ -595,6 +612,15 @@ onUnmounted(() => {
<span v-if="bookTitle" class="text-sm font-serif text-foreground truncate leading-tight">{{ bookTitle }}</span>
<span class="text-xs text-muted-foreground tabular-nums">{{ pageLabel }}</span>
</div>
<div v-if="props.peekMode" class="flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-1.5 text-primary">
<span class="hidden text-[11px] font-medium sm:inline">Peeking</span>
<button
class="h-5 rounded-sm bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground hover:bg-primary/90"
@click="startTrackedReading"
>
Start reading
</button>
</div>
<DropdownMenu v-model:open="showSettings">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''">
@@ -22,6 +22,7 @@ const props = defineProps<{
isBookmarked: boolean
settingsOpen: boolean
footerMode: 0 | 1 | 2
peekMode?: boolean
}>()
const emit = defineEmits<{
@@ -33,6 +34,7 @@ const emit = defineEmits<{
toggleFullscreen: []
toggleHelp: []
cycleFooterMode: []
startReading: []
}>()
const isFullscreen = ref(false)
@@ -105,6 +107,16 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
<!-- Right button group -->
<div class="flex items-center gap-1 shrink-0 ml-auto">
<div v-if="props.peekMode" class="flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-1.5 text-primary">
<span class="hidden text-[11px] font-medium sm:inline">Peeking</span>
<button
class="h-5 rounded-sm bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:h-6 sm:px-2 sm:text-[11px]"
@click="emit('startReading')"
>
Start reading
</button>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" aria-label="Search" @click="emit('toggleSearch')">
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import ReaderSidebar from '../ReaderSidebar.vue'
describe('ReaderSidebar', () => {
const scrollIntoView = vi.fn()
const scrollIntoView = vi.fn<() => void>()
beforeEach(() => {
scrollIntoView.mockClear()
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { PDFViewer, ScrollPlugin, ScrollStrategy, SpreadMode, ZoomMode } from '@embedpdf/vue-pdf-viewer'
import type { PDFViewerConfig, PluginRegistry, ScrollCapability, EmbedPdfContainer } from '@embedpdf/vue-pdf-viewer'
import { getAccessToken } from '@/lib/api'
@@ -10,16 +11,23 @@ import { useThemeStore, ACCENT_OPTIONS } from '@/stores/theme'
import type { PdfReaderSettings } from '@bookorbit/types'
import { getIsDark, lookupAccentHex } from './pdf-viewer-utils'
const props = defineProps<{ bookId: number; fileId: number }>()
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const themeStore = useThemeStore()
const bookSettings = useReaderSettings(props.fileId, 'pdf')
const { onActivity, elapsedMinutes } = useReadingSession(props.fileId, () => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}))
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes)
const { onActivity, elapsedMinutes } = useReadingSession(
props.fileId,
() => ({
percentage: progress.percentage.value,
pageNumber: progress.pageNumber.value,
}),
{ trackingEnabled },
)
const progress = useReaderProgress(props.bookId, props.fileId, elapsedMinutes, 0, { trackingEnabled })
let saveTimer: ReturnType<typeof setTimeout> | null = null
let unsubPageChange: (() => void) | null = null
@@ -38,6 +46,8 @@ function buildThemeConfig() {
}
function scheduleSave(pageNumber: number, totalPages: number) {
currentPageNumber.value = pageNumber
totalPageCount.value = totalPages
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
progress.pageNumber.value = pageNumber
@@ -48,6 +58,8 @@ function scheduleSave(pageNumber: number, totalPages: number) {
const configReady = ref(false)
const viewerConfig = ref<PDFViewerConfig>({})
const currentPageNumber = ref<number | null>(null)
const totalPageCount = ref<number | null>(null)
onMounted(async () => {
await bookSettings.load()
@@ -116,6 +128,8 @@ async function setupProgressTracking(registry: PluginRegistry) {
unsubLayoutReady = scroll.onLayoutReady(({ isInitial, totalPages }) => {
if (!isInitial) return
const initialPage = progress.pageNumber.value ?? 1
currentPageNumber.value = initialPage
totalPageCount.value = totalPages
if (initialPage > 1 && initialPage <= totalPages) {
scroll.scrollToPage({ pageNumber: initialPage })
}
@@ -152,6 +166,19 @@ function setupThemeSync() {
})
}
async function startTrackedReading() {
const query = { ...route.query }
delete query.mode
await router.replace({ name: 'reader', params: route.params, query })
await nextTick()
if (currentPageNumber.value !== null) {
progress.pageNumber.value = currentPageNumber.value
progress.percentage.value = totalPageCount.value ? (currentPageNumber.value / totalPageCount.value) * 100 : progress.percentage.value
}
await progress.save()
onActivity()
}
onUnmounted(() => {
unsubPageChange?.()
unsubLayoutReady?.()
@@ -162,6 +189,17 @@ onUnmounted(() => {
<template>
<div class="fixed inset-0 flex flex-col">
<div v-if="props.peekMode" class="pointer-events-none absolute left-0 right-0 top-3 z-50 flex justify-center">
<div class="pointer-events-auto flex h-8 items-center gap-2 rounded-md border border-primary/30 bg-background/95 px-2 text-primary shadow-sm">
<span class="text-xs font-medium">Peeking</span>
<button
class="h-6 rounded-sm bg-primary px-2 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
@click="startTrackedReading"
>
Start reading
</button>
</div>
</div>
<PDFViewer v-if="configReady" :config="viewerConfig" style="flex: 1; min-height: 0" @init="handleInit" @ready="handleReady" />
</div>
</template>
@@ -193,4 +193,20 @@ describe('useReaderProgress', () => {
vi.useRealTimers()
})
it('keeps relocate state in memory without loading or saving when tracking is disabled', async () => {
vi.useFakeTimers()
const trackingEnabled = ref(false)
const progress = useReaderProgress(1, 1, elapsedMinutes, 0, { trackingEnabled })
await progress.load()
progress.onRelocate(makeDetail({ fraction: 0.42 }))
await progress.save()
await vi.advanceTimersByTimeAsync(2500)
expect(progress.percentage.value).toBe(42)
expect(apiMock).not.toHaveBeenCalled()
vi.useRealTimers()
})
})
@@ -1,4 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { ref } from 'vue'
import { useReadingSession } from '../useReadingSession'
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<unknown>>())
@@ -94,4 +95,16 @@ describe('useReadingSession - elapsedMinutes', () => {
onActivity()
expect(elapsedMinutes.value).toBe(0)
})
it('does not track elapsed time or save sessions when tracking is disabled', async () => {
const trackingEnabled = ref(false)
const { onActivity, elapsedMinutes, endSession } = useReadingSession(1, () => ({ percentage: 10 }), { trackingEnabled })
onActivity()
await vi.advanceTimersByTimeAsync(2 * 60 * 1000)
endSession()
expect(elapsedMinutes.value).toBe(0)
expect(apiMock).not.toHaveBeenCalled()
})
})
@@ -1,9 +1,13 @@
import { onUnmounted, ref, type Ref } from 'vue'
import { onUnmounted, ref, unref, type MaybeRef, type Ref } from 'vue'
import { api } from '@/lib/api'
import type { FoliateRenderer, RelocateDetail } from '../../epub/composables/useFoliate'
export type FooterDisplayMode = 0 | 1 | 2
export interface ReaderProgressOptions {
trackingEnabled?: MaybeRef<boolean>
}
export function formatTimeRemaining(minutes: number): string {
if (!Number.isFinite(minutes) || minutes < 0) return ''
if (minutes < 1) return '< 1 min'
@@ -14,7 +18,13 @@ export function formatTimeRemaining(minutes: number): string {
return `${hours} hr ${remainder} min`
}
export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes: Ref<number>, initialFooterMode: FooterDisplayMode = 0) {
export function useReaderProgress(
bookId: number,
fileId: number,
elapsedMinutes: Ref<number>,
initialFooterMode: FooterDisplayMode = 0,
options: ReaderProgressOptions = {},
) {
const cfi = ref<string | null>(null)
const pageNumber = ref<number | null>(null)
const percentage = ref(0)
@@ -31,6 +41,7 @@ export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes
const timeTotal = ref(0)
const footerMode = ref<FooterDisplayMode>(initialFooterMode)
const trackingEnabled = options.trackingEnabled ?? true
let saveTimer: ReturnType<typeof setTimeout> | null = null
@@ -39,6 +50,7 @@ export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes
})
async function load() {
if (!unref(trackingEnabled)) return
const res = await api(`/api/v1/books/files/${fileId}/progress`)
if (!res.ok) return
const data = await res.json()
@@ -63,10 +75,12 @@ export function useReaderProgress(bookId: number, fileId: number, elapsedMinutes
timeTotal.value = detail?.time?.total ?? 0
if (saveTimer) clearTimeout(saveTimer)
if (!unref(trackingEnabled)) return
saveTimer = setTimeout(() => save(), 2000)
}
async function save() {
if (!unref(trackingEnabled)) return
await api(`/api/v1/books/files/${fileId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1,4 +1,4 @@
import { onUnmounted, ref } from 'vue'
import { onUnmounted, ref, unref, type MaybeRef } from 'vue'
import { api } from '@/lib/api'
export interface ProgressSnapshot {
@@ -11,13 +11,17 @@ const IDLE_TIMEOUT_MS = 5 * 60 * 1000
const MIN_SESSION_MS = 10 * 1000
const ELAPSED_UPDATE_INTERVAL_MS = 30 * 1000
export interface ReadingSessionOptions {
trackingEnabled?: MaybeRef<boolean>
}
function generateSessionId(): string {
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `session-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` // codeql[js/insecure-randomness] - session IDs are non-security deduplication keys
}
export function useReadingSession(bookFileId: number, getProgress: () => ProgressSnapshot) {
export function useReadingSession(bookFileId: number, getProgress: () => ProgressSnapshot, options: ReadingSessionOptions = {}) {
let sessionId = generateSessionId()
let startedAt: Date | null = null
let activeMs = 0
@@ -28,6 +32,11 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
const elapsedMinutes = ref(0)
let elapsedInterval: ReturnType<typeof setInterval> | null = null
const trackingEnabled = options.trackingEnabled ?? true
function canTrack(): boolean {
return unref(trackingEnabled)
}
function getActiveMs(): number {
if (activeStart === null) return activeMs
@@ -92,6 +101,7 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
}
function onActivity() {
if (!canTrack()) return
// No active session or previous session ended (e.g. after idle timeout) - start fresh.
if (!startedAt || ended) {
sessionId = generateSessionId()
@@ -103,6 +113,16 @@ export function useReadingSession(bookFileId: number, getProgress: () => Progres
}
function endSession(useBeacon = false) {
if (!canTrack()) {
clearIdleTimer()
stopElapsedInterval()
startedAt = null
activeStart = null
activeMs = 0
ended = true
elapsedMinutes.value = 0
return
}
if (ended || !startedAt) return
ended = true
clearIdleTimer()