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:
@@ -154,7 +154,7 @@ templ ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
<!-- Separator -->
|
||||
<div class="w-px h-6 bg-gray-600 mx-1"></div>
|
||||
<!-- Progress display -->
|
||||
<div id="progress-display" x-text="progressText" data-progress-mode="pages" class="text-sm min-w-[4rem] text-center">
|
||||
<div id="progress-display" x-text="progressText" @click="cycleProgressMode()" title="Click to change progress mode" class="text-sm min-w-[4rem] text-center cursor-pointer">
|
||||
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() {
|
||||
</label>
|
||||
<label class="block mb-2">
|
||||
Progress Mode
|
||||
<select name="progress_mode" class="w-full mt-1 px-3 py-2 rounded border">
|
||||
<select name="progress_mode" x-model="progressMode" @change="applyProgressMode()" class="w-full mt-1 px-3 py-2 rounded border">
|
||||
<option value="pages">Pages</option>
|
||||
<option value="chapter">Chapter</option>
|
||||
<option value="percentage">Percentage</option>
|
||||
|
||||
+12
-12
File diff suppressed because one or more lines are too long
+130
-11
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user