import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
const BOOKS = window.READING_BUDDY_BOOKS || [];
const state = {
book: null,
chapter: 1,
};
/* --------------------------------------------------------------------------- */
/* State persistence */
/* --------------------------------------------------------------------------- */
function saveState() {
if (state.book) {
localStorage.setItem("reading-buddy-state", JSON.stringify({
bookId: state.book.id,
chapter: state.chapter,
}));
}
}
function loadState() {
try {
const saved = localStorage.getItem("reading-buddy-state");
if (saved) {
const data = JSON.parse(saved);
const book = BOOKS.find(b => b.id === data.bookId);
if (book) {
state.book = book;
state.chapter = data.chapter || 1;
return true;
}
}
} catch (e) {
console.warn("Failed to load saved state:", e);
}
return false;
}
/* Bind a handler to respond to both touch and click without double-firing.
On touch devices the touchend runs immediately; a flag suppresses the
browser's subsequent ghost click. */
function addTap(el, handler) {
let touchedAt = 0;
el.addEventListener(
"touchend",
(event) => {
touchedAt = Date.now();
handler(event);
},
{ passive: true }
);
el.addEventListener("click", (event) => {
if (Date.now() - touchedAt < 500) return;
handler(event);
});
}
/* --------------------------------------------------------------------------- */
/* View navigation */
/* --------------------------------------------------------------------------- */
function showView(name, pushHistory = true) {
document.querySelectorAll(".view").forEach((view) => {
view.classList.toggle("is-active", view.dataset.view === name);
});
window.scrollTo({ top: 0, behavior: "smooth" });
if (pushHistory) {
history.pushState({ view: name }, "", `#${name}`);
}
}
// Handle browser back/forward buttons
window.addEventListener("popstate", (event) => {
const view = event.state?.view || "shelf";
showView(view, false);
});
/* --------------------------------------------------------------------------- */
/* Cover rendering - uses the supplied JPEG art served from /covers */
/* --------------------------------------------------------------------------- */
function coverMarkup(book, { id = "" } = {}) {
const src = `/covers/${encodeURIComponent(book.cover)}`;
const idAttr = id ? ` id="${id}"` : "";
return `

`;
}
/* --------------------------------------------------------------------------- */
/* VIEW 1: Shelf */
/* --------------------------------------------------------------------------- */
function renderShelf() {
const row = document.getElementById("shelf-row");
row.innerHTML = "";
BOOKS.forEach((book) => {
const btn = document.createElement("button");
btn.className = "shelf__book";
btn.innerHTML = coverMarkup(book);
addTap(btn, () => openSetup(book));
row.appendChild(btn);
});
}
/* --------------------------------------------------------------------------- */
/* VIEW 2: Session Setup */
/* --------------------------------------------------------------------------- */
function openSetup(book) {
state.book = book;
state.chapter = 1;
saveState();
document.getElementById("setup-cover").outerHTML = coverMarkup(book, { id: "setup-cover" });
document.getElementById("setup-title").textContent = book.title;
document.getElementById("setup-byline").textContent = `${book.author} \u00b7 ${book.year}`;
document.getElementById("setup-chapters").textContent = `${book.chapters} Chapters`;
updateChapter(1);
showView("session-setup");
}
function updateChapter(value) {
const max = state.book ? state.book.chapters : 1;
state.chapter = Math.min(Math.max(1, value), max);
document.getElementById("chapter-value").textContent = state.chapter;
// Also update reading session display if it exists
const sessionChapterEl = document.getElementById("reading-session-chapter");
const sessionChapterNumEl = document.getElementById("session-chapter-num");
if (sessionChapterEl) {
sessionChapterEl.textContent = `Chapter ${state.chapter}`;
}
if (sessionChapterNumEl) {
sessionChapterNumEl.textContent = state.chapter;
}
saveState();
}
/* --------------------------------------------------------------------------- */
/* VIEW 3: Reading session */
/* --------------------------------------------------------------------------- */
function openReadingSession() {
const book = state.book;
document.getElementById("reading-session-author").textContent = book.author;
document.getElementById("reading-session-title").textContent = book.title;
updateReadingSessionChapter();
setMicState("idle", "Tap to ask a question");
showView("reading-session");
}
function updateReadingSessionChapter() {
document.getElementById("reading-session-chapter").textContent = `Chapter ${state.chapter}`;
document.getElementById("session-chapter-num").textContent = state.chapter;
}
const STATUS_TEXT = {
idle: "Tap to ask a question",
recording: "Listening\u2026 tap mic when finished",
processing: "Thinking\u2026",
playing: "Speaking\u2026 tap to stop",
"ready-to-play": "Tap to hear the answer",
error: "Something went quiet. Tap to try again.",
};
const MIC_LABELS = {
idle: "Tap to ask a question",
recording: "Tap when finished speaking",
processing: "Thinking",
playing: "Tap to stop the answer",
"ready-to-play": "Tap to hear the answer",
error: "Tap to try again",
};
function setMicState(micState, statusOverride) {
document.getElementById("mic").dataset.state = micState;
document.getElementById("reading-session-status").textContent =
statusOverride ?? STATUS_TEXT[micState] ?? "";
document.getElementById("mic-button").ariaLabel =
MIC_LABELS[micState] ?? MIC_LABELS.idle;
document.getElementById("cancel-recording").hidden = micState !== "recording";
}
/* --------------------------------------------------------------------------- */
/* Voice capture + backend call */
/* --------------------------------------------------------------------------- */
let gradioClient = null;
let mediaRecorder = null;
let recordedChunks = [];
let recordingDiscarded = false;
let pendingAnswerUrl = null;
let activePlayResolve = null;
function stopAnswer() {
const audio = document.getElementById("answer-audio");
audio.pause();
audio.currentTime = 0;
pendingAnswerUrl = null;
setMicState("idle");
if (activePlayResolve) {
const resolve = activePlayResolve;
activePlayResolve = null;
resolve();
}
}
function resolveAudioUrl(answer) {
const raw =
typeof answer === "string" ? answer : answer?.url || answer?.path || null;
if (!raw) return null;
if (raw.startsWith("http") || raw.startsWith("/")) return raw;
return `/gradio_api/file=${raw}`;
}
async function getClient() {
if (!gradioClient) {
gradioClient = await Client.connect(window.location.origin);
}
return gradioClient;
}
async function startRecording() {
recordingDiscarded = false;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
recordedChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) recordedChunks.push(event.data);
});
mediaRecorder.addEventListener("stop", () => {
stream.getTracks().forEach((track) => track.stop());
if (recordingDiscarded) {
recordedChunks = [];
mediaRecorder = null;
setMicState("idle");
return;
}
const blob = new Blob(recordedChunks, { type: mediaRecorder.mimeType });
mediaRecorder = null;
sendQuestion(blob);
});
mediaRecorder.start();
setMicState("recording");
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state === "recording") {
recordingDiscarded = false;
mediaRecorder.stop();
}
}
function cancelRecording() {
if (mediaRecorder && mediaRecorder.state === "recording") {
recordingDiscarded = true;
mediaRecorder.stop();
}
}
async function sendQuestion(blob) {
setMicState("processing");
try {
const file = new File([blob], "question.webm", { type: blob.type || "audio/webm" });
const client = await getClient();
console.info("[ask] sending chapter=%s book_id=%s", state.chapter, state.book.id);
const result = await client.predict("/ask", {
audio: handle_file(file),
book_id: state.book.id,
chapter: state.chapter,
});
const outputs = result?.data ?? result;
const answer = Array.isArray(outputs) ? outputs[0] : outputs;
const url = resolveAudioUrl(answer);
if (!url) throw new Error("No audio returned from backend");
await playAnswer(url);
} catch (err) {
console.error("Reading Buddy ask failed:", err);
setMicState("error");
}
}
function playAnswer(url) {
const resolved = resolveAudioUrl(url);
const audio = document.getElementById("answer-audio");
return new Promise((resolve) => {
activePlayResolve = resolve;
audio.onended = () => {
activePlayResolve = null;
pendingAnswerUrl = null;
setMicState("idle");
resolve();
};
audio.onerror = () => {
activePlayResolve = null;
console.error("Audio playback failed:", audio.error);
pendingAnswerUrl = resolved;
setMicState("ready-to-play");
resolve();
};
audio.src = resolved;
setMicState("playing");
audio.play().catch((err) => {
activePlayResolve = null;
// After a long Modal round-trip the original tap gesture is gone, so
// browsers block autoplay — keep the URL and let the user tap to play.
console.warn("Autoplay blocked, waiting for tap:", err);
pendingAnswerUrl = resolved;
setMicState("ready-to-play");
resolve();
});
});
}
async function onMicTap() {
const micState = document.getElementById("mic").dataset.state;
if (micState === "processing") return;
if (micState === "playing") {
stopAnswer();
return;
}
if (micState === "ready-to-play" && pendingAnswerUrl) {
await playAnswer(pendingAnswerUrl);
return;
}
if (micState === "recording") {
stopRecording();
return;
}
pendingAnswerUrl = null;
try {
await startRecording();
} catch (err) {
console.error("Microphone access failed:", err);
setMicState("error", "Microphone unavailable. Check permissions.");
}
}
/* --------------------------------------------------------------------------- */
/* Wire up events */
/* --------------------------------------------------------------------------- */
function init() {
renderShelf();
// Try to restore saved state and view
const hash = window.location.hash.slice(1);
const hasState = loadState();
if (hasState && hash === "session-setup" && state.book) {
// Restore session-setup view with saved book
document.getElementById("setup-cover").outerHTML = coverMarkup(state.book, { id: "setup-cover" });
document.getElementById("setup-title").textContent = state.book.title;
document.getElementById("setup-byline").textContent = `${state.book.author} \u00b7 ${state.book.year}`;
document.getElementById("setup-chapters").textContent = `${state.book.chapters} Chapters`;
document.getElementById("chapter-value").textContent = state.chapter;
showView("session-setup", false);
history.replaceState({ view: "session-setup" }, "", "#session-setup");
} else if (hasState && hash === "reading-session" && state.book) {
// Restore reading-session view with saved book
document.getElementById("reading-session-author").textContent = state.book.author;
document.getElementById("reading-session-title").textContent = state.book.title;
updateReadingSessionChapter();
setMicState("idle", "Tap to ask a question");
showView("reading-session", false);
history.replaceState({ view: "reading-session" }, "", "#reading-session");
} else {
// Default to shelf
showView("shelf", false);
history.replaceState({ view: "shelf" }, "", "#shelf");
}
addTap(document.getElementById("chapter-prev"), () => updateChapter(state.chapter - 1));
addTap(document.getElementById("chapter-next"), () => updateChapter(state.chapter + 1));
addTap(document.getElementById("begin-reading"), openReadingSession);
addTap(document.getElementById("setup-back"), () => showView("shelf"));
addTap(document.getElementById("session-chapter-prev"), () => updateChapter(state.chapter - 1));
addTap(document.getElementById("session-chapter-next"), () => updateChapter(state.chapter + 1));
addTap(document.getElementById("reading-session-back"), () => {
cancelRecording();
stopAnswer();
showView("session-setup");
});
addTap(document.getElementById("cancel-recording"), cancelRecording);
addTap(document.getElementById("mic-button"), onMicTap);
}
init();