")
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, "
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
")
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();