| 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, |
| }; |
|
|
| |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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); |
| }); |
| } |
|
|
| |
| |
| |
| 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}`); |
| } |
| } |
|
|
| |
| window.addEventListener("popstate", (event) => { |
| const view = event.state?.view || "shelf"; |
| showView(view, false); |
| }); |
|
|
| |
| |
| |
| function coverMarkup(book, { id = "" } = {}) { |
| const src = `/covers/${encodeURIComponent(book.cover)}`; |
| const idAttr = id ? ` id="${id}"` : ""; |
| return `<div class="book-cover"${idAttr}><img class="book-cover__img" src="${src}" alt="${book.title} cover" /></div>`; |
| } |
|
|
| |
| |
| |
| 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); |
| }); |
| } |
|
|
| |
| |
| |
| 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; |
| |
| |
| 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(); |
| } |
|
|
| |
| |
| |
| 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"; |
| } |
|
|
| |
| |
| |
| 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; |
| |
| |
| 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."); |
| } |
| } |
|
|
| |
| |
| |
| function init() { |
| renderShelf(); |
| |
| |
| const hash = window.location.hash.slice(1); |
| const hasState = loadState(); |
| |
| if (hasState && hash === "session-setup" && state.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) { |
| |
| 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 { |
| |
| 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(); |
|
|