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.
This commit is contained in:
2026-04-25 13:40:18 -04:00
parent d400377474
commit d68f72f21b
3 changed files with 144 additions and 25 deletions
+130 -11
View File
@@ -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();