Reader: stale reading sessions never move "Continue" back
A reader tab left open on one device (or restored by the browser) used to save its old chapter as the newest position as soon as it was shown again. Progress saves now carry the tab id and the newest progress time the tab knows (`seen`); the server refuses a save when another tab or device saved a newer position since. The reader then asks: jump to the newest position, or keep reading here (which makes this chapter the head again). It checks on open, when the tab becomes visible, and on every save. Reader links carry `at` and the reader keeps it in the address bar, so reloads and restored tabs still know what they had seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03767d5704
commit
618992ec14
9 files changed
+228
-24
No files matched your search
+62
-7
@@ -155,6 +155,55 @@
|
||||
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
|
||||
if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; }
|
||||
|
||||
// Stale reading sessions: a tab left open (or restored by the browser) on another device must not move
|
||||
// the "Continue" point back. `seen` is the newest progress time this tab knows: the link's `at`, kept
|
||||
// current in the address bar, so a reload or restored tab still knows it. Without `at` the open was a
|
||||
// deliberate one (bookmark, notification) and starts from the current head.
|
||||
const tab = tabId();
|
||||
let seen = Number(P.get('at')) || (D.latest ? D.latest.t : 0), stale = null, staleCard = null;
|
||||
const chapterUrl = (c, v) => readUrl(D.manga.id, c, v, seen);
|
||||
const newer = latest => !!latest && latest.t > seen && latest.w !== tab;
|
||||
function markSeen(t) {
|
||||
seen = Math.max(seen, t || 0);
|
||||
const q = new URLSearchParams(location.search);
|
||||
q.set('at', seen);
|
||||
history.replaceState(history.state, '', '?' + q);
|
||||
}
|
||||
// Another tab or device saved a newer position. The same place is no conflict; anything else asks.
|
||||
function checkLatest(latest) {
|
||||
if (!newer(latest)) return;
|
||||
if (latest.c === D.chapter.id && list[cur] && latest.p === list[cur].i) { markSeen(latest.t); return; }
|
||||
showStale(latest);
|
||||
}
|
||||
function showStale(latest) {
|
||||
stale = latest;
|
||||
clearTimeout(saveTimer);
|
||||
if (staleCard) staleCard.remove();
|
||||
const where = latest.done ? `${latest.label} (finished)` : `${latest.label} · page ${latest.p + 1}`;
|
||||
const go = h('a', { class: 'btn primary', href: readUrl(D.manga.id, latest.continue.c, latest.continue.v, latest.t) }, latest.continue.text);
|
||||
const stay = h('button', { class: 'btn', type: 'button' }, 'Keep reading here');
|
||||
stay.onclick = () => {
|
||||
staleCard.remove(); staleCard = null; stale = null;
|
||||
markSeen(latest.t);
|
||||
saveProgress();
|
||||
stage.focus({ preventScroll: true });
|
||||
};
|
||||
staleCard = h('div', { class: 'stale-session', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'stale-title' },
|
||||
h('div', { class: 'stale-box' },
|
||||
h('h2', { id: 'stale-title' }, 'You have read further since'),
|
||||
h('p', {}, 'This page is an older reading session. Your newest reading position is ', h('b', {}, where), ` (${ago(latest.t)}).`),
|
||||
h('div', { class: 'stale-actions' }, go, stay),
|
||||
h('p', { class: 'muted small' }, `Keeping ${D.chapter.label} makes it your “Continue” point again.`)));
|
||||
document.body.append(staleCard);
|
||||
go.focus();
|
||||
}
|
||||
async function recheck() {
|
||||
if (stale || document.hidden) return;
|
||||
try { checkLatest((await api('api/progress/latest?' + qsa({ m: D.manga.id }))).latest); } catch { /* offline: the next save checks again */ }
|
||||
}
|
||||
document.addEventListener('visibilitychange', recheck);
|
||||
window.addEventListener('pageshow', e => { if (e.persisted) recheck(); });
|
||||
|
||||
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
|
||||
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
|
||||
function computeSpreads() {
|
||||
@@ -240,7 +289,7 @@
|
||||
const n = D.next;
|
||||
return h('div', { class: 'end' }, h('div', { class: 'endbox' },
|
||||
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')),
|
||||
n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
|
||||
n ? h('a', { class: 'btn primary', href: chapterUrl(n.id), onclick: e => { e.currentTarget.href = chapterUrl(n.id); saveProgress(true); } }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
|
||||
: h('div', {}, 'You are up to date.'),
|
||||
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
|
||||
}
|
||||
@@ -589,7 +638,7 @@
|
||||
function goChapter(c) {
|
||||
if (!c) return toast('You are up to date');
|
||||
saveProgress(true);
|
||||
location.href = readUrl(D.manga.id, c.id);
|
||||
location.href = chapterUrl(c.id);
|
||||
}
|
||||
// Flow: jump to the top of the next page, but never further than ~one screen.
|
||||
function flowStep(dir) {
|
||||
@@ -701,8 +750,13 @@
|
||||
}
|
||||
function saveProgress(keepalive) {
|
||||
clearTimeout(saveTimer);
|
||||
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] });
|
||||
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
|
||||
if (stale) return; // nothing is saved until the reader chose where to go on
|
||||
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift], tab, seen });
|
||||
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body })
|
||||
.then(r => r.json()).then(r => {
|
||||
if (r.stale) checkLatest(r.latest);
|
||||
else if (r.ok && r.t) markSeen(r.t);
|
||||
}).catch(() => {});
|
||||
}
|
||||
addEventListener('pagehide', () => saveProgress(true));
|
||||
|
||||
@@ -717,17 +771,17 @@
|
||||
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`));
|
||||
}
|
||||
chsel.value = D.chapter.id;
|
||||
chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); };
|
||||
chsel.onchange = () => { saveProgress(true); location.href = chapterUrl(chsel.value); };
|
||||
$('#back').href = mangaUrl(D.manga.id);
|
||||
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
|
||||
const a = $(id);
|
||||
if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); }
|
||||
if (c) { a.href = chapterUrl(c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => { a.href = chapterUrl(c.id); saveProgress(true); }; }
|
||||
else a.classList.add('disabled');
|
||||
}
|
||||
for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : '')));
|
||||
ver.value = D.version;
|
||||
ver.hidden = D.versions.length < 2;
|
||||
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); };
|
||||
ver.onchange = () => { saveProgress(true); location.href = chapterUrl(D.chapter.id, ver.value); };
|
||||
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
|
||||
toneButton.onclick = cycleTone;
|
||||
$('#moment').onclick = saveMoment;
|
||||
@@ -748,6 +802,7 @@
|
||||
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
|
||||
|
||||
setMode(mode, false);
|
||||
if (newer(D.latest)) checkLatest(D.latest); else markSeen(seen);
|
||||
if (S.readingClock) readingClock = createReadingClock({
|
||||
button: $('#readclock'),
|
||||
panel: $('#clockpanel'),
|
||||
|
||||
Reference in new issue
Block a user