From e66308c32321effff9ab895f3247ca15bdaad554 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 25 Apr 2026 13:40:18 -0400 Subject: [PATCH] feat(reader): wire up progress mode switching with four display modes Connect the existing progress_mode setting dropdown to the reader's progress display. Four modes are now functional: - pages: overall percent + page/location number (default, existing) - chapter: chapter title + page X / Y within current section - percentage: overall percent only - time-left: percent + estimated time remaining via reading speed API The progress display in the bottom bar is now clickable to cycle through modes with immediate visual feedback. The settings dropdown is bound with x-model for persistence. Reading speed is fetched once on init from the backend reading-speed API for time-left estimates. --- templates/reader.templ | 4 +- templates/reader_templ.go | 24 +++---- web/src/reader/reader.ts | 141 +++++++++++++++++++++++++++++++++++--- 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/templates/reader.templ b/templates/reader.templ index 2ac4eb4..3248a16 100644 --- a/templates/reader.templ +++ b/templates/reader.templ @@ -154,7 +154,7 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
-
+
if progress.FormatGroup == "reflowable" { if metadata.EstimatedPages > 0 { { fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) } @@ -205,7 +205,7 @@ templ ReaderSettingsPanel() {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -190,7 +190,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 160, Col: 112} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 160, Col: 112} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -200,7 +200,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 162, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 162, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -211,7 +211,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress) var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 165, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 165, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -247,7 +247,7 @@ func ReaderSettingsPanel() templ.Component { templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

⚙️ Settings

Display

Reading Theme

Typography

Navigation

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

⚙️ Settings

Display

Reading Theme

Typography

Navigation

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -351,7 +351,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.CfiPosition) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 382, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 382, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -364,7 +364,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 385, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 385, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -377,7 +377,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Position) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `reader.templ`, Line: 387, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 387, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 8f5459f..aaff163 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -385,6 +385,16 @@ document.addEventListener("alpine:init", () => { 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, async initReader(config: { mediaItemId: string; fileUrl: string; @@ -397,6 +407,7 @@ document.addEventListener("alpine:init", () => { 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; @@ -443,21 +454,30 @@ document.addEventListener("alpine:init", () => { ); }); this.view.addEventListener("relocate", (e: any) => { - const { fraction, location, pageItem, cfi } = e.detail; - const percent = new Intl.NumberFormat("en", { - style: "percent", - }).format(fraction); - const loc = pageItem - ? `Page ${pageItem.label}` - : `Loc ${location.current}`; - this.progressText = `${percent} · ${loc}`; + const { fraction, location, pageItem, cfi, tocItem, section } = + e.detail; + this.lastRelocateDetail = { + fraction, + location, + pageItem, + tocItem, + section, + }; + const progressStr = this.formatProgress( + fraction, + location, + pageItem, + tocItem, + section, + ); + this.progressText = progressStr; this.sliderValue = fraction; const slider = document.getElementById( "progress-slider", ) as HTMLInputElement; if (slider) { slider.value = fraction; - slider.title = `${percent} · ${loc}`; + slider.title = progressStr; } this.debouncedSaveProgress(fraction, location, cfi); }); @@ -468,9 +488,10 @@ document.addEventListener("alpine:init", () => { 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.view.getSectionFractions()) { + for (const fraction of this.sectionFractionsArr) { const option = document.createElement("option"); option.value = fraction; tickMarks.append(option); @@ -483,10 +504,13 @@ document.addEventListener("alpine:init", () => { 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 } }); + await this.view.init({ + lastLocation: { fraction: config.savedPercentage }, + }); } else { await this.view.init({}); } + this.fetchReadingSpeed(); }, debouncedSaveProgress(fraction: number, location: any, cfi: string) { if (this.saveTimeout) clearTimeout(this.saveTimeout); @@ -653,6 +677,7 @@ document.addEventListener("alpine:init", () => { }); }, restoreDefaults() { + this.progressMode = "pages"; this.readingTheme = "light"; this.readingMode = this.detectChromeDarkMode() ? "dark" : "light"; this.readingFont = "literata"; @@ -684,6 +709,100 @@ document.addEventListener("alpine:init", () => { /* ignore bookmark errors for now */ } }, + formatProgress( + fraction: number, + location: { current: number; next: number; total: number }, + pageItem: { id: number; label: string; href: string }, + tocItem: FoliateTocItem | null, + section: { current: number; total: number }, + ): string { + const percent = new Intl.NumberFormat("en", { style: "percent" }).format( + fraction, + ); + switch (this.progressMode) { + case "percentage": + return percent; + case "chapter": { + if (!section || !this.sectionFractionsArr.length) return percent; + const idx = section.current; + const startFrac = this.sectionFractionsArr[idx] ?? 0; + const endFrac = this.sectionFractionsArr[idx + 1] ?? 1; + const sectionFrac = endFrac - startFrac; + if (sectionFrac <= 0) return percent; + const totalInSec = Math.max( + 1, + Math.round(sectionFrac * location.total), + ); + const currentInSec = Math.max( + 1, + Math.round((fraction - startFrac) * location.total), + ); + const clamped = Math.min(currentInSec, totalInSec); + const label = tocItem?.label ? `${tocItem.label} · ` : ""; + return `${label}${clamped} / ${totalInSec}`; + } + 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 `${percent} · ~${hrs}h ${m}m left`; + } + return `${percent} · ~${mins} min left`; + } + return percent; + } + default: { + const loc = pageItem + ? `Page ${pageItem.label}` + : `Loc ${location.current}`; + return `${percent} · ${loc}`; + } + } + }, + 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.progressText = this.formatProgress( + fraction, + location, + pageItem, + tocItem, + section, + ); + const slider = document.getElementById( + "progress-slider", + ) as HTMLInputElement; + if (slider) { + slider.title = this.progressText; + } + } + }, + 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 }); + }, handleKeydown(event: KeyboardEvent) { const k = event.key; if (k === "ArrowLeft" || k === "h") this.goLeft();