fix(reader): read position from the API at open; never write a restored position
The reader page embedded a snapshot of reading state (position, bookmarks) server-side at render time. Browsers may reuse that HTML (heuristic caching, bfcache), so opening a book could restore a stale position — and worse, the restore's relocate auto-saved it back, overwriting a newer device push minutes later. A KOReader sync followed by opening the web reader would silently revert the row to the old web position; the row's source and the rendered page disagreed. The web reader is intrinsically tied to the server, so it has no business preserving reading state client-side: - The rendered page now carries only immutable book metadata. The reader fetches progress fresh (cache: no-store) from the existing progress API at open and restores with the same priority as before (page for fixed-layout, CFI, percentage, fresh start); a failed fetch opens at the start and writes nothing. Initial bookmarks likewise come from their endpoint instead of the embed; annotations already did. - Progress saves are gated on deliberate navigation only (page turns, keys, slider, search/TOC/bookmark/back-stack jumps, tap zones — each marks the session as user-moved). Restores and section-load relocations never write, so displaying a position can no longer clobber a newer one. A bfcache-resurrected page resets the flag and cannot write its frozen position either. This replaces the old five-second post-init suppression, which a stale page bypassed. - The server-rendered initial progress badges render a neutral placeholder until the first relocate fills them (sub-second). No API, schema, or sync-engine changes. Normal reading saves exactly as before — the first save now simply waits for the first real page turn.
This commit is contained in:
+74
-21
@@ -482,7 +482,11 @@ document.addEventListener("alpine:init", () => {
|
||||
tocItems: [] as any[],
|
||||
mediaItemId: "" as string,
|
||||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||
initTime: 0 as number,
|
||||
// Set only by deliberate navigation (page turns, jumps, slider). The
|
||||
// restore at open time and section-load relocations never set it, so
|
||||
// progress saves can only ever write a position the user actually
|
||||
// moved to — never a stale restore clobbering a newer device push.
|
||||
userMoved: false as boolean,
|
||||
contextText: "" as string,
|
||||
readingTheme: "light" as string,
|
||||
readingMode: "light" as string,
|
||||
@@ -564,20 +568,13 @@ document.addEventListener("alpine:init", () => {
|
||||
formatGroup: string;
|
||||
readingDirection: string;
|
||||
mangaType: string;
|
||||
savedPercentage?: number;
|
||||
savedCfi?: string;
|
||||
savedPage?: number;
|
||||
savedTotalPages?: number;
|
||||
bookmarks?: {
|
||||
id: string;
|
||||
title: string;
|
||||
positionLabel: string;
|
||||
cfi: string;
|
||||
page: number | null;
|
||||
}[];
|
||||
}) {
|
||||
this.mediaItemId = config.mediaItemId;
|
||||
this.bookmarkItems = config.bookmarks ?? [];
|
||||
// Reading state (position, bookmarks, annotations) is never baked
|
||||
// into the rendered page: the web reader is intrinsically tied to
|
||||
// the server, so it reads all of it from the APIs at open time —
|
||||
// a device sync between render and open can never be shadowed by a
|
||||
// stale snapshot.
|
||||
this.isComic = config.formatGroup === "comic_archive";
|
||||
// Reading flow for comics is a per-book preference (a webtoon title
|
||||
// vs. a paged manga volume); read before the renderer is chosen.
|
||||
@@ -916,15 +913,18 @@ document.addEventListener("alpine:init", () => {
|
||||
document.addEventListener("keydown", (ev: KeyboardEvent) =>
|
||||
this.handleKeydown(ev),
|
||||
);
|
||||
if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) {
|
||||
// Reading position comes from the database, fetched fresh at open
|
||||
// (the rendered page carries no snapshot of it).
|
||||
const saved = await this.fetchSavedLocation();
|
||||
if (this.isFixedLayout && saved.page != null && saved.page > 0) {
|
||||
// Fixed-layout & comics: a page index is the exact, universal locator.
|
||||
// A bare number navigates directly to the section index in foliate.
|
||||
await this.view.init({ lastLocation: config.savedPage - 1 })
|
||||
} else if (config.savedCfi) {
|
||||
await this.view.init({ lastLocation: config.savedCfi })
|
||||
} else if (config.savedPercentage && config.savedPercentage > 0) {
|
||||
await this.view.init({ lastLocation: saved.page - 1 })
|
||||
} else if (saved.cfi) {
|
||||
await this.view.init({ lastLocation: saved.cfi })
|
||||
} else if (saved.percentage != null && saved.percentage > 0) {
|
||||
await this.view.init({
|
||||
lastLocation: { fraction: config.savedPercentage },
|
||||
lastLocation: { fraction: saved.percentage },
|
||||
})
|
||||
} else {
|
||||
await this.view.init({})
|
||||
@@ -938,9 +938,14 @@ document.addEventListener("alpine:init", () => {
|
||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||||
}
|
||||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||||
this.initTime = Date.now();
|
||||
// A bfcache-resurrected page is stale by definition: forbid it from
|
||||
// writing its frozen position back until the user navigates again.
|
||||
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
||||
if (e.persisted) this.userMoved = false;
|
||||
});
|
||||
this.fetchReadingSpeed();
|
||||
this.refreshAnnotations();
|
||||
this.refreshBookmarks();
|
||||
this.setupChrome();
|
||||
this.setupTapZones();
|
||||
},
|
||||
@@ -1536,8 +1541,44 @@ document.addEventListener("alpine:init", () => {
|
||||
/* ignore note errors */
|
||||
}
|
||||
},
|
||||
// Fresh reading position from the database — the single source of
|
||||
// truth at open time. Fails soft to a fresh start: the userMoved gate
|
||||
// guarantees merely opening (even at the wrong spot) can never
|
||||
// overwrite the stored position.
|
||||
async fetchSavedLocation(): Promise<{
|
||||
cfi?: string;
|
||||
page?: number;
|
||||
percentage?: number;
|
||||
}> {
|
||||
const token = getToken();
|
||||
if (!token || !this.mediaItemId) return {};
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/media-items/${this.mediaItemId}/progress`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
if (!resp.ok) return {};
|
||||
const row: any = await resp.json();
|
||||
const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? "";
|
||||
const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0;
|
||||
// The stored percentage is a 0-1 fraction.
|
||||
const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0;
|
||||
return {
|
||||
cfi: typeof cfi === "string" ? cfi : "",
|
||||
page: typeof page === "number" ? page : 0,
|
||||
percentage: typeof pct === "number" ? pct : 0,
|
||||
};
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||
if (Date.now() - this.initTime < 5000) return;
|
||||
// Only deliberate navigation writes progress: displaying a restored
|
||||
// position must never overwrite a newer device push.
|
||||
if (!this.userMoved) return;
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
this.saveProgress(fraction, location, cfi);
|
||||
@@ -1678,18 +1719,23 @@ document.addEventListener("alpine:init", () => {
|
||||
saveSettings({ double_page_spread: this.doublePageSpread });
|
||||
},
|
||||
goLeft() {
|
||||
this.userMoved = true;
|
||||
this.view?.goLeft?.();
|
||||
},
|
||||
goRight() {
|
||||
this.userMoved = true;
|
||||
this.view?.goRight?.();
|
||||
},
|
||||
nextPage() {
|
||||
this.userMoved = true;
|
||||
this.view?.next?.();
|
||||
},
|
||||
previousPage() {
|
||||
this.userMoved = true;
|
||||
this.view?.prev?.();
|
||||
},
|
||||
goToFraction(value: string) {
|
||||
this.userMoved = true;
|
||||
this.view?.goToFraction?.(parseFloat(value));
|
||||
},
|
||||
toggleTOC() {
|
||||
@@ -1868,9 +1914,11 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||||
if (item.cfi) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(item.cfi);
|
||||
} else if (item.page != null) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view?.goTo?.(item.page);
|
||||
} else return;
|
||||
@@ -1899,6 +1947,7 @@ document.addEventListener("alpine:init", () => {
|
||||
goBackToLocation() {
|
||||
const loc = this.backStack.pop();
|
||||
if (!loc) return;
|
||||
this.userMoved = true;
|
||||
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
||||
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
||||
},
|
||||
@@ -1914,6 +1963,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToTOCItem(item: any) {
|
||||
if (this.view && item.href) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view.goTo(item.href);
|
||||
this.tocOpen = false;
|
||||
@@ -2041,6 +2091,7 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
goToPage(index: number) {
|
||||
if (!this.view || typeof index !== "number" || index < 0) return;
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view.goTo(index);
|
||||
this.tocOpen = false;
|
||||
@@ -2048,9 +2099,11 @@ document.addEventListener("alpine:init", () => {
|
||||
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||
if (!this.view) return;
|
||||
if (item.cfi) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
this.view.goTo(item.cfi);
|
||||
} else if (item.page != null && item.page > 0) {
|
||||
this.userMoved = true;
|
||||
this.pushBackStack();
|
||||
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||||
this.view.goTo(item.page - 1);
|
||||
|
||||
Reference in New Issue
Block a user