feat(reader): support fullscreen mode in all readers

Introduce a shared useFullscreen composable to unify fullscreen state and
toggling across all file format readers. Add fullscreen toggle buttons to
the PDF, CBZ (comic), and audiobook readers, and clean up the legacy inline
fullscreen logic in the EPUB reader.

Closes #561
This commit is contained in:
neon
2026-07-07 15:52:58 +00:00
parent efd3c9f487
commit a49686d2ba
8 changed files with 169 additions and 41 deletions
+3 -16
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { useFoliate, type RelocateDetail } from './epub/composables/useFoliate'
@@ -11,6 +11,7 @@ import { useReaderSettings } from './shared/composables/useReaderSettings'
import { useCustomFonts } from './epub/composables/useCustomFonts'
import { useVisibility } from './shared/composables/useVisibility'
import { useWakeLock } from './shared/composables/useWakeLock'
import { useFullscreen } from './shared/composables/useFullscreen'
import { useBookmarks } from './epub/composables/useBookmarks'
import { useAnnotations } from './epub/composables/useAnnotations'
import { useReaderAnnotationActions } from './epub/composables/useReaderAnnotationActions'
@@ -58,7 +59,6 @@ const showSidebar = ref(false)
const showSettings = ref(false)
const showSearch = ref(false)
const searchInitialQuery = ref('')
const isFullscreen = ref(false)
const sectionFractions = ref<number[]>([])
const sidebarLocationMetaByCfi = ref<Record<string, { chapterTitle: string | null; percentage: number | null }>>({})
let sidebarLocationResolveSeq = 0
@@ -107,6 +107,7 @@ const { cfi, chapterTitle, sectionIndex, totalSections, fraction, locationTotal,
const visibility = useVisibility()
const { headerVisible, footerVisible, handleMiddleTap, setVisibilityLock } = visibility
const { toggleFullscreen } = useFullscreen()
useWakeLock()
@@ -275,12 +276,6 @@ setTextSelectedHandler(handleTextSelected)
setAnnotationClickHandler(handleAnnotationClick)
onMounted(async () => {
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement
}
document.addEventListener('fullscreenchange', onFullscreenChange)
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
// Specialized readers own their own progress/settings/loading lifecycle.
if (isAudioFormat || isPdfFormat || isComicFormat) return
@@ -381,14 +376,6 @@ async function applyUpdate(partial: Partial<ReaderState>) {
}
}
function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen?.()
} else {
document.documentElement.requestFullscreen?.()
}
}
watch(
() => footerMode.value,
(mode) => {
@@ -9,6 +9,8 @@ import {
ChevronLeft,
ChevronRight,
ChevronUp,
Maximize,
Minimize,
Minus,
Moon,
Pause,
@@ -32,6 +34,7 @@ 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 props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
@@ -46,6 +49,8 @@ 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)
@@ -815,6 +820,15 @@ onMounted(async () => {
<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>
@@ -18,6 +18,7 @@ import {
Layers,
LayoutGrid,
Maximize,
Minimize,
Moon,
ScanLine,
Settings,
@@ -30,6 +31,7 @@ import { useCbz } from './composables/useCbz'
import { useCbzSettings } from './composables/useCbzSettings'
import type { BgColor, Direction, FitMode, ScrollMode, SpreadAlignment, ViewMode, WidePageSingletonMode } from './composables/useCbzSettings'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import { useFullscreen } from '../shared/composables/useFullscreen'
import type { CbxReaderSettings } from '@bookorbit/types'
import { DEFAULT_WIDE_PAGE_RATIO_THRESHOLD, createCbzSpreadLayout } from './lib/spread-layout'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -43,6 +45,7 @@ const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const { headerVisible, footerVisible, handleMiddleTap, showHeader, showFooter, setVisibilityLock } = useVisibility()
const { isFullscreen, toggleFullscreen } = useFullscreen()
const { onActivity, elapsedMinutes } = useReadingSession(
props.fileId,
@@ -247,6 +250,8 @@ const pageLabel = computed(() => {
return `${start + 1} / ${pageCount.value}`
})
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
const progressPageIndex = computed(() => {
const spread = currentSpread.value
if (!spread || spread.pages.length === 0) return currentPage.value
@@ -622,6 +627,15 @@ onUnmounted(() => {
Start reading
</button>
</div>
<Tooltip>
<TooltipTrigger as-child>
<button class="viewer-btn" :aria-label="fullscreenLabel" @click="toggleFullscreen">
<Minimize v-if="isFullscreen" :size="15" />
<Maximize v-else :size="15" />
</button>
</TooltipTrigger>
<TooltipContent>{{ fullscreenLabel }}</TooltipContent>
</Tooltip>
<DropdownMenu v-model:open="showSettings">
<DropdownMenuTrigger as-child>
<button class="viewer-btn" :class="showSettings ? '!bg-muted !text-foreground' : ''">
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import {
ArrowLeft,
BookOpen,
@@ -16,6 +15,7 @@ import {
} from '@lucide/vue'
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useFullscreen } from '../../shared/composables/useFullscreen'
const props = defineProps<{
chapterTitle: string
@@ -37,11 +37,7 @@ const emit = defineEmits<{
startReading: []
}>()
const isFullscreen = ref(false)
function onFullscreenChange() {
isFullscreen.value = !!document.fullscreenElement
}
const { isFullscreen } = useFullscreen()
function onSettingsOpenChange(open: boolean) {
emit('update:settingsOpen', open)
@@ -58,9 +54,6 @@ function getFooterModeTooltip(mode: 0 | 1 | 2): string {
if (mode === 1) return 'Footer info: reading session + time left'
return 'Footer info: chapter + chapter time left'
}
onMounted(() => document.addEventListener('fullscreenchange', onFullscreenChange))
onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenChange))
</script>
<template>
@@ -146,11 +139,7 @@ onUnmounted(() => document.removeEventListener('fullscreenchange', onFullscreenC
<Tooltip>
<TooltipTrigger as-child>
<button
class="viewer-btn hidden sm:flex"
:aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
@click="emit('toggleFullscreen')"
>
<button class="viewer-btn" :aria-label="isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'" @click="emit('toggleFullscreen')">
<Minimize v-if="isFullscreen" :size="18" />
<Maximize v-else :size="18" />
</button>
@@ -47,6 +47,7 @@ describe('ReaderHeader', () => {
expect(wrapper.emitted('cycleFooterMode')?.length).toBe(1)
expect(wrapper.emitted('toggleHelp')?.length).toBe(1)
expect(wrapper.emitted('toggleFullscreen')?.length).toBe(1)
expect(wrapper.get('button[aria-label="Enter fullscreen"]').classes()).not.toContain('hidden')
})
it('forwards dropdown open changes', () => {
@@ -7,10 +7,12 @@ import { getAccessToken } from '@/lib/api'
import { useReaderProgress } from '../shared/composables/useReaderProgress'
import { useReadingSession } from '../shared/composables/useReadingSession'
import { useReaderSettings } from '../shared/composables/useReaderSettings'
import { useFullscreen } from '../shared/composables/useFullscreen'
import { useThemeStore, ACCENT_OPTIONS } from '@/stores/theme'
import type { PdfReaderSettings } from '@bookorbit/types'
import { getIsDark, lookupAccentHex } from './pdf-viewer-utils'
import { ArrowLeft } from '@lucide/vue'
import { ArrowLeft, Maximize, Minimize } from '@lucide/vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const props = defineProps<{ bookId: number; fileId: number; peekMode?: boolean }>()
const route = useRoute()
@@ -18,6 +20,8 @@ const router = useRouter()
const trackingEnabled = computed(() => !props.peekMode)
const themeStore = useThemeStore()
const { isFullscreen, toggleFullscreen } = useFullscreen()
const fullscreenLabel = computed(() => (isFullscreen.value ? 'Exit fullscreen' : 'Enter fullscreen'))
const bookSettings = useReaderSettings(props.fileId, 'pdf')
const { onActivity, elapsedMinutes } = useReadingSession(
@@ -182,6 +186,10 @@ async function startTrackedReading() {
onActivity()
}
function goBack() {
router.back()
}
onUnmounted(() => {
unsubPageChange?.()
unsubLayoutReady?.()
@@ -192,15 +200,34 @@ onUnmounted(() => {
<template>
<div class="fixed inset-0 flex flex-col">
<!-- Floating Back Button (FAB) -->
<div class="pointer-events-none absolute bottom-6 left-6 z-50 flex">
<button
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-background/95 text-foreground shadow-lg border border-border backdrop-blur-md transition-transform hover:scale-105 active:scale-95"
@click="router.back()"
aria-label="Go back"
>
<ArrowLeft :size="22" />
</button>
<!-- Floating reader controls -->
<div class="pointer-events-none absolute bottom-6 left-6 z-50 flex gap-2">
<Tooltip>
<TooltipTrigger as-child>
<button
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-background/95 text-foreground shadow-lg border border-border backdrop-blur-md transition-transform hover:scale-105 active:scale-95"
aria-label="Go back"
@click="goBack"
>
<ArrowLeft :size="22" />
</button>
</TooltipTrigger>
<TooltipContent>Go back</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
class="pointer-events-auto flex h-12 w-12 items-center justify-center rounded-full bg-background/95 text-foreground shadow-lg border border-border backdrop-blur-md transition-transform hover:scale-105 active:scale-95"
:aria-label="fullscreenLabel"
@click="toggleFullscreen"
>
<Minimize v-if="isFullscreen" :size="22" />
<Maximize v-else :size="22" />
</button>
</TooltipTrigger>
<TooltipContent>{{ fullscreenLabel }}</TooltipContent>
</Tooltip>
</div>
<div v-if="props.peekMode" class="pointer-events-none absolute left-0 right-0 top-3 z-50 flex justify-center">
@@ -45,6 +45,9 @@ vi.mock('../../shared/composables/useReadingSession', () => ({
}),
}))
const requestFullscreenMock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
const exitFullscreenMock = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
vi.mock('@/lib/api', () => ({
getAccessToken: () => 'test-token-abc',
}))
@@ -82,13 +85,30 @@ describe('PdfV4ReaderView', () => {
document.documentElement.className = ''
localStorage.clear()
setActivePinia(createPinia())
requestFullscreenMock.mockClear()
exitFullscreenMock.mockClear()
Object.defineProperty(document.documentElement, 'requestFullscreen', {
configurable: true,
value: requestFullscreenMock,
})
Object.defineProperty(document, 'exitFullscreen', {
configurable: true,
value: exitFullscreenMock,
})
})
async function mountComponent() {
const pinia = createPinia()
const wrapper = mount(PdfV4ReaderView, {
props: { bookId: 42, fileId: 101 },
global: { plugins: [pinia] },
global: {
plugins: [pinia],
stubs: {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
},
},
})
await flushPromises()
await nextTick()
@@ -105,6 +125,14 @@ describe('PdfV4ReaderView', () => {
const wrapper = await mountComponent()
expect(wrapper.find('.mock-pdf-viewer').exists()).toBe(true)
})
it('requests fullscreen from the floating fullscreen control', async () => {
const wrapper = await mountComponent()
await wrapper.get('button[aria-label="Enter fullscreen"]').trigger('click')
await flushPromises()
expect(requestFullscreenMock).toHaveBeenCalled()
})
})
describe('config construction', () => {
@@ -0,0 +1,68 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useFullscreen(target: () => Element | null = () => document.documentElement) {
const isFullscreen = ref(false)
const isFullscreenSupported = ref(false)
function getTarget() {
if (typeof document === 'undefined') return null
return target() ?? document.documentElement
}
function updateFullscreenState() {
if (typeof document === 'undefined') return
const fullscreenTarget = getTarget()
isFullscreen.value = !!document.fullscreenElement
isFullscreenSupported.value = !!document.fullscreenEnabled && !!fullscreenTarget?.requestFullscreen && !!document.exitFullscreen
}
async function enterFullscreen() {
const fullscreenTarget = getTarget()
if (!fullscreenTarget?.requestFullscreen) return false
try {
await fullscreenTarget.requestFullscreen()
return true
} catch {
return false
} finally {
updateFullscreenState()
}
}
async function exitFullscreen() {
if (typeof document === 'undefined' || !document.exitFullscreen) return false
try {
await document.exitFullscreen()
return true
} catch {
return false
} finally {
updateFullscreenState()
}
}
async function toggleFullscreen() {
if (typeof document === 'undefined') return false
if (document.fullscreenElement) return exitFullscreen()
return enterFullscreen()
}
onMounted(() => {
updateFullscreenState()
document.addEventListener('fullscreenchange', updateFullscreenState)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', updateFullscreenState)
})
return {
isFullscreen,
isFullscreenSupported,
enterFullscreen,
exitFullscreen,
toggleFullscreen,
}
}