feat(reader): send richer progress payload with chapter boundaries and zoom

The reader's saveProgress() now sends a more complete payload to the
backend so ProgressService has more data for enrichment and merge:

- chapter: computed from TOC boundary index instead of missing
- reading_mode: current display mode (page, chapter, percent, time-left)
- zoom_level: for fixed-layout books (renderer.zoomPercent / 100)
- current_page: real page number for fixed-layout, location.current for
  reflowable
- total_pages: section count for fixed-layout, location.total for
  reflowable

Adds computeChapterPageBoundaries(doc) for reflowable EPUBs that maps
TOC anchors to rendered page numbers, recomputes after fonts load.
Adds computeFixedLayoutChapterBoundaries() for fixed-layout books that
resolves TOC hrefs to page indices via view.resolveNavigation().

Updates reader.templ to expose isFixedLayout to Alpine init.
This commit is contained in:
2026-04-25 21:16:52 -04:00
parent 4fe36ba0a5
commit cb214b16b2
3 changed files with 235 additions and 31 deletions
+233 -29
View File
@@ -395,6 +395,11 @@ document.addEventListener("alpine:init", () => {
tocItem: FoliateTocItem | null;
section: { current: number; total: number };
} | null,
chapterBoundaries: [] as {
id: number;
label: string;
startPage: number;
}[],
async initReader(config: {
mediaItemId: string;
fileUrl: string;
@@ -440,11 +445,12 @@ document.addEventListener("alpine:init", () => {
this.renderer.addEventListener("zoom", () => {
this.zoomPercent = this.renderer.zoomPercent;
});
this.computeFixedLayoutChapterBoundaries();
} else {
this.renderer.setStyles?.(this.buildCSS());
}
this.view.addEventListener("load", (e: any) => {
const { doc } = e.detail;
const { doc, index } = e.detail;
const link = doc.createElement("link");
link.rel = "stylesheet";
link.href = "/static/reader-fonts.css";
@@ -452,6 +458,10 @@ document.addEventListener("alpine:init", () => {
doc.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev),
);
if (!this.isFixedLayout) {
this.computeChapterPageBoundaries(doc);
doc.fonts.ready.then(() => this.computeChapterPageBoundaries(doc));
}
});
this.view.addEventListener("relocate", (e: any) => {
const { fraction, location, pageItem, cfi, tocItem, section } =
@@ -521,6 +531,36 @@ document.addEventListener("alpine:init", () => {
async saveProgress(fraction: number, location: any, cfi: string) {
const token = getToken();
if (!token || !this.mediaItemId) return;
const body: Record<string, any> = {
percentage: fraction,
current_page: this.isFixedLayout
? (this.renderer?.index ?? 0) + 1
: location?.current ?? 0,
total_pages: this.isFixedLayout
? this.book?.sections?.filter((s: any) => s.linear !== "no")
?.length ?? 0
: location?.total ?? 0,
epubcfi: cfi || "",
reading_mode: this.readingMode || undefined,
};
const tocItem = this.lastRelocateDetail?.tocItem;
if (tocItem) {
if (tocItem.label) {
const boundaryIdx = this.chapterBoundaries.findIndex(
(b: any) => b.tocItem === tocItem,
);
if (boundaryIdx !== -1) {
body.chapter = boundaryIdx;
}
}
}
if (this.isFixedLayout && this.renderer?.zoomPercent) {
body.zoom_level = this.renderer.zoomPercent / 100;
}
try {
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
method: "PUT",
@@ -528,12 +568,7 @@ document.addEventListener("alpine:init", () => {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
percentage: fraction,
current_page: location?.current ?? 0,
total_pages: location?.total ?? 0,
epubcfi: cfi || "",
}),
body: JSON.stringify(body),
});
} catch (_e) {
// silent fail — progress save is non-critical
@@ -723,23 +758,87 @@ document.addEventListener("alpine:init", () => {
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}`;
if (this.isFixedLayout && tocItem && this.chapterBoundaries.length > 0) {
const totalSections = this.book?.sections?.filter(
(s: any) => s.linear !== "no",
)?.length ?? 0;
const currentPage = this.renderer?.index ?? 0;
const boundaryIdx = this.chapterBoundaries.findIndex(
(b) => b.id === tocItem.id,
);
if (boundaryIdx !== -1 && totalSections > 0) {
const chapterStart =
this.chapterBoundaries[boundaryIdx].startPage;
const nextBoundary =
this.chapterBoundaries[boundaryIdx + 1];
const chapterEnd = nextBoundary
? nextBoundary.startPage
: totalSections;
const chapterPages = Math.max(1, chapterEnd - chapterStart);
const currentInChapter = Math.max(
1,
Math.min(currentPage - chapterStart + 1, chapterPages),
);
const label = tocItem.label
? `${tocItem.label} · `
: "";
return `${label}${currentInChapter} / ${chapterPages}`;
}
}
if (tocItem && this.chapterBoundaries.length > 0) {
const boundaryIdx = this.chapterBoundaries.findIndex(
(b) => b.id === tocItem.id,
);
if (boundaryIdx !== -1) {
const currentPage = this.renderer?.page ?? 1;
const chapterStart =
this.chapterBoundaries[boundaryIdx].startPage;
const nextBoundary =
this.chapterBoundaries[boundaryIdx + 1];
const totalPages = this.renderer?.pages ?? 0;
const chapterEnd = nextBoundary
? nextBoundary.startPage
: totalPages > 2
? totalPages - 1
: chapterStart + 1;
const chapterPages = Math.max(1, chapterEnd - chapterStart);
const currentInChapter = Math.max(
1,
Math.min(currentPage - chapterStart + 1, chapterPages),
);
const label = tocItem.label
? `${tocItem.label} · `
: "";
return `${label}${currentInChapter} / ${chapterPages}`;
}
}
if (section && this.sectionFractionsArr.length > 1) {
const pageInfo = this.getRenderedPageInfo();
const pageBase = pageInfo?.total ?? location.total;
const idx = section.current;
const startFrac = this.sectionFractionsArr[idx] ?? 0;
const endFrac = this.sectionFractionsArr[idx + 1] ?? 1;
const sectionFrac = endFrac - startFrac;
if (sectionFrac > 0 && pageBase > 0) {
const totalInSec = Math.max(
1,
Math.round(sectionFrac * pageBase),
);
const currentInSec = Math.max(
1,
Math.round((fraction - startFrac) * pageBase),
);
const clamped = Math.min(currentInSec, totalInSec);
const label = tocItem?.label
? `${tocItem.label} · `
: "";
return `${label}${clamped} / ${totalInSec}`;
}
}
if (location.total > 0) {
return `${percent} · ${location.current} / ${location.total}`;
}
return percent;
}
case "time-left": {
if (this.readingSpeedPpm > 0 && location.total > 0) {
@@ -752,13 +851,23 @@ document.addEventListener("alpine:init", () => {
}
return `${percent} · ~${mins} min left`;
}
return percent;
return `${percent} · ~-- min left`;
}
default: {
const loc = pageItem
? `Page ${pageItem.label}`
: `Loc ${location.current}`;
return `${percent} · ${loc}`;
if (this.isFixedLayout) {
const pageInfo = this.getRenderedPageInfo();
if (pageInfo) {
return `${pageInfo.current} / ${pageInfo.total}`;
}
}
if (pageItem) {
return `${percent} · Page ${pageItem.label}`;
}
const pageInfoReflow = this.getRenderedPageInfo();
if (pageInfoReflow) {
return `${percent} · ${pageInfoReflow.current + 1} / ${pageInfoReflow.total}`;
}
return `${percent} · ${location.current} / ${location.total}`;
}
}
},
@@ -802,6 +911,101 @@ document.addEventListener("alpine:init", () => {
},
applyProgressMode() {
saveSettings({ progress_mode: this.progressMode as any });
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;
}
}
},
computeFixedLayoutChapterBoundaries() {
if (!this.book?.toc || !this.view) return;
const flat = this.flattenTOC(this.book.toc);
for (const item of flat) {
try {
const resolved = this.view.resolveNavigation(item.href);
if (resolved?.index != null) {
this.chapterBoundaries.push({
id: item.id,
label: item.label,
startPage: resolved.index,
});
}
} catch {
// skip unresolvable anchors
}
}
this.chapterBoundaries.sort(
(a, b) => a.startPage - b.startPage,
);
},
computeChapterPageBoundaries(doc: Document) {
this.chapterBoundaries = [];
if (!this.book?.toc) return;
const size = this.renderer?.size;
if (!size) return;
const flat = this.flattenTOC(this.book.toc);
for (const item of flat) {
const fragment = item.href?.split("#")[1];
if (!fragment) continue;
try {
const el = this.book.getTOCFragment?.(doc, fragment);
if (!el) continue;
const rect = el.getBoundingClientRect();
const page = Math.floor(rect.left / size) + 1;
this.chapterBoundaries.push({
id: item.id,
label: item.label,
startPage: page,
});
} catch {
// skip unresolvable anchors
}
}
this.chapterBoundaries.sort((a, b) => a.startPage - b.startPage);
},
getRenderedPageInfo(): { total: number; current: number } | null {
if (this.isFixedLayout) {
const total =
this.book?.sections?.filter(
(s: any) => s.linear !== "no",
)?.length ?? 0;
if (total === 0) return null;
const index = this.renderer?.index ?? 0;
return { total, current: index + 1 };
}
const pages = this.renderer?.pages;
if (pages != null && pages > 2) {
return { total: pages - 2, current: (this.renderer.page ?? 1) - 1 };
}
return null;
},
progressTooltip(): string {
const labels: Record<string, string> = {
pages: "Pages",
chapter: "Chapter",
percentage: "Percentage",
"time-left": "Time Left",
};
const label = labels[this.progressMode] ?? "Pages";
if (
this.progressMode === "chapter" &&
this.chapterBoundaries.length === 0
) {
return `${label} · no chapter data · Click to switch`;
}
return `${label} · Click to switch`;
},
handleKeydown(event: KeyboardEvent) {
const k = event.key;