import "foliate-js/view.js"; import { config as foliateConfig } from "@bookhoard/foliate-js/pdf.js"; import { Alpine } from "../alpine"; import { loadSettings, saveSettings } from "./settings-manager"; import { getToken } from "../storage"; foliateConfig.pdfjsPath = (path) => `/static/vendor/pdfjs/${path}`; const FONT_MAP: Record = { literata: '"Literata"', crimson: '"Crimson Pro"', "source-serif": '"Source Serif 4"', "eb-garamond": '"EB Garamond"', libertinus: '"Libertinus Serif"', "noto-serif": '"Noto Serif"', "charis-sil": '"Charis SIL"', "ibm-plex": '"IBM Plex Serif"', }; interface FontFile { family: string; url: string; weight: string; style: string; } const FONT_FILES: FontFile[] = [ { family: '"Literata"', url: "/static/fonts/literata/Literata-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Literata"', url: "/static/fonts/literata/Literata-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Crimson Pro"', url: "/static/fonts/crimson/CrimsonPro-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Crimson Pro"', url: "/static/fonts/crimson/CrimsonPro-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Source Serif 4"', url: "/static/fonts/source-serif/SourceSerif4-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Source Serif 4"', url: "/static/fonts/source-serif/SourceSerif4-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"EB Garamond"', url: "/static/fonts/eb-garamond/EBGaramond-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"EB Garamond"', url: "/static/fonts/eb-garamond/EBGaramond-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Regular.woff2", weight: "400", style: "normal", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Bold.woff2", weight: "700", style: "normal", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-Italic.woff2", weight: "400", style: "italic", }, { family: '"Libertinus Serif"', url: "/static/fonts/libertinus/LibertinusSerif-BoldItalic.woff2", weight: "700", style: "italic", }, { family: '"Noto Serif"', url: "/static/fonts/noto-serif/NotoSerif-Variable.woff2", weight: "100 900", style: "normal", }, { family: '"Noto Serif"', url: "/static/fonts/noto-serif/NotoSerif-Italic-Variable.woff2", weight: "100 900", style: "italic", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Regular.woff2", weight: "400", style: "normal", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Bold.woff2", weight: "700", style: "normal", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-Italic.woff2", weight: "400", style: "italic", }, { family: '"Charis SIL"', url: "/static/fonts/charis-sil/CharisSIL-BoldItalic.woff2", weight: "700", style: "italic", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Regular.woff2", weight: "400", style: "normal", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Bold.woff2", weight: "700", style: "normal", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-Italic.woff2", weight: "400", style: "italic", }, { family: '"IBM Plex Serif"', url: "/static/fonts/ibm-plex/IBMPlexSerif-BoldItalic.woff2", weight: "700", style: "italic", }, ]; let fontBlobUrls: Record = {}; let fontsLoaded = false; async function loadFontBlobUrls(): Promise { if (fontsLoaded) return; const entries = await Promise.all( FONT_FILES.map(async (f) => { try { const resp = await fetch(f.url); const blob = await resp.blob(); const blobUrl = URL.createObjectURL(blob); return { key: f.url, blobUrl }; } catch (_e) { return { key: f.url, blobUrl: f.url }; } }), ); for (const { key, blobUrl } of entries) { fontBlobUrls[key] = blobUrl; } fontsLoaded = true; } function buildFontFaceCSS(): string { return FONT_FILES.map((f) => { const src = fontBlobUrls[f.url] ?? f.url; return `@font-face { font-family: ${f.family}; src: url("${src}") format("woff2"); font-weight: ${f.weight}; font-style: ${f.style}; }`; }).join("\n"); } interface ThemeColors { fg: string; bg: string; link: string; } const THEME_COLORS: Record = { light: { light: { fg: "#1a1a1a", bg: "#fafafa", link: "#0066cc" }, dark: { fg: "#e8e8e8", bg: "#1a1a1a", link: "#60a5fa" }, }, paper: { light: { fg: "#2d2d2d", bg: "#fdfbf7", link: "#0284c7" }, dark: { fg: "#ebe5dd", bg: "#1c1917", link: "#38bdf8" }, }, sepia: { light: { fg: "#4a3728", bg: "#f4ecd8", link: "#8b4513" }, dark: { fg: "#d4c4a8", bg: "#2b2420", link: "#d97706" }, }, parchment: { light: { fg: "#5c4033", bg: "#f0e6d3", link: "#92400e" }, dark: { fg: "#e8dcc8", bg: "#3d2b1f", link: "#b45309" }, }, warm: { light: { fg: "#2b2b2b", bg: "#faf8f0", link: "#d97706" }, dark: { fg: "#f5e6d3", bg: "#2d2416", link: "#fbbf24" }, }, candlelight: { light: { fg: "#3d2b1f", bg: "#fef3c7", link: "#b45309" }, dark: { fg: "#fde68a", bg: "#451a03", link: "#fb923c" }, }, azure: { light: { fg: "#262d48", bg: "#cedef5", link: "#2d53e5" }, dark: { fg: "#babee1", bg: "#282e47", link: "#0ea5e9" }, }, sky: { light: { fg: "#1e3a5f", bg: "#e0f2fe", link: "#0284c7" }, dark: { fg: "#bae6fd", bg: "#0c4a6e", link: "#0ea5e9" }, }, arctic: { light: { fg: "#1e3a5f", bg: "#f0f9ff", link: "#0284c7" }, dark: { fg: "#bfdbfe", bg: "#1e293b", link: "#38bdf8" }, }, frost: { light: { fg: "#334155", bg: "#f8fafc", link: "#38bdf8" }, dark: { fg: "#e0f2fe", bg: "#0f172a", link: "#7dd3fc" }, }, dusk: { light: { fg: "#3d2914", bg: "#fef3e2", link: "#ea580c" }, dark: { fg: "#f5d5b8", bg: "#2d1f14", link: "#fb923c" }, }, sunset: { light: { fg: "#4a1d1d", bg: "#fff7ed", link: "#f97316" }, dark: { fg: "#fed7aa", bg: "#431407", link: "#fb923c" }, }, twilight: { light: { fg: "#4c1d95", bg: "#f5f3ff", link: "#8b5cf6" }, dark: { fg: "#ddd6fe", bg: "#2e1065", link: "#a78bfa" }, }, forest: { light: { fg: "#1a2e1a", bg: "#e8efe8", link: "#15803d" }, dark: { fg: "#c8dcc8", bg: "#1a2e1a", link: "#22c55e" }, }, moss: { light: { fg: "#14532d", bg: "#dcfce7", link: "#22c55e" }, dark: { fg: "#86efac", bg: "#052e16", link: "#4ade80" }, }, slate: { light: { fg: "#334155", bg: "#f8fafc", link: "#475569" }, dark: { fg: "#e2e8f0", bg: "#1e293b", link: "#94a3b8" }, }, oled: { light: { fg: "#000000", bg: "#ffffff", link: "#0066cc" }, dark: { fg: "#ffffff", bg: "#000000", link: "#3b82f6" }, }, solarized: { light: { fg: "#657b83", bg: "#fdf6e3", link: "#268bd2" }, dark: { fg: "#839496", bg: "#002b36", link: "#268bd2" }, }, }; const getCSS = ({ fontFamily, fontSize, lineHeight, justify, hyphenate, themeName, themeMode, }: { fontFamily: string; fontSize: number; lineHeight: number; justify: boolean; hyphenate: boolean; themeName: string; themeMode: string; }) => { const themeSet = THEME_COLORS[themeName] ?? THEME_COLORS.light; const theme = themeMode === "dark" ? themeSet.dark : themeSet.light; const font = FONT_MAP[fontFamily] ?? '"Literata"'; const fontFamilyRule = fontFamily ? ` body { font-family: ${font}, serif !important; } body * { font-family: inherit !important; }` : ""; return ` ${buildFontFaceCSS()} @namespace epub "http://www.idpf.org/2007/ops"; html { color-scheme: light dark; font-size: ${fontSize}px; line-height: ${lineHeight}; } ${fontFamilyRule} html, body { color: ${theme.fg} !important; background-color: ${theme.bg} !important; } body * { color: inherit !important; border-color: currentColor !important; } a:any-link { color: ${theme.link} !important; text-decoration-color: color-mix(in srgb, currentColor 20%, transparent); text-underline-offset: .1em; } svg, img { background-color: transparent !important; } aside[epub|type~="footnote"] { display: none; } p, li, blockquote, dd { line-height: ${lineHeight} !important; text-align: ${justify ? "justify" : "start"} !important; hyphens: ${hyphenate ? "auto" : "none"}; } [align="left"] { text-align: left; } [align="right"] { text-align: right; } [align="center"] { text-align: center; } [align="justify"] { text-align: justify; } pre { white-space: pre-wrap !important; tab-size: 2; } ::selection { background-color: rgba(128, 128, 128, 0.3); } img, svg, video { max-width: 100%; height: auto; } html { hanging-punctuation: allow-end last; orphans: 2; widows: 2; } `; }; document.addEventListener("alpine:init", () => { Alpine.data("readerShell", () => ({ view: null as any, renderer: null as any, book: null as any, zoomPercent: 100, isFixedLayout: false, isPDF: false, interactionMode: "select" as string, magnifierEnabled: false, doublePageSpread: true as boolean, progressText: "", progressLabel: "", progressMain: "", sliderValue: 0, settings: null as ReaderSettings | null, justify: true, hyphenate: true, tocOpen: false, settingsOpen: false, bookmarksOpen: false, navigatorOpen: false, tocItems: [] as any[], mediaItemId: "" as string, saveTimeout: null as ReturnType | null, initTime: 0 as number, contextText: "" as string, readingTheme: "light" as string, readingMode: "light" as string, readingFont: "literata" as string, fontSize: 16 as number, lineHeight: 1.6 as number, progressMode: "pages" as string, readingSpeedPpm: 0 as number, sectionFractionsArr: [] as number[], lastRelocateDetail: null as { fraction: number; location: { current: number; next: number; total: number }; pageItem: { id: number; label: string; href: string } | null; tocItem: FoliateTocItem | null; section: { current: number; total: number }; } | null, chapterBoundaries: [] as { id: number; label: string; startPage: number; }[], init() { const link = document.getElementById("reader-back"); if (!link) return; const storageKey = "reader_back"; if (document.referrer) { try { const ref = new URL(document.referrer); if ( ref.origin === window.location.origin && !ref.pathname.startsWith("/readers/") && ref.pathname !== window.location.pathname ) { sessionStorage.setItem(storageKey, ref.pathname + ref.search); } } catch {} } else { sessionStorage.removeItem(storageKey); } const backUrl = sessionStorage.getItem(storageKey) || link.getAttribute("href") || "/dashboard"; link.addEventListener("click", (e: Event) => { e.preventDefault(); sessionStorage.removeItem(storageKey); window.location.href = backUrl; }); }, async initReader(config: { mediaItemId: string; fileUrl: string; formatGroup: string; readingDirection: string; mangaType: string; savedPercentage?: number; savedCfi?: string; savedPage?: number; savedTotalPages?: number; }) { this.mediaItemId = config.mediaItemId; this.settings = await loadSettings(); if (this.settings) { this.progressMode = this.settings.progress_mode || "pages"; this.readingTheme = this.settings.reading_theme || "light"; this.readingFont = this.settings.reading_font || "literata"; this.fontSize = this.settings.font_size || 18; this.lineHeight = this.settings.line_height || 1.6; this.doublePageSpread = this.settings.double_page_spread ?? true; if (this.settings.reading_mode) { this.readingMode = this.settings.reading_mode; } else { this.readingMode = this.detectChromeDarkMode() ? "dark" : "light"; } } const viewport = document.getElementById("reader-viewport")!; const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light; const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light; viewport.style.backgroundColor = themeColors.bg; this.view = document.getElementById("reader-view") as any; await loadFontBlobUrls(); const resp = await fetch(config.fileUrl, { headers: { Authorization: `Bearer ${getToken()}` }, }); const blob = await resp.blob(); const fileName = new URL(config.fileUrl, window.location.origin).pathname; const file = new File([blob], fileName, { type: blob.type }); await this.view.open(file, { pdf: {}, }); this.renderer = this.view.renderer; this.book = this.view.book; this.isFixedLayout = this.view.isFixedLayout; if (this.isFixedLayout) { // Manga/RTL comics: foliate's goLeft/goRight swap on book.dir === "rtl", // but makeComicBook never sets dir. Apply it from metadata so RTL page // turns (and spread ordering) read right-to-left. Reflowable EPUBs keep // whatever direction foliate read from the OPF. if (config.readingDirection === "rtl" && this.book.dir !== "rtl") { this.book.dir = "rtl"; } this.isPDF = (this.renderer as any).isPDF; this.renderer.addEventListener("zoom", () => { this.zoomPercent = this.renderer.zoomPercent; }); this.computeFixedLayoutChapterBoundaries(); this.applyDoublePageSpread(); } else { this.renderer.setStyles?.(this.buildCSS()); } this.view.addEventListener("load", (e: any) => { const { doc, index } = e.detail; const link = doc.createElement("link"); link.rel = "stylesheet"; link.href = "/static/reader-fonts.css"; doc.head.append(link); doc.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); if (!this.isFixedLayout) { this.computeChapterPageBoundaries(doc); doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc)); } }); this.view.addEventListener("relocate", (e: any) => { const { fraction, location, pageItem, cfi, tocItem, section } = e.detail; this.lastRelocateDetail = { fraction, location, pageItem, tocItem, section, }; const progressParts = this.formatProgressParts( fraction, location, pageItem, tocItem, section, ); this.setProgress(progressParts); this.sliderValue = fraction; const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider) { slider.value = fraction; } const range = e.detail.range as Range | undefined; if (range) { const text = range.toString().replace(/\s+/g, " ").trim(); this.contextText = text ? text.slice(0, 100) : ""; } this.debouncedSaveProgress(fraction, location, cfi); }); const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider && this.book.dir) { slider.dir = this.book.dir; } if (this.view.getSectionFractions) { this.sectionFractionsArr = this.view.getSectionFractions(); const tickMarks = document.getElementById("tick-marks"); if (tickMarks) { for (const fraction of this.sectionFractionsArr) { const option = document.createElement("option"); option.value = fraction; tickMarks.append(option); } } } document.addEventListener("keydown", (ev: KeyboardEvent) => this.handleKeydown(ev), ); if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) { // Fixed-layout & comics: a page index is the exact, universal locator. // A bare number navigates directly to the section index in foliate. await this.view.init({ lastLocation: config.savedPage - 1 }) } else if (config.savedCfi) { await this.view.init({ lastLocation: config.savedCfi }) } else if (config.savedPercentage && config.savedPercentage > 0) { await this.view.init({ lastLocation: { fraction: config.savedPercentage }, }) } else { await this.view.init({}) } this.initTime = Date.now(); this.fetchReadingSpeed(); this.setupViewportInsets(); }, updateViewportInsets() { const top = document.getElementById("reader-topbar"); const bot = document.getElementById("reader-bottombar"); const vp = document.getElementById("reader-viewport"); if (!top || !bot || !vp) return; const margin = 6; vp.style.setProperty("top", `${top.offsetHeight + margin}px`); vp.style.setProperty("bottom", `${bot.offsetHeight + margin}px`); }, setupViewportInsets() { const top = document.getElementById("reader-topbar"); const bot = document.getElementById("reader-bottombar"); this.updateViewportInsets(); window.addEventListener("resize", () => this.updateViewportInsets()); window.addEventListener("orientationchange", () => setTimeout(() => this.updateViewportInsets(), 250), ); if ( typeof ResizeObserver !== "undefined" && top && bot ) { const ro = new ResizeObserver(() => this.updateViewportInsets()); ro.observe(top); ro.observe(bot); } }, debouncedSaveProgress(fraction: number, location: any, cfi: string) { if (Date.now() - this.initTime < 5000) return; if (this.saveTimeout) clearTimeout(this.saveTimeout); this.saveTimeout = setTimeout(() => { this.saveProgress(fraction, location, cfi); }, 2000); }, async saveProgress(fraction: number, location: any, cfi: string) { const token = getToken(); if (!token || !this.mediaItemId) return; const body: Record = { percentage: fraction, current_page: this.isFixedLayout ? (this.renderer?.index ?? 0) + 1 : location?.current ?? 0, total_pages: this.isFixedLayout ? this.book?.sections?.filter((s: any) => s.linear !== "no") ?.length ?? 0 : location?.total ?? 0, reading_mode: this.readingMode || undefined, }; if (!this.isFixedLayout) { body.epubcfi = cfi || ""; body.context_text = this.contextText || ""; } const tocItem = this.lastRelocateDetail?.tocItem; if (tocItem) { if (tocItem.label) { const boundaryIdx = this.chapterBoundaries.findIndex( (b: any) => b.tocItem === tocItem, ); if (boundaryIdx !== -1) { body.chapter = boundaryIdx; } } } if (this.isFixedLayout && this.renderer?.zoomPercent) { body.zoom_level = this.renderer.zoomPercent / 100; } try { await fetch(`/api/media-items/${this.mediaItemId}/progress`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); } catch (_e) { // silent fail — progress save is non-critical } }, zoomIn() { if (!this.isFixedLayout) return; const newScale = Math.min(10, this.renderer.currentScale * 1.2); this.renderer.setAttribute("zoom", newScale); this.zoomPercent = this.renderer.zoomPercent; }, zoomOut() { if (!this.isFixedLayout) return; const newScale = Math.max(0.1, this.renderer.currentScale / 1.2); this.renderer.setAttribute("zoom", newScale); this.zoomPercent = this.renderer.zoomPercent; }, resetZoom() { if (!this.isFixedLayout) return; this.renderer.resetZoom(); this.renderer.dragOffset = { x: 0, y: 0 }; this.zoomPercent = 100; }, toggleMagnifier() { if (!this.isFixedLayout) return; this.renderer.toggleMagnifier(); this.magnifierEnabled = this.renderer.zoomMagnifierEnabled; }, toggleInteractionMode() { if (!this.isFixedLayout) return; const current = this.renderer.getAttribute("interaction-mode") || "select"; const next = current === "select" ? "pan" : "select"; this.renderer.setAttribute("interaction-mode", next); this.interactionMode = next; }, applyDoublePageSpread() { if (!this.isFixedLayout || !this.renderer) return; this.renderer.setAttribute( "spread", this.doublePageSpread ? "auto" : "none", ); saveSettings({ double_page_spread: this.doublePageSpread }); }, goLeft() { this.view?.goLeft?.(); }, goRight() { this.view?.goRight?.(); }, nextPage() { this.view?.next?.(); }, previousPage() { this.view?.prev?.(); }, goToFraction(value: string) { this.view?.goToFraction?.(parseFloat(value)); }, toggleTOC() { this.tocOpen = !this.tocOpen; if (this.tocOpen && this.tocItems.length === 0 && this.book?.toc) { this.tocItems = this.flattenTOC(this.book.toc); } }, toggleSettings() { this.settingsOpen = !this.settingsOpen; }, toggleBookmarks() { this.bookmarksOpen = !this.bookmarksOpen; }, toggleNavigator() { this.navigatorOpen = !this.navigatorOpen; }, toggleWindowShade(panelEl: HTMLElement) { panelEl.classList.toggle("panel-collapsed"); }, flattenTOC(items: any[], depth = 0): any[] { const result: any[] = []; for (const item of items) { result.push({ ...item, depth }); if (item.subitems?.length) { result.push(...this.flattenTOC(item.subitems, depth + 1)); } } return result; }, goToTOCItem(item: any) { if (this.view && item.href) { this.view.goTo(item.href); this.tocOpen = false; } }, goToBookmarkTarget(cfi: string) { if (this.view && cfi) { this.view.goTo(cfi); this.bookmarksOpen = false; } }, applyTheme() { const viewport = document.getElementById("reader-viewport")!; const themeSet = THEME_COLORS[this.readingTheme] ?? THEME_COLORS.light; const themeColors = this.readingMode === "dark" ? themeSet.dark : themeSet.light; viewport.style.backgroundColor = themeColors.bg; this.applyStyles(); saveSettings({ reading_theme: this.readingTheme, reading_mode: this.readingMode, }); }, toggleReadingMode() { this.readingMode = this.readingMode === "dark" ? "light" : "dark"; this.applyTheme(); }, detectChromeDarkMode(): boolean { const darkChromeThemes = [ "theme-tokyo-night", "theme-dracula", "theme-nord", "theme-monokai", "theme-one-dark", "theme-material", "theme-catppuccin", ]; return darkChromeThemes.some((t) => document.body.classList.contains(t)); }, applyFont() { this.applyStyles(); saveSettings({ reading_font: this.readingFont, font_size: this.fontSize, line_height: this.lineHeight, }); }, applyStyles() { if (!this.renderer?.setStyles) return; this.renderer.setStyles(this.buildCSS()); }, buildCSS() { return getCSS({ fontFamily: this.readingFont, fontSize: this.fontSize, lineHeight: this.lineHeight, justify: this.justify, hyphenate: this.hyphenate, themeName: this.readingTheme, themeMode: this.readingMode, }); }, restoreDefaults() { this.progressMode = "pages"; this.readingTheme = "light"; this.readingMode = this.detectChromeDarkMode() ? "dark" : "light"; this.readingFont = "literata"; this.fontSize = 18; this.lineHeight = 1.6; this.justify = true; this.hyphenate = true; this.applyTheme(); this.applyFont(); }, async addBookmark() { const token = getToken(); if (!token || !this.view) return; const location = this.view.lastLocation; if (!location) return; try { await fetch("/readers/bookmarks", { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ title: `Bookmark at ${this.progressText}`, position: JSON.stringify(location), }), }); } catch (_e) { /* ignore bookmark errors for now */ } }, formatProgressParts( fraction: number, location: { current: number; next: number; total: number }, pageItem: { id: number; label: string; href: string }, tocItem: FoliateTocItem | null, section: { current: number; total: number }, ): { label: string; main: string } { const percent = new Intl.NumberFormat("en", { style: "percent" }).format( fraction, ); const chapterLabel = tocItem?.label ? `${tocItem.label} · ` : ""; switch (this.progressMode) { case "percentage": return { label: "", main: percent }; case "chapter": { if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) { const totalSections = this.book?.sections?.filter( (s: any) => s.linear !== "no", )?.length ?? 0; const currentPage = this.renderer?.index ?? 0; const boundaryIdx = this.chapterBoundaries.findIndex( (b) => b.id === tocItem.id, ); if (boundaryIdx !== -1 && totalSections > 0) { const chapterStart = this.chapterBoundaries[boundaryIdx].startPage; const nextBoundary = this.chapterBoundaries[boundaryIdx + 1]; const chapterEnd = nextBoundary ? nextBoundary.startPage : totalSections; const chapterPages = Math.max(1, chapterEnd - chapterStart); const currentInChapter = Math.max( 1, Math.min(currentPage - chapterStart + 1, chapterPages), ); return { label: chapterLabel, main: `${currentInChapter} / ${chapterPages}`, }; } } if (tocItem && this.chapterBoundaries.length > 0) { const boundaryIdx = this.chapterBoundaries.findIndex( (b) => b.id === tocItem.id, ); if (boundaryIdx !== -1) { const currentPage = this.renderer?.page ?? 1; const chapterStart = this.chapterBoundaries[boundaryIdx].startPage; const nextBoundary = this.chapterBoundaries[boundaryIdx + 1]; const totalPages = this.renderer?.pages ?? 0; const chapterEnd = nextBoundary ? nextBoundary.startPage : totalPages > 2 ? totalPages - 1 : chapterStart + 1; const chapterPages = Math.max(1, chapterEnd - chapterStart); const currentInChapter = Math.max( 1, Math.min(currentPage - chapterStart + 1, chapterPages), ); return { label: chapterLabel, main: `${currentInChapter} / ${chapterPages}`, }; } } if (section && this.sectionFractionsArr.length > 1) { const pageInfo = this.getRenderedPageInfo(); const pageBase = pageInfo?.total ?? location.total; const idx = section.current; const startFrac = this.sectionFractionsArr[idx] ?? 0; const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; const sectionFrac = endFrac - startFrac; if (sectionFrac > 0 && pageBase > 0) { const totalInSec = Math.max( 1, Math.round(sectionFrac * pageBase), ); const currentInSec = Math.max( 1, Math.round((fraction - startFrac) * pageBase), ); const clamped = Math.min(currentInSec, totalInSec); return { label: chapterLabel, main: `${clamped} / ${totalInSec}`, }; } } if (location.total > 0) { return { label: "", main: `${percent} · ${location.current} / ${location.total}`, }; } return { label: "", main: percent }; } case "time-left": { if (this.readingSpeedPpm > 0 && location.total > 0) { const remaining = location.total - location.current; const mins = Math.ceil(remaining / this.readingSpeedPpm); if (mins >= 60) { const hrs = Math.floor(mins / 60); const m = mins % 60; return { label: "", main: `${percent} · ~${hrs}h ${m}m left` }; } return { label: "", main: `${percent} · ~${mins} min left` }; } return { label: "", main: `${percent} · ~-- min left` }; } default: { if (this.isFixedLayout) { const pageInfo = this.getRenderedPageInfo(); if (pageInfo) { return { label: "", main: `${pageInfo.current} / ${pageInfo.total}` }; } } if (pageItem) { return { label: "", main: `${percent} · Page ${pageItem.label}` }; } const pageInfoReflow = this.getRenderedPageInfo(); if (pageInfoReflow) { return { label: "", main: `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`, }; } return { label: "", main: `${percent} · ${location.current} / ${location.total}`, }; } } }, setProgress(parts: { label: string; main: string }, sliderTitle?: string) { this.progressLabel = parts.label; this.progressMain = parts.main; this.progressText = parts.label + parts.main; const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider) { slider.title = sliderTitle ?? this.progressText; } }, cycleProgressMode() { const modes = ["pages", "chapter", "percentage", "time-left"]; const idx = modes.indexOf(this.progressMode); this.progressMode = modes[(idx + 1) % modes.length]; this.applyProgressMode(); if (this.lastRelocateDetail) { const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail; this.setProgress( this.formatProgressParts( fraction, location, pageItem, tocItem, section, ), ); } }, async fetchReadingSpeed() { const token = getToken(); if (!token || !this.mediaItemId) return; try { const resp = await fetch(`/readers/${this.mediaItemId}/reading-speed`, { headers: { Authorization: `Bearer ${token}` }, }); if (resp.ok) { const data = await resp.json(); this.readingSpeedPpm = data.pages_per_minute || 0; } } catch (_e) { this.readingSpeedPpm = 0; } }, applyProgressMode() { saveSettings({ progress_mode: this.progressMode as any }); if (this.lastRelocateDetail) { const { fraction, location, pageItem, tocItem, section } = this.lastRelocateDetail; this.setProgress( this.formatProgressParts( fraction, location, pageItem, tocItem, section, ), ); } }, computeFixedLayoutChapterBoundaries() { if (!this.book?.toc || !this.view) return; const flat = this.flattenTOC(this.book.toc); for (const item of flat) { try { const resolved = this.view.resolveNavigation(item.href); if (resolved?.index != null) { this.chapterBoundaries.push({ id: item.id, label: item.label, startPage: resolved.index, }); } } catch { // skip unresolvable anchors } } this.chapterBoundaries.sort( (a, b) => a.startPage - b.startPage, ); }, computeChapterPageBoundaries(doc: Document) { this.chapterBoundaries = []; if (!this.book?.toc) return; const size = this.renderer?.size; if (!size) return; const flat = this.flattenTOC(this.book.toc); for (const item of flat) { const fragment = item.href?.split("#")[1]; if (!fragment) continue; try { const el = this.book.getTOCFragment?.(doc, fragment); if (!el) continue; const rect = el.getBoundingClientRect(); const page = Math.floor(rect.left / size) + 1; this.chapterBoundaries.push({ id: item.id, label: item.label, startPage: page, }); } catch { // skip unresolvable anchors } } this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage); }, getRenderedPageInfo(): { total: number; current: number } | null { if (this.isFixedLayout) { const total = this.book?.sections?.filter( (s: any) => s.linear !== "no", )?.length ?? 0; if (total === 0) return null; const index = this.renderer?.index ?? 0; return { total, current: index + 1 }; } const pages = this.renderer?.pages; if (pages != null && pages > 2) { return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 }; } return null; }, progressTooltip(): string { const labels: Record = { pages: "Pages", chapter: "Chapter", percentage: "Percentage", "time-left": "Time Left", }; const label = labels[this.progressMode] ?? "Pages"; if ( this.progressMode === "chapter" && this.chapterBoundaries.length === 0 ) { return `${label} · no chapter data · Click to switch`; } return `${label} · Click to switch`; }, handleKeydown(event: KeyboardEvent) { const k = event.key; if (k === "ArrowLeft" || k === "h") this.goLeft(); else if (k === "ArrowRight" || k === "l") this.goRight(); else if (k === "+" || k === "=") this.zoomIn(); else if (k === "-" || k === "_") this.zoomOut(); else if (k === "0") this.resetZoom(); else if (k === "Escape") { if (this.isFixedLayout && this.renderer?.zoomMagnifierEnabled) { this.toggleMagnifier(); } } }, })); }); Alpine.start();