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();