")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts
index aaff163..98680f4 100644
--- a/web/src/reader/reader.ts
+++ b/web/src/reader/reader.ts
@@ -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 = {
+ 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 = {
+ 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;