Files
Pete/internal/web/static/js/reader.js
prosolis dbcb459908 Add server-side Piper read-aloud with voice picker
Reader read-aloud now streams neural WAV audio from a new POST /api/tts
endpoint that shells out to Piper, instead of the browser's Web Speech
voice. Each paragraph is synthesized on demand with the next one
prefetched during playback, keeping the existing highlight/scroll sync.

Voices are configured under [web.tts] (piper binary + voices_dir + a
labelled voice list) and exposed to the client as window.PETE_TTS; the
reader gets a Voice selector in the Aa menu, persisted per-device. Still
a signed-in-only perk and gated on auth.
2026-07-07 23:00:27 -07:00

805 lines
32 KiB
JavaScript

// Feed / reader mode. Presents the stories currently on the page one at a
// time in a focused overlay, marking each read as it's shown. Left/right arrow
// keys (or the on-screen buttons) page through them; the full article text is
// whatever Pete captured at ingest (content:encoded or the scraped body),
// fetched on demand from /api/article and cached for the session.
//
// Read state lives in localStorage under pete.read.v1. It's deliberately
// device-local — unlike the small prefs blob, the read set grows unbounded, so
// it isn't mirrored through PetePrefs to the account.
(function () {
var overlay = document.getElementById("pete-reader");
if (!overlay) return;
var READ_KEY = "pete.read.v1";
var articleEl = overlay.querySelector("[data-reader-article]");
var scrollEl = overlay.querySelector("[data-reader-scroll]");
var progressEl = overlay.querySelector("[data-reader-progress]");
var linkEl = overlay.querySelector("[data-reader-link]");
var prevBtn = overlay.querySelector("[data-reader-prev]");
var nextBtn = overlay.querySelector("[data-reader-next]");
var closeBtn = overlay.querySelector("[data-reader-close]");
var backdrop = overlay.querySelector("[data-reader-backdrop]");
var readerBookmarkBtn = overlay.querySelector("[data-reader-bookmark]");
var listenBtn = overlay.querySelector("[data-reader-listen]");
var shareBtn = overlay.querySelector("[data-reader-share]");
var typeBtn = overlay.querySelector("[data-reader-type]");
var typeMenu = overlay.querySelector("[data-reader-typemenu]");
var voiceRow = overlay.querySelector("[data-reader-voicerow]");
var voiceSel = overlay.querySelector("[data-reader-voice]");
var relatedEl = overlay.querySelector("[data-reader-related]");
var relatedCache = {}; // id -> results array
// Signed-in users (Authentik) get read + bookmark state synced server-side;
// window.PETE_USER is non-null for them. Anonymous visitors keep the
// localStorage-only behaviour and never see the bookmark controls.
var SIGNED_IN = !!(window.PETE_USER);
var bookmarkSet = Object.create(null); // id -> 1 for bookmarked stories
// Read-aloud is a signed-in-only perk, backed by server-side Piper voices
// (window.PETE_TTS) rather than the browser's robotic Web Speech voice. Share
// is available to everyone (native sheet where present, clipboard otherwise).
var TTS_CFG = (window.PETE_TTS && window.PETE_TTS.enabled &&
window.PETE_TTS.voices && window.PETE_TTS.voices.length) ? window.PETE_TTS : null;
var TTS_OK = SIGNED_IN && !!TTS_CFG;
var items = []; // {id, url, headline, lede, image, time, source, chTitle, chEmoji, chTheme, posted, paywalled}
var index = 0;
var open = false;
var contentCache = {}; // id -> {content, lede}
var inflight = null;
var bodyReady = false; // true once the real article body is rendered (not the loading placeholder)
// ---- read-state store -----------------------------------------------------
function loadRead() {
try {
var raw = localStorage.getItem(READ_KEY);
var obj = raw ? JSON.parse(raw) : {};
return obj && typeof obj === "object" ? obj : {};
} catch (e) { return {}; }
}
function saveRead(set) {
try { localStorage.setItem(READ_KEY, JSON.stringify(set)); } catch (e) {}
}
var readSet = loadRead();
function isRead(id) { return !!readSet[id]; }
function setRead(id, on) {
if (on) readSet[id] = 1; else delete readSet[id];
saveRead(readSet);
paintCard(id, on);
if (SIGNED_IN) postState("/api/read", { id: Number(id), read: !!on });
}
// ---- server sync (signed-in only) -----------------------------------------
function postState(url, body) {
try {
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
credentials: "same-origin",
keepalive: true
}).catch(function () {});
} catch (e) {}
}
function isBookmarked(id) { return !!bookmarkSet[id]; }
// setBookmark updates memory, paints every matching control, and persists.
function setBookmark(id, on) {
if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
paintBookmark(id, on);
postState("/api/bookmark", { id: Number(id), on: !!on });
// On the bookmarks page, an un-bookmark should drop the card immediately.
if (!on && location.pathname === "/bookmarks") {
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
c.parentNode && c.parentNode.removeChild(c);
});
}
}
// setBookmarkQuiet applies server-provided state without echoing it back.
function setBookmarkQuiet(id, on) {
if (on) bookmarkSet[id] = 1; else delete bookmarkSet[id];
paintBookmark(id, on);
}
function paintBookmark(id, on) {
document.querySelectorAll('[data-bookmark-btn][data-story-id="' + cssEsc(id) + '"]').forEach(function (b) {
applyCardBookmark(b, on);
});
if (readerBookmarkBtn && items[index] && String(items[index].id) === String(id)) {
applyReaderBookmark(on);
}
}
function applyCardBookmark(btn, on) {
btn.setAttribute("aria-pressed", on ? "true" : "false");
var svg = btn.querySelector("svg");
if (on) {
btn.style.background = "var(--accent)";
btn.style.color = "#1c1305";
if (svg) svg.setAttribute("fill", "currentColor");
} else {
btn.style.background = "rgba(20,14,6,.62)";
btn.style.color = "#fff";
if (svg) svg.setAttribute("fill", "none");
}
}
function applyReaderBookmark(on) {
if (!readerBookmarkBtn) return;
readerBookmarkBtn.setAttribute("aria-pressed", on ? "true" : "false");
readerBookmarkBtn.textContent = on ? "🔖 Saved" : "🔖 Save";
}
// initUserState reveals the bookmark controls and pulls the signed-in user's
// read + bookmark state for the stories on this page, painting them and
// migrating any device-local reads the account doesn't have yet.
function initUserState() {
if (!SIGNED_IN) return;
document.querySelectorAll("[data-bookmark-btn]").forEach(function (b) {
b.style.display = "inline-flex";
});
var ids = [];
document.querySelectorAll("[data-story-card]").forEach(function (c) {
var id = c.getAttribute("data-id");
if (id) ids.push(id);
});
if (!ids.length) return;
fetch("/api/state?ids=" + encodeURIComponent(ids.join(",")), { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var serverRead = Object.create(null);
(data.read || []).forEach(function (id) {
serverRead[id] = 1; readSet[id] = 1; paintCard(id, true);
});
(data.bookmarked || []).forEach(function (id) { setBookmarkQuiet(id, true); });
// Migrate device-local reads the account doesn't have yet — but only
// once per device. After the first sync the server is authoritative, so
// a story the user later marks unread on another device stays unread
// instead of being perpetually resurrected from this device's stale
// local set on every page load.
var MIGRATED_KEY = "pete.readMigrated.v1";
var migrated = false;
try { migrated = localStorage.getItem(MIGRATED_KEY) === "1"; } catch (e) {}
if (!migrated) {
ids.forEach(function (id) {
if (readSet[id] && !serverRead[id]) postState("/api/read", { id: Number(id), read: true });
});
try { localStorage.setItem(MIGRATED_KEY, "1"); } catch (e) {}
}
saveRead(readSet);
})
.catch(function () {});
}
// Reflect read state onto every matching card on the page (a story can appear
// in more than one section on the home page).
function paintCard(id, on) {
document.querySelectorAll('[data-story-card][data-id="' + cssEsc(id) + '"]').forEach(function (c) {
if (on) c.setAttribute("data-read", "1");
else c.removeAttribute("data-read");
});
}
function cssEsc(s) {
return String(s).replace(/["\\]/g, "\\$&");
}
// ---- collecting the page's stories ----------------------------------------
function isVisible(el) {
return window.getComputedStyle(el).display !== "none";
}
function collect() {
var seen = Object.create(null);
var out = [];
document.querySelectorAll("[data-story-card]").forEach(function (c) {
var id = c.getAttribute("data-id");
if (!id || seen[id] || !isVisible(c)) return;
var url = c.getAttribute("data-url") || c.getAttribute("href") || "";
if (!/^https?:\/\//i.test(url)) url = "";
seen[id] = true;
out.push({
id: id,
url: url,
headline: c.getAttribute("data-headline") || "",
lede: c.getAttribute("data-lede") || "",
image: c.getAttribute("data-image") || "",
time: c.getAttribute("data-time") || "",
source: c.getAttribute("data-source") || "",
chTitle: c.getAttribute("data-ch-title") || "",
chEmoji: c.getAttribute("data-ch-emoji") || "",
chTheme: c.getAttribute("data-ch-theme") || "",
paywalled: c.getAttribute("data-paywalled") === "1"
});
});
return out;
}
// ---- rendering ------------------------------------------------------------
function escapeHTML(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function safeURL(s) {
var raw = String(s == null ? "" : s).trim();
return /^https?:\/\//i.test(raw) ? raw : "";
}
// Turn stored plain text (paragraphs separated by blank lines) into escaped
// <p> blocks. Single newlines inside a paragraph become spaces.
function paragraphs(text) {
var blocks = String(text || "").split(/\n{2,}/);
var html = "";
for (var i = 0; i < blocks.length; i++) {
var t = blocks[i].replace(/\s*\n\s*/g, " ").trim();
if (t) html += "<p>" + escapeHTML(t) + "</p>";
}
return html;
}
function eyebrow(it) {
var chip = it.chTheme
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.chTheme) + '">' +
(it.chEmoji ? '<span aria-hidden="true">' + escapeHTML(it.chEmoji) + "</span>" : "") +
escapeHTML(it.chTitle) + "</span>"
: "";
var src = it.source ? '<span class="pete-reader-source">' + escapeHTML(it.source) + "</span>" : "";
var time = it.time ? '<span class="pete-reader-time">' + escapeHTML(it.time) + "</span>" : "";
return '<div class="pete-reader-eyebrow">' + chip + src + time + "</div>";
}
function renderBody(it, bodyHTML) {
var hero = it.image
? '<img class="pete-reader-hero" src="' + escapeHTML(it.image) + '" alt="' + escapeHTML(it.headline) + '" loading="lazy" decoding="async">'
: "";
var href = safeURL(it.url);
var note = '<p class="pete-reader-note">' +
(it.paywalled ? "This source is paywalled, so the text above may be partial. " : "") +
(href ? 'Read it at the source: <a href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
escapeHTML(it.source || "original article") + " ↗</a>." : "") +
"</p>";
articleEl.innerHTML =
eyebrow(it) +
'<h1 class="pete-reader-title">' + escapeHTML(it.headline) + "</h1>" +
hero +
'<div class="pete-reader-body">' + bodyHTML + "</div>" +
note;
}
function loadingBody(it) {
bodyReady = false; // read-aloud waits for the real body, not this placeholder
renderBody(it, '<p style="opacity:.55">Loading the full story…</p>');
}
function fillContent(it, data) {
var text = (data && data.content) ? data.content : ((data && data.lede) || it.lede);
var html = paragraphs(text);
if (!html) {
html = '<p style="opacity:.7">No article text was captured for this story. Open the original to read it.</p>';
}
renderBody(it, html);
bodyReady = true;
}
function fetchContent(it) {
if (contentCache[it.id]) { fillContent(it, contentCache[it.id]); return; }
loadingBody(it);
if (inflight) inflight.abort();
var ctrl = new AbortController();
inflight = ctrl;
var reqId = it.id;
fetch("/api/article?id=" + encodeURIComponent(it.id), { signal: ctrl.signal, credentials: "same-origin" })
.then(function (r) { if (!r.ok) throw new Error("status " + r.status); return r.json(); })
.then(function (data) {
contentCache[reqId] = data;
if (ctrl !== inflight) return; // superseded by a newer navigation
if (items[index] && items[index].id === reqId) fillContent(it, data);
})
.catch(function (err) {
if (err.name === "AbortError") return;
if (items[index] && items[index].id === reqId) fillContent(it, null); // fall back to lede
})
.finally(function () { if (ctrl === inflight) inflight = null; });
}
// ---- related ("you might also like") --------------------------------------
function clearRelated() {
if (!relatedEl) return;
relatedEl.innerHTML = "";
relatedEl.hidden = true;
}
function renderRelated(reqId, results) {
if (!relatedEl) return;
// Ignore a response that arrived after the user moved on.
if (!items[index] || String(items[index].id) !== String(reqId)) return;
if (!results || !results.length) { clearRelated(); return; }
var html = '<h2 class="pete-reader-related-title">You might also like</h2>' +
'<div class="pete-reader-related-grid">';
for (var i = 0; i < results.length; i++) {
var it = results[i];
var href = safeURL(it.article_url);
if (!href) continue;
var chip = it.channel_theme
? '<span class="pete-reader-chip bg-theme-' + escapeHTML(it.channel_theme) + '">' +
(it.channel_emoji ? '<span aria-hidden="true">' + escapeHTML(it.channel_emoji) + "</span>" : "") +
escapeHTML(it.channel_title) + "</span>"
: "";
var thumb = it.thumb_url
? '<img class="pete-reader-related-thumb" src="' + escapeHTML(it.thumb_url) + '" alt="' + escapeHTML(it.headline || "") + '" loading="lazy" decoding="async">'
: '<div class="pete-reader-related-thumb pete-reader-related-thumb-empty"></div>';
html += '<a class="pete-reader-related-card" href="' + escapeHTML(href) + '" target="_blank" rel="noopener noreferrer">' +
thumb +
'<div class="pete-reader-related-meta">' +
'<div class="pete-reader-related-eyebrow">' + chip +
(it.source ? '<span class="pete-reader-related-source">' + escapeHTML(it.source) + "</span>" : "") +
"</div>" +
'<div class="pete-reader-related-headline">' + escapeHTML(it.headline) + "</div>" +
"</div></a>";
}
html += "</div>";
relatedEl.innerHTML = html;
relatedEl.hidden = false;
}
function fetchRelated(it) {
if (!relatedEl) return;
clearRelated();
var reqId = it.id;
if (relatedCache[reqId]) { renderRelated(reqId, relatedCache[reqId]); return; }
fetch("/api/related?id=" + encodeURIComponent(it.id), { credentials: "same-origin" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
var results = (data && data.results) || [];
relatedCache[reqId] = results;
renderRelated(reqId, results);
})
.catch(function () {});
}
// ---- navigation -----------------------------------------------------------
function show(i) {
stopSpeak(); // never keep reading a story the user navigated away from
index = i;
if (index >= items.length) { renderDone(); return; }
var it = items[index];
progressEl.textContent = (index + 1) + " / " + items.length;
var href = safeURL(it.url);
if (href) { linkEl.href = href; linkEl.style.display = ""; }
else linkEl.style.display = "none";
prevBtn.disabled = index === 0;
nextBtn.disabled = false;
nextBtn.textContent = index === items.length - 1 ? "done ✓" : "→";
if (scrollEl) scrollEl.scrollTop = 0;
if (readerBookmarkBtn) {
readerBookmarkBtn.style.display = SIGNED_IN ? "" : "none";
applyReaderBookmark(isBookmarked(it.id));
}
fetchContent(it);
fetchRelated(it);
setRead(it.id, true); // presenting a story marks it read
}
function renderDone() {
stopSpeak();
clearRelated();
progressEl.textContent = items.length + " / " + items.length;
linkEl.style.display = "none";
prevBtn.disabled = items.length === 0;
nextBtn.disabled = true;
nextBtn.textContent = "→";
if (scrollEl) scrollEl.scrollTop = 0;
articleEl.innerHTML =
'<div class="pete-reader-empty">' +
'<span class="pete-reader-empty-emoji" aria-hidden="true">🎉</span>' +
'<h1 class="pete-reader-title" style="margin:0">You\'re all caught up</h1>' +
'<p style="opacity:.7;max-width:28rem">That\'s every story on this page. ' +
"Head back to browse more, or press ← to revisit the last one.</p>" +
"</div>";
}
function next() { if (index < items.length) show(index + 1); }
function prev() { if (index > 0) show(index - 1); }
function firstUnread() {
for (var i = 0; i < items.length; i++) if (!isRead(items[i].id)) return i;
return 0;
}
// ---- typography (Aa popover) ----------------------------------------------
// Per-device reading preferences: text size, sans/serif, default/sepia paper.
// Kept in localStorage (like the read set) rather than the synced prefs blob,
// since they're a device-local reading-comfort choice.
var TYPE_KEY = "pete.reader.type.v1";
var SIZES = ["s", "m", "l", "xl"];
var typePrefs = { size: "m", font: "sans", paper: "default" };
(function loadType() {
try {
var raw = JSON.parse(localStorage.getItem(TYPE_KEY) || "{}");
if (raw && typeof raw === "object") {
if (SIZES.indexOf(raw.size) >= 0) typePrefs.size = raw.size;
if (raw.font === "serif" || raw.font === "sans") typePrefs.font = raw.font;
if (raw.paper === "sepia" || raw.paper === "default") typePrefs.paper = raw.paper;
}
} catch (e) {}
})();
function saveType() {
try { localStorage.setItem(TYPE_KEY, JSON.stringify(typePrefs)); } catch (e) {}
}
function applyType() {
if (!scrollEl) return;
SIZES.forEach(function (s) { scrollEl.classList.remove("is-size-" + s); });
scrollEl.classList.add("is-size-" + typePrefs.size);
scrollEl.classList.toggle("is-serif", typePrefs.font === "serif");
scrollEl.classList.toggle("is-sepia", typePrefs.paper === "sepia");
paintType();
}
function paintType() {
if (!typeMenu) return;
typeMenu.querySelectorAll("[data-size]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-size") === typePrefs.size);
});
typeMenu.querySelectorAll("[data-font]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-font") === typePrefs.font);
});
typeMenu.querySelectorAll("[data-paper]").forEach(function (b) {
b.classList.toggle("is-active", b.getAttribute("data-paper") === typePrefs.paper);
});
}
function toggleTypeMenu(force) {
if (!typeMenu || !typeBtn) return;
var openNow = force != null ? force : typeMenu.hidden;
typeMenu.hidden = !openNow;
typeBtn.setAttribute("aria-expanded", openNow ? "true" : "false");
}
// ---- share ----------------------------------------------------------------
var toastEl = null, toastTimer = null;
function toast(msg) {
if (!toastEl) {
toastEl = document.createElement("div");
toastEl.className = "pete-reader-toast";
overlay.appendChild(toastEl);
}
toastEl.textContent = msg;
toastEl.classList.add("is-shown");
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-shown"); }, 1800);
}
function shareCurrent() {
var it = items[index];
if (!it) return;
var url = safeURL(it.url);
if (!url) { toast("No link to share"); return; }
var data = { title: it.headline || "Pete", text: it.headline || "", url: url };
if (navigator.share) {
navigator.share(data).catch(function () {});
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(
function () { toast("Link copied ✓"); },
function () { toast("Couldn't copy link"); }
);
return;
}
window.open(url, "_blank", "noopener");
}
// ---- read aloud (signed-in only, server-side Piper) -----------------------
// Each paragraph is synthesized on demand by POSTing its text to /api/tts and
// playing the returned WAV. We fetch the next paragraph while the current one
// plays, so the model-load + synthesis latency is hidden behind playback.
// speakGen is bumped on every stop/start/voice-change; any async callback that
// fires afterwards checks it against its captured gen and no-ops if stale.
var VOICE_KEY = "pete.reader.voice.v1";
var speaking = false;
var speakGen = 0;
var speakParas = []; // <p> elements currently highlighted
var speakItems = []; // [{el, text}] for the whole article
var speakIdx = 0; // index into speakItems currently playing
var speakReq = null; // in-flight fetch for the current paragraph (has .ctrl)
var prefetch = null; // { idx, gen, promise, abort } for the next paragraph
var audioEl = null; // shared <audio> element
var currentURL = null; // object URL currently loaded into audioEl
// Device-local voice choice, like the type prefs (see loadType).
var ttsVoice = "";
(function loadVoice() {
if (!TTS_CFG) return;
var saved = "";
try { saved = localStorage.getItem(VOICE_KEY) || ""; } catch (e) {}
var ok = TTS_CFG.voices.some(function (v) { return v.id === saved; });
ttsVoice = ok ? saved : TTS_CFG.default;
})();
function saveVoice() { try { localStorage.setItem(VOICE_KEY, ttsVoice); } catch (e) {} }
function speakingText() {
if (!articleEl) return [];
var out = [];
var h = articleEl.querySelector(".pete-reader-title");
if (h && h.textContent.trim()) out.push({ el: null, text: h.textContent.trim() });
articleEl.querySelectorAll(".pete-reader-body p").forEach(function (p) {
var t = p.textContent.trim();
if (t) out.push({ el: p, text: t });
});
return out;
}
function clearSpeakHighlight() {
speakParas.forEach(function (p) { p.classList.remove("is-speaking"); });
speakParas = [];
}
function highlight(item) {
clearSpeakHighlight();
if (item && item.el) {
item.el.classList.add("is-speaking");
speakParas.push(item.el);
try { item.el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) {}
}
}
// ttsFetch kicks off synthesis of one paragraph. Returns { ctrl, promise },
// where promise resolves to an object URL for the audio blob.
function ttsFetch(text) {
var ctrl = new AbortController();
var promise = fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ voice: ttsVoice, text: text }),
signal: ctrl.signal,
}).then(function (r) {
if (!r.ok) throw new Error("tts " + r.status);
return r.blob();
}).then(function (b) { return URL.createObjectURL(b); });
return { ctrl: ctrl, promise: promise };
}
function abortPrefetch() {
if (!prefetch) return;
try { prefetch.ctrl.abort(); } catch (e) {}
// If it already resolved to a URL, reclaim it.
prefetch.promise.then(function (u) { URL.revokeObjectURL(u); }, function () {});
prefetch = null;
}
function startPrefetch(i, gen) {
var item = speakItems[i];
if (!item) { prefetch = null; return; }
var req = ttsFetch(item.text);
prefetch = { idx: i, gen: gen, ctrl: req.ctrl, promise: req.promise };
}
function playURL(url, gen) {
if (!audioEl) audioEl = new Audio();
if (currentURL) URL.revokeObjectURL(currentURL);
currentURL = url;
audioEl.src = url;
audioEl.onended = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
audioEl.onerror = function () { if (speaking && gen === speakGen) playIdx(speakIdx + 1); };
var pr = audioEl.play();
if (pr && pr.catch) pr.catch(function () {}); // ignore autoplay/abort rejections
}
function playIdx(i) {
if (!speaking) return;
var gen = speakGen;
var item = speakItems[i];
if (!item) { stopSpeak(); return; }
speakIdx = i;
highlight(item);
var urlP;
if (prefetch && prefetch.idx === i && prefetch.gen === gen) {
urlP = prefetch.promise;
prefetch = null; // hand off; do not abort/revoke this one
} else {
abortPrefetch();
var req = ttsFetch(item.text);
speakReq = req;
urlP = req.promise;
}
urlP.then(function (url) {
if (!speaking || gen !== speakGen) { URL.revokeObjectURL(url); return; }
speakReq = null;
startPrefetch(i + 1, gen); // synthesize the next paragraph during playback
playURL(url, gen);
}).catch(function () {
if (!speaking || gen !== speakGen) return;
speakReq = null;
playIdx(i + 1); // skip a paragraph that failed rather than stalling
});
}
function teardownAudio() {
if (speakReq) { try { speakReq.ctrl.abort(); } catch (e) {} speakReq = null; }
abortPrefetch();
if (audioEl) { try { audioEl.pause(); } catch (e) {} audioEl.onended = null; audioEl.onerror = null; audioEl.removeAttribute("src"); }
if (currentURL) { URL.revokeObjectURL(currentURL); currentURL = null; }
}
function stopSpeak() {
if (!TTS_OK) return;
speakGen++;
speaking = false;
teardownAudio();
speakItems = [];
clearSpeakHighlight();
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "false"); listenBtn.textContent = "🔊 Listen"; }
}
function startSpeak() {
if (!TTS_OK) return;
if (!bodyReady) { toast("Still loading…"); return; }
var parts = speakingText();
if (!parts.length) { toast("Nothing to read yet"); return; }
speakGen++;
teardownAudio();
speaking = true;
speakItems = parts;
speakIdx = 0;
if (listenBtn) { listenBtn.setAttribute("aria-pressed", "true"); listenBtn.textContent = "⏹ Stop"; }
playIdx(0);
}
function toggleSpeak() {
if (!TTS_OK) return;
if (speaking) stopSpeak(); else startSpeak();
}
// Switching voice restarts the current paragraph with the new voice so the
// change is audible immediately, keeping our place in the article.
function changeVoice(id) {
if (!TTS_CFG) return;
ttsVoice = id;
saveVoice();
if (voiceSel) voiceSel.value = ttsVoice;
if (speaking) {
speakGen++;
teardownAudio();
playIdx(speakIdx);
}
}
function buildVoiceMenu() {
if (!voiceSel || !TTS_CFG) return;
voiceSel.innerHTML = "";
TTS_CFG.voices.forEach(function (v) {
var o = document.createElement("option");
o.value = v.id;
o.textContent = v.label;
voiceSel.appendChild(o);
});
voiceSel.value = ttsVoice;
voiceSel.addEventListener("change", function () { changeVoice(voiceSel.value); });
if (voiceRow) voiceRow.hidden = false;
}
// ---- open / close ---------------------------------------------------------
function openReader() {
items = collect();
if (items.length === 0) return;
open = true;
overlay.classList.remove("hidden");
document.body.classList.add("overflow-hidden");
applyType();
show(firstUnread());
}
function closeReader() {
open = false;
stopSpeak();
toggleTypeMenu(false);
if (inflight) { inflight.abort(); inflight = null; }
clearRelated();
overlay.classList.add("hidden");
document.body.classList.remove("overflow-hidden");
}
// ---- wiring ---------------------------------------------------------------
document.addEventListener("DOMContentLoaded", function () {
// Dim stories already read on this device.
document.querySelectorAll("[data-story-card]").forEach(function (c) {
var id = c.getAttribute("data-id");
if (id && isRead(id)) c.setAttribute("data-read", "1");
});
document.querySelectorAll("[data-reader-open]").forEach(function (b) {
b.addEventListener("click", function (e) { e.preventDefault(); openReader(); });
});
if (prevBtn) prevBtn.addEventListener("click", prev);
if (nextBtn) nextBtn.addEventListener("click", next);
if (closeBtn) closeBtn.addEventListener("click", closeReader);
if (backdrop) backdrop.addEventListener("click", closeReader);
if (readerBookmarkBtn) readerBookmarkBtn.addEventListener("click", function () {
var it = items[index];
if (it) setBookmark(it.id, !isBookmarked(it.id));
});
// Read-aloud: signed-in only, and only when server-side TTS is configured.
if (listenBtn) {
if (TTS_OK) { listenBtn.style.display = ""; listenBtn.addEventListener("click", toggleSpeak); buildVoiceMenu(); }
else listenBtn.style.display = "none";
}
// Share: available to everyone.
if (shareBtn) { shareBtn.style.display = ""; shareBtn.addEventListener("click", shareCurrent); }
// Text-options popover.
if (typeBtn) typeBtn.addEventListener("click", function (e) { e.stopPropagation(); toggleTypeMenu(); });
if (typeMenu) {
typeMenu.addEventListener("click", function (e) {
var b = e.target.closest("button");
if (!b) return;
e.stopPropagation();
if (b.hasAttribute("data-size")) typePrefs.size = b.getAttribute("data-size");
else if (b.hasAttribute("data-font")) typePrefs.font = b.getAttribute("data-font");
else if (b.hasAttribute("data-paper")) typePrefs.paper = b.getAttribute("data-paper");
else return;
saveType();
applyType();
});
}
// Close the type popover on any outside click.
document.addEventListener("click", function (e) {
if (!typeMenu || typeMenu.hidden) return;
if (e.target.closest("[data-reader-type]")) return;
toggleTypeMenu(false);
});
applyType();
initUserState();
});
// Bookmark buttons live inside the card's <a>; intercept so a tap toggles the
// bookmark instead of following the link. Delegated so it also covers cards
// that are added or removed after load.
document.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
if (!btn) return;
e.preventDefault();
e.stopPropagation();
var id = btn.getAttribute("data-story-id");
if (id) setBookmark(id, !isBookmarked(id));
});
document.addEventListener("keydown", function (e) {
if (e.key !== "Enter" && e.key !== " ") return;
var btn = e.target.closest && e.target.closest("[data-bookmark-btn]");
if (!btn) return;
e.preventDefault();
var id = btn.getAttribute("data-story-id");
if (id) setBookmark(id, !isBookmarked(id));
});
document.addEventListener("keydown", function (e) {
// Enter feed mode with `f` when nothing is focused and no other overlay is up.
if (!open && (e.key === "f" || e.key === "F") && !e.metaKey && !e.ctrlKey && !e.altKey) {
var tag = (document.activeElement && document.activeElement.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA") return;
var searchOpen = document.getElementById("pete-search");
if (searchOpen && !searchOpen.classList.contains("hidden")) return;
e.preventDefault();
openReader();
return;
}
if (!open) return;
switch (e.key) {
case "ArrowRight": case "l": case " ": e.preventDefault(); next(); break;
case "ArrowLeft": case "h": e.preventDefault(); prev(); break;
case "Escape":
e.preventDefault();
if (typeMenu && !typeMenu.hidden) toggleTypeMenu(false); // esc closes the popover first
else closeReader();
break;
case "s": case "S": e.preventDefault(); shareCurrent(); break;
case "t": case "T": e.preventDefault(); toggleTypeMenu(); break;
case "r": case "R": if (TTS_OK) { e.preventDefault(); toggleSpeak(); } break;
case "o": case "Enter": {
var it = items[index];
var href = it && safeURL(it.url);
if (href) { e.preventDefault(); window.open(href, "_blank", "noopener"); }
break;
}
case "u": {
var cur = items[index];
if (cur) setRead(cur.id, false); // let the user undo an accidental read
break;
}
case "b": case "B": {
if (!SIGNED_IN) break;
var it = items[index];
if (it) { e.preventDefault(); setBookmark(it.id, !isBookmarked(it.id)); }
break;
}
}
});
})();