diff --git a/README.md b/README.md
index a9a8b61..60e8a3f 100644
--- a/README.md
+++ b/README.md
@@ -1492,6 +1492,32 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged.
+103. **Podcast mode: your notes, read aloud, highlighted word by word.** The third segment of
+ the note-powered wizards (Study deck ⇄ Homework ⇄ Podcast) takes the same intake — photos,
+ documents, pasted text, the share target — and turns it into a spoken episode instead of
+ activities: `PODCAST_SYSTEM_PROMPT` asks for plain prose written for the ear (no markdown, no
+ lists, no "as you can see"), chaptered into 3-8 sections of 60-140 words around a
+ length target, and the new `/api/podcast` route mirrors `/api/generate`'s plumbing exactly
+ (rate limit, model gate, provider keys, heartbeats, the 2x deadline, the optional vision pass).
+ Episodes are stored like homework results (`freezmo.podcasts.v1`, zod-validated, riding the
+ backup and the encrypted sync payload) and listed in a new library tab with listen / rename /
+ delete-with-undo.
+ The player is the Spotify-lyrics part: the transcript is word-wrapped spans, and the word
+ under the voice rides a highlight that keeps itself centred as the episode plays. Two clocks
+ drive it — `onboundary` where the browser reports it (the character index maps onto the word
+ list) and a per-word length timer where it does not (iOS Safari, several Android engines);
+ the first boundary seen retires the timer for the session. Pause is a cancel plus a
+ re-speak-from-word, which makes "resume", "jump to a chapter" and "tap any word to seek" the
+ same operation, and the position is saved on pause, page-hide and unmount, so reopening an
+ episode resumes where you stopped. A speed button rebuilds the engine and keeps playing, the
+ Aero soundtrack pauses itself when the voice starts (`freezmo:foreground-audio`), and a
+ browser with no installed voice gets an honest note with the transcript still readable. The
+ script sits on one glass pane — in Aero, plain paragraphs over grass and sun failed
+ contrast, and the scan now covers this screen too. `tests/podcast-test.mjs` (17th suite)
+ stubs both the model and the speech engine: it drives a boundary event, checks the
+ pause/resume word, the chapter jump, the speed change, the stored position and the plugin's
+ 400/stream guards (26 checks).
+
102. **An API key is not a login.** "When trying to use a different provider on the web app it
glitches out completely" — a photo of the first-run gate with iOS's saved-password sheet laid
over it ("password for proxmox…"). Two causes, both fixed. (1) The key fields were
diff --git a/app/api/podcast/route.ts b/app/api/podcast/route.ts
new file mode 100644
index 0000000..6d6d071
--- /dev/null
+++ b/app/api/podcast/route.ts
@@ -0,0 +1,234 @@
+import { z } from "zod";
+
+import { extractNotes, generatePodcast, notesToText, type PipelineStage } from "@/lib/ai";
+import { getServerConfig, resolveModelName } from "@/lib/config";
+import { INERT_OLLAMA_AUTH, resolveModelTarget } from "@/lib/providers-server";
+import { friendlyErrorMessage, OllamaError } from "@/lib/ollama";
+import { rejectCrossOrigin, rejectOversizedBody, requestLocale } from "@/lib/http-guard";
+import { clientKey, rateLimit, rateLimitResponse } from "@/lib/rate-limit";
+import { acquireModelGate } from "@/lib/model-guard";
+import { translate } from "@/lib/i18n-dict";
+import { dataUrlSchema } from "@/lib/schemas";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+/** ~8 MB per image after client-side resizing; up to 16 images (photos + PDF pages). */
+const MAX_IMAGE_CHARS = 8_000_000;
+
+const requestSchema = z.object({
+ textModel: z.string().min(1).max(200).optional(),
+ visionModel: z.string().min(1).max(200).optional(),
+ images: z.array(dataUrlSchema(MAX_IMAGE_CHARS)).max(16).optional(),
+ text: z.string().max(120_000).optional(),
+ /** Target episode length in minutes. */
+ minutes: z.number().int().min(1).max(15).optional(),
+ focus: z.string().max(300).optional(),
+ /** Language the episode must be written in (usually detected by the vision step). */
+ language: z.string().max(60).optional(),
+});
+
+type StreamEvent =
+ | { type: "stage"; stage: PipelineStage; message: string }
+ | { type: "podcast"; podcast: unknown; meta: Record }
+ | { type: "error"; error: string; kind?: string }
+ | { type: "ping" };
+
+function encoder() {
+ const enc = new TextEncoder();
+ return (event: StreamEvent) => enc.encode(`${JSON.stringify(event)}\n`);
+}
+
+/** Generous but finite: ~16 compressed images plus JSON overhead. */
+const MAX_BODY_BYTES = 33554432;
+
+/**
+ * Notes → a spoken episode. Same plumbing as /api/generate (rate limit, model
+ * gate, provider keys, heartbeats, one hard deadline), one model call for the
+ * script after the optional vision pass.
+ */
+export async function POST(request: Request) {
+ const locale = requestLocale(request);
+ const crossOrigin = rejectCrossOrigin(request);
+ if (crossOrigin) return crossOrigin;
+ const oversized = rejectOversizedBody(request, MAX_BODY_BYTES);
+ if (oversized) return oversized;
+
+ const config = getServerConfig();
+
+ const limit = rateLimit(`podcast:${clientKey(request)}`, config.rateLimits.generate, config.rateLimits.windowMs);
+ if (!limit.allowed) return rateLimitResponse(limit, locale);
+
+ const resolved = resolveModelTarget(config, request, locale);
+ if ("error" in resolved) {
+ return Response.json({ error: resolved.error.message, kind: resolved.error.kind }, { status: resolved.error.status });
+ }
+ const target = resolved.target;
+ const provider = target.kind === "provider" ? target.target : undefined;
+ const baseAuth = target.kind === "provider" ? INERT_OLLAMA_AUTH : target.auth;
+
+ let payload: unknown;
+ try {
+ payload = await request.json();
+ } catch {
+ return Response.json({ error: translate(locale, "server.bodyNotJson") }, { status: 400 });
+ }
+
+ const parsed = requestSchema.safeParse(payload);
+ if (!parsed.success) {
+ return Response.json(
+ {
+ error: translate(locale, "server.invalidRequest", {
+ detail: parsed.error.issues
+ .slice(0, 3)
+ .map((issue) => `${issue.path.join(".") || "body"} ${issue.message}`)
+ .join("; "),
+ }),
+ },
+ { status: 400 },
+ );
+ }
+
+ const { images, text, minutes, focus, language } = parsed.data;
+ const imageList = (images ?? []).map(stripDataUrlPrefix).filter(Boolean);
+
+ if (imageList.length === 0 && !text?.trim()) {
+ return Response.json({ error: translate(locale, "server.needContent") }, { status: 400 });
+ }
+
+ const providerTextModel = provider ? parsed.data.textModel ?? parsed.data.visionModel : undefined;
+ const providerVisionModel = provider ? parsed.data.visionModel ?? parsed.data.textModel : undefined;
+ if (provider && !providerTextModel) {
+ return Response.json(
+ { error: translate(locale, "server.providerModelNeeded"), kind: "bad_provider" },
+ { status: 400 },
+ );
+ }
+ const textModel = provider ? providerTextModel! : config.textModel;
+ const visionModel = provider ? providerVisionModel ?? providerTextModel! : config.visionModel;
+
+ const gate = acquireModelGate(request, config, locale);
+ if ("response" in gate) return gate.response;
+
+ const encode = encoder();
+ const abort = new AbortController();
+ /* One vision pass plus one script: the same 2x deadline as the deck flow. */
+ const globalTimer = setTimeout(() => abort.abort(), config.timeoutMs * 2);
+ const onRequestAbort = () => abort.abort();
+ if (request.signal.aborted) abort.abort();
+ else request.signal.addEventListener("abort", onRequestAbort, { once: true });
+ let heartbeat: ReturnType | null = null;
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const send = (event: StreamEvent) => {
+ try {
+ controller.enqueue(encode(event));
+ } catch {
+ /* client went away */
+ }
+ };
+
+ heartbeat = setInterval(() => send({ type: "ping" }), 15_000);
+ try {
+ let sourceText: string;
+ let detectedLanguage: string | undefined = language;
+ let title: string | undefined;
+ let summary: string | undefined;
+ let repaired = false;
+
+ if (imageList.length > 0) {
+ send({ type: "stage", stage: "vision", message: "Reading your notes…" });
+ const extraction = await extractNotes({
+ model: visionModel,
+ repairModel: textModel,
+ auth: baseAuth,
+ provider,
+ images: imageList,
+ text,
+ signal: abort.signal,
+ onProgress: (message) => send({ type: "stage", stage: "vision", message }),
+ });
+ sourceText = notesToText(extraction.notes);
+ title = extraction.notes.title;
+ summary = extraction.notes.summary;
+ detectedLanguage = extraction.notes.language || language;
+ repaired = repaired || extraction.repaired || extraction.languageRepaired;
+ send({ type: "stage", stage: "generation", message: "Writing the script…" });
+ } else {
+ sourceText = text!.trim();
+ send({ type: "stage", stage: "generation", message: "Writing the script…" });
+ }
+
+ const generated = await generatePodcast({
+ model: textModel,
+ auth: baseAuth,
+ provider,
+ notes: sourceText,
+ minutes: minutes ?? 4,
+ language: detectedLanguage,
+ focus,
+ signal: abort.signal,
+ });
+
+ send({
+ type: "podcast",
+ podcast: {
+ title: generated.podcast.title || title || "Your podcast",
+ language: generated.podcast.language || detectedLanguage,
+ summary: generated.podcast.summary ?? summary,
+ sourceText,
+ sections: generated.podcast.sections,
+ },
+ meta: {
+ repaired,
+ language: generated.podcast.language || detectedLanguage || null,
+ visionModel: imageList.length ? (provider ? visionModel : resolveModelName(config.visionModel, baseAuth.directCloud)) : null,
+ textModel: provider ? textModel : resolveModelName(config.textModel, baseAuth.directCloud),
+ },
+ });
+ } catch (error) {
+ const message = friendlyErrorMessage(error, baseAuth.baseUrl || provider?.baseUrl || "", locale);
+ // Never log the request body: it may contain the user's notes.
+ console.error("[api/podcast]", error instanceof OllamaError ? `${error.kind}: ${error.message}` : error);
+ send({
+ type: "error",
+ error: message,
+ kind: error instanceof OllamaError ? error.kind : "unknown",
+ });
+ } finally {
+ if (heartbeat) clearInterval(heartbeat);
+ clearTimeout(globalTimer);
+ request.signal.removeEventListener("abort", onRequestAbort);
+ gate.gate.release();
+ try {
+ controller.close();
+ } catch {
+ /* already closed */
+ }
+ }
+ },
+ cancel() {
+ if (heartbeat) clearInterval(heartbeat);
+ clearTimeout(globalTimer);
+ request.signal.removeEventListener("abort", onRequestAbort);
+ gate.gate.release();
+ abort.abort();
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "application/x-ndjson; charset=utf-8",
+ "Cache-Control": "no-store, no-transform",
+ Connection: "keep-alive",
+ },
+ });
+}
+
+function stripDataUrlPrefix(value: string): string {
+ const commaIndex = value.indexOf(",");
+ if (value.startsWith("data:") && commaIndex !== -1) return value.slice(commaIndex + 1);
+ return value;
+}
diff --git a/app/globals.css b/app/globals.css
index 8f8d188..d8c4088 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1306,6 +1306,23 @@ body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) {
.chip { min-height: 44px; }
}
+/* Podcast transcript: the word under the voice rides a highlight, the way
+ lyrics do in Spotify. Words are plain spans; the player flips one class. */
+.podcast-word {
+ border-radius: 0.4rem;
+ transition: background-color 140ms ease, color 140ms ease;
+ cursor: pointer;
+}
+.podcast-word-active,
+.podcast-word:hover {
+ background: var(--nav-active-bg);
+ color: var(--text);
+ box-shadow: 0 0 0 2px var(--nav-active-bg);
+}
+.podcast-word-active {
+ font-weight: 600;
+}
+
/* The AI note under a surface that shows model output -------------------- */
.ai-disclaimer {
font-size: 12px;
diff --git a/app/page.tsx b/app/page.tsx
index 1b3eeaa..3086924 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -19,7 +19,9 @@ export default async function HomePage({
-
+
diff --git a/app/podcast/[id]/page.tsx b/app/podcast/[id]/page.tsx
new file mode 100644
index 0000000..47cdad3
--- /dev/null
+++ b/app/podcast/[id]/page.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import Link from "next/link";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+
+import { ApiKeyGate } from "@/components/ApiKeyGate";
+import { PodcastPlayer } from "@/components/podcast/PodcastPlayer";
+import { Button } from "@/components/ui";
+import { useI18n } from "@/lib/i18n";
+import { getPodcast } from "@/lib/podcasts";
+import type { Podcast } from "@/lib/schemas";
+
+export default function PodcastEpisodePage() {
+ const params = useParams<{ id: string }>();
+ const router = useRouter();
+ const id = typeof params.id === "string" ? params.id : "";
+ const { t } = useI18n();
+ const [episode, setEpisode] = useState(null);
+ const [loaded, setLoaded] = useState(false);
+
+ useEffect(() => {
+ if (!id) {
+ setLoaded(true);
+ return;
+ }
+ setEpisode(getPodcast(id) ?? null);
+ setLoaded(true);
+ }, [id]);
+
+ if (!loaded) {
+ return (
+
+
+
+ );
+ }
+
+ if (!episode) {
+ return (
+
+
+
+ 🎙️
+
+
{t("podcast.notFound")}
+
{t("podcast.notFoundBody")}
+
+
+ {t("review.done")}
+
+ router.push("/podcast")}>
+ {t("podcast.title")}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/podcast/page.tsx b/app/podcast/page.tsx
new file mode 100644
index 0000000..91c3b69
--- /dev/null
+++ b/app/podcast/page.tsx
@@ -0,0 +1,34 @@
+import { ApiKeyGate } from "@/components/ApiKeyGate";
+import { AppHeader } from "@/components/AppHeader";
+import { RouteTransition } from "@/components/RouteTransition";
+import { Sidebar, SidebarOffset } from "@/components/Sidebar";
+import { PodcastWizard } from "@/components/podcast/PodcastWizard";
+import { WizardModeSwitch } from "@/components/WizardModeSwitch";
+
+export const metadata = {
+ title: "New podcast",
+};
+
+/**
+ * Podcast mode's intake: the same notes as a deck, but the output is a spoken
+ * episode with a synced transcript (see components/podcast).
+ */
+export default function PodcastPage() {
+ return (
+
+
+
+ );
+}
diff --git a/components/AeroMusic.tsx b/components/AeroMusic.tsx
index 1d55826..09549a2 100644
--- a/components/AeroMusic.tsx
+++ b/components/AeroMusic.tsx
@@ -187,6 +187,17 @@ export function AeroMusic() {
};
}, [show, autoStart, playing, startFromGesture]);
+ /* Something with a voice started (the podcast player): the soundtrack
+ steps aside rather than talking over it. */
+ useEffect(() => {
+ const onForeground = () => {
+ widget.current?.pause();
+ setAutoStart(false);
+ };
+ window.addEventListener("freezmo:foreground-audio", onForeground);
+ return () => window.removeEventListener("freezmo:foreground-audio", onForeground);
+ }, []);
+
if (!show) return null;
const title = track?.title ?? FALLBACK_TITLE;
diff --git a/components/DataSection.tsx b/components/DataSection.tsx
index c32b94e..9301dfc 100644
--- a/components/DataSection.tsx
+++ b/components/DataSection.tsx
@@ -95,6 +95,7 @@ export function DataSection() {
homework: pending.preview.homework.added + pending.preview.homework.replaced,
folders: pending.preview.folders.added + pending.preview.folders.replaced,
chats: pending.preview.chats.added + pending.preview.chats.replaced,
+ podcasts: pending.preview.podcasts.added + pending.preview.podcasts.replaced,
})}
{t("settings.importMergeNote")}
diff --git a/components/LibraryScreen.tsx b/components/LibraryScreen.tsx
index 5dae38e..0234cc5 100644
--- a/components/LibraryScreen.tsx
+++ b/components/LibraryScreen.tsx
@@ -8,6 +8,7 @@ import { DeckCard } from "@/components/DeckCard";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard";
+import { PodcastList } from "@/components/podcast/PodcastList";
import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
import { SegmentedPill } from "@/components/SegmentedPill";
@@ -33,7 +34,7 @@ import { cn } from "@/lib/utils";
* saved homework (solutions and step-by-step guides). The tab bar picks one,
* so the two never mix in a single list.
*/
-export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" }) {
+export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" | "podcasts" }) {
const reduceMotion = useReducedMotion();
const tab = initialTab;
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
@@ -269,6 +270,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
{isPhone && tab === "homework" ? : null}
{t("tabHomework")}
+
+ {isPhone && tab === "podcasts" ? : null}
+ {t("tabPodcasts")}
+
) : null}
+ {tab === "podcasts" ? : null}
+
diff --git a/components/WizardModeSwitch.tsx b/components/WizardModeSwitch.tsx
index b9362ce..6f79c1e 100644
--- a/components/WizardModeSwitch.tsx
+++ b/components/WizardModeSwitch.tsx
@@ -9,12 +9,13 @@ import { useWizardBusy } from "@/lib/wizard-busy";
import { cn } from "@/lib/utils";
/**
- * In-page switch between the two wizards (New deck ⇄ Make my homework).
- * Phones reach both through this control and the centre tab; desktop keeps
- * the rail rows as well. Hidden while either wizard is generating: leaving a
- * loading run through it would abandon the request mid-flight.
+ * In-page switch between the three note-powered wizards (New deck ⇄ Make my
+ * homework ⇄ New podcast). Phones reach the first two through this control and
+ * the centre tab; desktop keeps the rail rows as well. Hidden while a wizard
+ * is generating: leaving a loading run through it would abandon the request
+ * mid-flight.
*/
-export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
+export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" | "podcast" }) {
const { t } = useI18n();
const isPhone = useIsPhone();
const busy = useWizardBusy();
@@ -39,6 +40,14 @@ export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
{isPhone && mode === "homework" ? : null}
{t("wizard.mode.homework")}
+
+ {isPhone && mode === "podcast" ? : null}
+ {t("wizard.mode.podcast")}
+
);
}
diff --git a/components/podcast/PodcastList.tsx b/components/podcast/PodcastList.tsx
new file mode 100644
index 0000000..d474e95
--- /dev/null
+++ b/components/podcast/PodcastList.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import Link from "next/link";
+import { AnimatePresence, motion } from "framer-motion";
+import { useMemo, useState, useSyncExternalStore } from "react";
+
+import { useI18n } from "@/lib/i18n";
+import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
+import { deletePodcast, podcastMinutes, podcastStore, renamePodcast, sortPodcasts, upsertPodcast } from "@/lib/podcasts";
+import { showToast } from "@/lib/toast";
+import { EmptyState } from "@/components/ui";
+import { ClockIcon, EllipsisIcon, MusicIcon, PlayIcon } from "@/components/icons";
+import { cn } from "@/lib/utils";
+
+/**
+ * The library's podcasts tab: short episode cards with listen / rename /
+ * delete, the same interaction language as the homework cards.
+ */
+export function PodcastList() {
+ const { t, plural } = useI18n();
+ const reduceMotion = useReducedMotion();
+ const episodes = useSyncExternalStore(
+ podcastStore.subscribe,
+ podcastStore.getSnapshot,
+ podcastStore.getServerSnapshot,
+ );
+ const sorted = useMemo(() => sortPodcasts(episodes), [episodes]);
+ const [menuFor, setMenuFor] = useState(null);
+ const [renaming, setRenaming] = useState(null);
+ const [draft, setDraft] = useState("");
+
+ if (sorted.length === 0) {
+ return (
+
+ {t("podcast.library.new")}
+
+ }
+ >
+ {t("podcast.library.emptyBody")}
+
+ );
+ }
+
+ return (
+
+
+ {sorted.map((episode, index) => {
+ const minutes = podcastMinutes(episode);
+ const progress = episode.progress;
+ return (
+
+
+
+
+
+
+ {renaming === episode.id ? (
+
setDraft(event.target.value)}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === "Enter") {
+ const title = draft.trim();
+ if (title) {
+ renamePodcast(episode.id, title);
+ showToast({ message: t("podcast.card.renamed") });
+ }
+ setRenaming(null);
+ }
+ if (event.key === "Escape") setRenaming(null);
+ }}
+ maxLength={200}
+ aria-label={t("podcast.card.rename")}
+ className="w-full rounded-lg border border-[var(--hairline)] bg-[var(--surface-strong)] px-2.5 py-1.5 text-[15px] font-semibold text-[var(--text)] outline-none focus:border-accent-400"
+ />
+ ) : (
+
+ {episode.title}
+
+ )}
+
+
+
+ {t("podcast.card.minutes", { n: minutes })}
+
+ ·
+ {t("podcast.card.sections", { n: episode.sections.length })}
+ {progress ? (
+ <>
+ ·
+ {t("podcast.sectionOf", { n: progress.section + 1, total: episode.sections.length })}
+ >
+ ) : null}
+
+
+
setMenuFor((current) => (current === episode.id ? null : episode.id))}
+ aria-label={t("podcast.card.actions")}
+ aria-expanded={menuFor === episode.id}
+ className="grid size-9 shrink-0 place-items-center rounded-xl text-[var(--text-subtle)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
+ >
+
+
+
+
+ {episode.summary ? (
+ {episode.summary}
+ ) : null}
+
+ {menuFor === episode.id ? (
+
+ {
+ setDraft(episode.title);
+ setRenaming(episode.id);
+ setMenuFor(null);
+ }}
+ >
+ {t("podcast.card.rename")}
+
+ {
+ deletePodcast(episode.id);
+ setMenuFor(null);
+ /* Deleting is reversible: no confirm, just an Undo. */
+ showToast({
+ message: t("podcast.card.deleted"),
+ actionLabel: t("toast.undo"),
+ onAction: () => upsertPodcast(episode),
+ });
+ }}
+ >
+ {t("podcast.card.delete")}
+
+
+ ) : null}
+
+
+
+
+ {t("podcast.card.open")}
+
+
+ {plural(episode.sections.length, "podcast.sectionCount")}
+
+
+
+ );
+ })}
+
+
+ {/* Tapping anywhere else closes an open menu. */}
+ {menuFor ? (
+ setMenuFor(null)} />
+ ) : null}
+
+ );
+}
diff --git a/components/podcast/PodcastPlayer.tsx b/components/podcast/PodcastPlayer.tsx
new file mode 100644
index 0000000..17841ef
--- /dev/null
+++ b/components/podcast/PodcastPlayer.tsx
@@ -0,0 +1,376 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { useI18n } from "@/lib/i18n";
+import { createPodcastSpeech, tokenizeSections, type PodcastVoice } from "@/lib/podcast-speech";
+import { savePodcastProgress } from "@/lib/podcasts";
+import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
+import type { Podcast } from "@/lib/schemas";
+import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
+import { ChevronLeftIcon, ClockIcon, PauseIcon, PlayIcon, RefreshIcon } from "@/components/icons";
+import { cn } from "@/lib/utils";
+
+/** Playback speeds, cycled by one button. */
+const RATES = [1, 1.15, 1.3, 0.9] as const;
+
+/**
+ * Reads an episode aloud and highlights the word being spoken — the transcript
+ * is the player, Spotify-lyrics style: past words stay dark, upcoming words
+ * are dimmed, the word under the voice rides a rounded highlight that keeps
+ * itself centred as the episode plays.
+ *
+ * The heavy lifting (boundary events, the timer fallback, seeking by
+ * re-speaking) lives in lib/podcast-speech; this component owns the layout,
+ * the controls and the saved position.
+ */
+export function PodcastPlayer({ episode }: { episode: Podcast }) {
+ const { t, plural, locale } = useI18n();
+ const reduceMotion = useReducedMotion();
+ const [rate, setRate] = useState(1);
+ const [playing, setPlaying] = useState(false);
+ const [position, setPosition] = useState(null);
+ const [unsupported, setUnsupported] = useState(false);
+ const transcript = useRef(null);
+ const activeWord = useRef(null);
+ /** Latest position for the unmount/pause save, without re-creating callbacks. */
+ const latest = useRef(episode.progress ?? null);
+
+ const tokens = useMemo(() => tokenizeSections(episode.sections), [episode.sections]);
+ const supported = useMemo(() => speechAvailable(), []);
+
+ const speech = useMemo(() => {
+ if (!supported) return null;
+ return createPodcastSpeech({
+ sections: episode.sections,
+ locale: speechLocale(episode.language, locale),
+ rate,
+ onWord: (next) => {
+ latest.current = next;
+ setPosition(next);
+ },
+ onPlay: () => {
+ setPlaying(true);
+ /* The Aero soundtrack steps aside for the voice (see AeroMusic). */
+ window.dispatchEvent(new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }));
+ },
+ onPause: () => setPlaying(false),
+ onEnd: () => {
+ setPlaying(false);
+ setPosition(null);
+ latest.current = null;
+ savePodcastProgress(episode.id, { section: 0, word: 0 });
+ },
+ onError: (message) => {
+ if (message === "unsupported") setUnsupported(true);
+ setPlaying(false);
+ },
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- rebuilt only when the episode, the speed or the locale changes
+ }, [episode.id, episode.sections, rate, supported, locale]);
+
+ /* Resume where the listener left off (a reopened episode starts there). */
+ useEffect(() => {
+ if (episode.progress) {
+ latest.current = { section: episode.progress.section, word: episode.progress.word };
+ setPosition({ section: episode.progress.section, word: episode.progress.word });
+ }
+ }, [episode.id, episode.progress]);
+
+ const persist = useCallback(() => {
+ if (latest.current) savePodcastProgress(episode.id, latest.current);
+ }, [episode.id]);
+
+ /* The engine instance is rebuilt on a speed change; keep a ref so the
+ unmount cleanup always stops whichever one is current. */
+ const speechRef = useRef(speech);
+ const playingRef = useRef(false);
+ useEffect(() => {
+ speechRef.current = speech;
+ }, [speech]);
+ useEffect(() => {
+ playingRef.current = playing;
+ }, [playing]);
+ useEffect(() => {
+ return () => {
+ persist();
+ speechRef.current?.stop();
+ };
+ }, [persist]);
+
+ /* Closing the tab, switching apps or hard-navigating away: the unmount
+ cleanup never runs there, so the position also rides the page lifecycle. */
+ useEffect(() => {
+ const save = () => persist();
+ const onHide = () => {
+ if (document.visibilityState === "hidden") save();
+ };
+ window.addEventListener("pagehide", save);
+ document.addEventListener("visibilitychange", onHide);
+ return () => {
+ window.removeEventListener("pagehide", save);
+ document.removeEventListener("visibilitychange", onHide);
+ };
+ }, [persist]);
+
+ /* A rebuilt engine (speed change) resumes the same word when it was
+ playing — the listener should not notice the swap. */
+ const firstEngine = useRef(true);
+ useEffect(() => {
+ if (firstEngine.current) {
+ firstEngine.current = false;
+ return;
+ }
+ if (!playingRef.current || !speech) return;
+ speech.play(latest.current ?? { section: 0, word: 0 });
+ }, [speech]);
+
+ /* The highlight: one class on the active word (imperative, so a few hundred
+ spans do not re-render per word), then keep it in the comfortable band. */
+ useEffect(() => {
+ const container = transcript.current;
+ if (!container) return;
+ activeWord.current?.removeAttribute("data-word-active");
+ activeWord.current?.classList.remove("podcast-word-active");
+ if (!position) {
+ activeWord.current = null;
+ return;
+ }
+ const index = tokens.findIndex((token) => token.section === position.section && token.index === position.word);
+ if (index === -1) return;
+ const element = container.querySelector(`[data-word="${index}"]`);
+ if (!element) return;
+ element.setAttribute("data-word-active", "1");
+ element.classList.add("podcast-word-active");
+ activeWord.current = element;
+
+ const box = element.getBoundingClientRect();
+ const view = container.getBoundingClientRect();
+ const top = box.top - view.top;
+ const comfortable = view.height * 0.38;
+ if (top < view.height * 0.18 || top > view.height * 0.62) {
+ container.scrollTo({
+ top: container.scrollTop + top - comfortable,
+ behavior: reduceMotion ? "auto" : "smooth",
+ });
+ }
+ }, [position, tokens, reduceMotion]);
+
+ const sections = episode.sections;
+ const words = tokens.length;
+ const done = useMemo(() => {
+ if (!position) return 0;
+ return tokens.findIndex((token) => token.section === position.section && token.index === position.word);
+ }, [position, tokens]);
+
+ const toggle = () => {
+ if (!speech) return;
+ if (playing) {
+ speech.pause();
+ persist();
+ return;
+ }
+ /* Other readers (a card's read-aloud) yield to the episode. */
+ stopSpeaking();
+ speech.play(position ?? { section: 0, word: 0 });
+ };
+
+ const jump = (section: number) => {
+ if (!speech) return;
+ const next = { section, word: 0 };
+ latest.current = next;
+ setPosition(next);
+ stopSpeaking();
+ speech.play(next);
+ };
+
+ const jumpWord = (index: number) => {
+ if (!speech) return;
+ const token = tokens[index];
+ if (!token) return;
+ const next = { section: token.section, word: token.index };
+ latest.current = next;
+ setPosition(next);
+ stopSpeaking();
+ speech.play(next);
+ };
+
+ /* Word spans, built once per section: the highlight is a class flip. */
+ const rendered = useMemo(() => {
+ return sections.map((section, sectionIndex) => {
+ const sectionTokens = tokens.filter((token) => token.section === sectionIndex);
+ const nodes: React.ReactNode[] = [];
+ let cursor = 0;
+ sectionTokens.forEach((token) => {
+ if (token.start > cursor) nodes.push(section.text.slice(cursor, token.start));
+ const globalIndex = token.global;
+ nodes.push(
+
+ {section.text.slice(token.start, token.end)}
+ ,
+ );
+ cursor = token.end;
+ });
+ if (cursor < section.text.length) nodes.push(section.text.slice(cursor));
+ return { section, sectionIndex, nodes };
+ });
+ }, [sections, tokens]);
+
+ const currentSection = position?.section ?? 0;
+
+ return (
+
+
+
+
+
+
+
+
{episode.title}
+
+ {t("podcast.sectionOf", { n: Math.min(currentSection + 1, sections.length), total: sections.length })}
+ {" · "}
+ {sections[currentSection]?.heading ?? ""}
+
+
+
{
+ const index = RATES.indexOf(rate as (typeof RATES)[number]);
+ setRate(RATES[(index + 1) % RATES.length]);
+ }}
+ aria-label={t("podcast.speedAria")}
+ title={t("podcast.speedAria")}
+ className="shrink-0 rounded-full bg-[var(--surface-sunken)] px-3 py-2 text-[12px] font-extrabold tabular-nums text-[var(--text)] transition hover:bg-[var(--hover)]"
+ >
+ {rate}×
+
+
+
+
+
{
+ /* Word taps seek: the transcript is the scrubber, like lyrics. */
+ const target = event.target as HTMLElement;
+ if (!target.classList.contains("podcast-word")) return;
+ const index = Number(target.dataset.word);
+ if (Number.isInteger(index)) jumpWord(index);
+ }}
+ className="no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-40 pt-24"
+ >
+
+ {!supported || unsupported ? (
+
+ {t("podcast.noVoice")}
+
+ ) : null}
+
+
+
+ {episode.title}
+
+ {episode.summary ? (
+
{episode.summary}
+ ) : null}
+
+
+ {plural(episode.sections.length, "podcast.sectionCount")} · {plural(words, "podcast.wordCount")}
+
+
+
+ {/* The script reads on a glass pane: in Aero the wallpaper behind
+ plain paragraphs is bright grass and sun (the contrast scan caught
+ it), and the same treatment keeps every theme consistent. */}
+
+ {rendered.map(({ section, sectionIndex, nodes }) => (
+
+
+
+ {sectionIndex + 1}
+
+ {section.heading}
+
+
+ {nodes}
+
+
+ ))}
+
+
+
{t("podcast.tapToJump")}
+
+
+
+ {/* Controls: chapter list on the left, transport in the middle. */}
+
+
+
+
+
+
+ {playing ? : }
+
+
+ {sections.map((section, index) => (
+ jump(index)}
+ aria-current={index === currentSection ? "true" : undefined}
+ className={cn(
+ "shrink-0 rounded-full px-3 py-2 text-[12px] font-semibold transition",
+ index === currentSection
+ ? "bg-[var(--nav-active-bg)] text-[var(--text)] ring-1 ring-[var(--nav-active-border)]"
+ : "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
+ )}
+ >
+ {section.heading}
+
+ ))}
+
+
{
+ speech?.stop();
+ setPosition({ section: 0, word: 0 });
+ latest.current = { section: 0, word: 0 };
+ savePodcastProgress(episode.id, { section: 0, word: 0 });
+ }}
+ aria-label={t("podcast.restart")}
+ title={t("podcast.restart")}
+ className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/podcast/PodcastWizard.tsx b/components/podcast/PodcastWizard.tsx
new file mode 100644
index 0000000..4d6604a
--- /dev/null
+++ b/components/podcast/PodcastWizard.tsx
@@ -0,0 +1,505 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { AiDisclaimer } from "@/components/AiDisclaimer";
+import { ApiError, generatePodcast, type GenerateProgress } from "@/lib/client-api";
+import {
+ DOCUMENT_ACCEPT_ATTR,
+ KIND_LABELS,
+ describeDocumentError,
+ documentKindOf,
+ extractDocument,
+ isDocumentFile,
+ type DocumentKind,
+ type ExtractedDocument,
+} from "@/lib/documents";
+import {
+ ACCEPTED_TYPES,
+ MAX_IMAGES,
+ describeFileError,
+ imageFilesFromDataTransfer,
+ isImageFile,
+ prepareImage,
+ type PreparedImage,
+} from "@/lib/image";
+import { useI18n } from "@/lib/i18n";
+import { ModelErrorActions } from "@/components/ModelErrorActions";
+import { takePendingShare } from "@/lib/share-pending";
+import { upsertPodcast } from "@/lib/podcasts";
+import { useIsPhone } from "@/lib/use-phone";
+import { useOnline } from "@/lib/use-online";
+import { uid } from "@/lib/utils";
+import { wizardBusy } from "@/lib/wizard-busy";
+import { CameraIcon, ClockIcon, MusicIcon, PlusIcon, SparklesIcon, XIcon } from "@/components/icons";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui";
+import { LoadingState, type LoadingStage } from "@/components/LoadingState";
+import { WizardActionBar } from "@/components/WizardActionBar";
+
+type Phase = "idle" | "loading" | "error";
+
+type DocItem = {
+ id: string;
+ name: string;
+ kind: DocumentKind;
+ status: "extracting" | "ready" | "error";
+ progress?: string;
+ extracted?: ExtractedDocument;
+ error?: string;
+};
+
+const MAX_DOCUMENTS = 6;
+const MAX_IMAGES_TO_SEND = 12;
+const MAX_TEXT_TO_SEND = 100_000;
+
+/** Episode lengths, in minutes; the prompt turns these into a word target. */
+const LENGTH_OPTIONS = [2, 4, 7] as const;
+
+const PODCAST_STAGES: LoadingStage[] = [
+ { id: "vision", labelKey: "podcast.loading.read1", messageKeys: ["podcast.loading.read1", "podcast.loading.read2"] },
+ { id: "generation", labelKey: "podcast.loading.write1", messageKeys: ["podcast.loading.write1", "podcast.loading.write2", "podcast.loading.write3"] },
+];
+
+const LOADING_TIPS = ["podcast.tip1", "podcast.tip2"];
+
+/**
+ * Podcast mode: the same notes intake as the deck wizard, but the output is a
+ * spoken episode. One screen — the choices are only "how long" and an optional
+ * topic hint — so it does not need the deck wizard's three steps.
+ */
+export function PodcastWizard() {
+ const router = useRouter();
+ const { t, plural } = useI18n();
+ const isPhone = useIsPhone();
+ const online = useOnline();
+
+ const [images, setImages] = useState([]);
+ const [documents, setDocuments] = useState([]);
+ const [text, setText] = useState("");
+ const [focus, setFocus] = useState("");
+ const [minutes, setMinutes] = useState(4);
+ const [phase, setPhase] = useState("idle");
+ const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
+ const [fileError, setFileError] = useState(null);
+ const [dragging, setDragging] = useState(false);
+ const [progress, setProgress] = useState({ stage: "generation", message: "" });
+
+ const fileInput = useRef(null);
+ const docInput = useRef(null);
+ const cameraInput = useRef(null);
+ const abortRef = useRef(null);
+
+ const documentText = useMemo(
+ () =>
+ documents
+ .filter((entry) => entry.status === "ready" && entry.extracted)
+ .map((entry) => entry.extracted?.text ?? "")
+ .filter(Boolean)
+ .join("\n\n"),
+ [documents],
+ );
+
+ const imagesToSend = useMemo(
+ () =>
+ [
+ ...images.map((image) => image.dataUrl),
+ ...documents.flatMap((entry) => (entry.extracted?.images ?? []).map((image) => image.dataUrl)),
+ ].slice(0, MAX_IMAGES_TO_SEND),
+ [images, documents],
+ );
+
+ const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
+ const canGenerate = online && phase !== "loading" && (imagesToSend.length > 0 || mergedText.length > 0);
+ const extracting = documents.some((entry) => entry.status === "extracting");
+
+ useEffect(() => {
+ wizardBusy.set(phase === "loading");
+ return () => wizardBusy.set(false);
+ }, [phase]);
+
+ const addImages = useCallback(
+ async (files: File[]) => {
+ setFileError(null);
+ const room = MAX_IMAGES - images.length;
+ if (room <= 0) {
+ setFileError(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
+ return;
+ }
+ const accepted = files.slice(0, room);
+ for (const file of accepted) {
+ const failure = describeFileError(file);
+ if (failure) {
+ setFileError(failure);
+ continue;
+ }
+ try {
+ const prepared = await prepareImage(file);
+ setImages((current) => (current.length >= MAX_IMAGES ? current : [...current, prepared]));
+ } catch (error) {
+ setFileError(error instanceof Error ? error.message : t("upload.couldNotReadFile"));
+ }
+ }
+ if (files.length > room) setFileError(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
+ },
+ [images.length, t],
+ );
+
+ const addDocuments = useCallback(
+ async (files: File[]) => {
+ setFileError(null);
+ for (const file of files) {
+ const kind = documentKindOf(file);
+ if (!kind) {
+ setFileError(describeDocumentError(file));
+ continue;
+ }
+ if (documents.length >= MAX_DOCUMENTS) {
+ setFileError(t("upload.tooManyDocs", { n: MAX_DOCUMENTS }));
+ break;
+ }
+ const id = uid("doc");
+ setDocuments((current) => [
+ ...current,
+ { id, name: file.name, kind, status: "extracting", progress: t("upload.readingDocs") },
+ ]);
+ try {
+ const extracted = await extractDocument(file, (update) =>
+ setDocuments((current) =>
+ current.map((entry) => (entry.id === id ? { ...entry, progress: update.message } : entry)),
+ ),
+ );
+ setDocuments((current) =>
+ current.map((entry) => (entry.id === id ? { ...entry, status: "ready", extracted } : entry)),
+ );
+ } catch (error) {
+ setDocuments((current) =>
+ current.map((entry) =>
+ entry.id === id
+ ? {
+ ...entry,
+ status: "error",
+ error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"),
+ }
+ : entry,
+ ),
+ );
+ }
+ }
+ },
+ [documents.length, t],
+ );
+
+ /* A share into the app (web share target) lands here as pending notes. */
+ useEffect(() => {
+ const pending = takePendingShare();
+ if (!pending) return;
+ if (pending.text) setText((current) => (current ? `${current}\n\n${pending.text}` : pending.text));
+ if (pending.files.length > 0) void addImages(pending.files.filter(isImageFile));
+ }, [addImages]);
+
+ const generate = async () => {
+ if (!canGenerate) return;
+ setError(null);
+ setPhase("loading");
+ setProgress({
+ stage: imagesToSend.length ? "vision" : "generation",
+ message: imagesToSend.length ? t("podcast.loading.read1") : t("podcast.loading.write1"),
+ });
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ try {
+ const { podcast } = await generatePodcast(
+ {
+ images: imagesToSend.length ? imagesToSend : undefined,
+ text: mergedText || undefined,
+ minutes,
+ focus: focus.trim() || undefined,
+ },
+ { onProgress: setProgress, signal: controller.signal },
+ );
+
+ const now = Date.now();
+ const stored = {
+ ...podcast,
+ id: uid("pod"),
+ createdAt: now,
+ updatedAt: now,
+ };
+ upsertPodcast(stored);
+ router.push(`/podcast/${stored.id}`);
+ } catch (generateError) {
+ setError({
+ message: generateError instanceof ApiError ? generateError.message : t("upload.genericError"),
+ kind: generateError instanceof ApiError ? generateError.kind : undefined,
+ });
+ setPhase("error");
+ } finally {
+ abortRef.current = null;
+ }
+ };
+
+ if (phase === "loading") {
+ return (
+ {
+ abortRef.current?.abort();
+ setPhase("idle");
+ }}
+ />
+ );
+ }
+
+ const notesReady = imagesToSend.length > 0 || mergedText.length > 0;
+
+ const dropzone = (
+ (images.length >= MAX_IMAGES ? cameraInput.current?.click() : fileInput.current?.click())}
+ onDragOver={(event) => {
+ event.preventDefault();
+ setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={(event) => {
+ event.preventDefault();
+ setDragging(false);
+ void addImages(imageFilesFromDataTransfer(event.dataTransfer).filter(isImageFile));
+ }}
+ className={cn(
+ "press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed px-4 py-6 text-center transition",
+ dragging ? "border-accent-400 bg-accent-400/10" : "border-[var(--glass-border)] bg-[var(--surface-strong)] hover:border-accent-400",
+ )}
+ >
+
+
+
+ {t("podcast.addNotes")}
+ {t("upload.formats")}
+
+ );
+
+ return (
+
+
+
+
+ {t("podcast.title")}
+
+ {t("podcast.subtitle")}
+
+
+
+ {dropzone}
+
+
fileInput.current?.click()}>
+ {t("podcast.addPhotos")}
+
+
docInput.current?.click()}>
+ {t("podcast.addDocs")}
+
+ {isPhone ? (
+
cameraInput.current?.click()}>
+ {t("upload.camera")}
+
+ ) : null}
+
+
+ {images.length > 0 || documents.length > 0 ? (
+
+ {images.map((image) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+ setImages((current) => current.filter((entry) => entry.id !== image.id))}
+ aria-label={t("upload.remove", { name: image.name ?? "" })}
+ className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
+ >
+
+
+
+ ))}
+ {documents.map((entry) => (
+
+
+ {KIND_LABELS[entry.kind]}
+
+
+ {entry.name}
+
+
+ {entry.status === "extracting" ? entry.progress ?? t("upload.readingDocs") : null}
+ {entry.status === "error" ? entry.error : null}
+
+ setDocuments((current) => current.filter((item) => item.id !== entry.id))}
+ aria-label={t("upload.remove", { name: entry.name })}
+ className="grid size-5 place-items-center rounded-full text-[var(--text-subtle)] hover:text-[var(--text)]"
+ >
+
+
+
+ ))}
+
+ ) : null}
+
+ {fileError ? (
+
+ {fileError}
+
+ ) : null}
+
+
+
+
+
+ {t("podcast.lengthAria")}
+
+
+ {LENGTH_OPTIONS.map((option) => (
+ setMinutes(option)}
+ className={cn(
+ "chip touch-target inline-flex items-center gap-1.5 px-3 text-[13px]",
+ minutes === option && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
+ )}
+ >
+
+ {plural(option, "podcast.minutes")}
+
+ ))}
+
+
{t("podcast.lengthHelp")}
+
+
+
+
+ {t("podcast.focusLabel")}
+
+ setFocus(event.target.value)}
+ placeholder={t("upload.topicPlaceholder")}
+ inputMode="text"
+ enterKeyHint="done"
+ autoComplete="off"
+ autoCapitalize="sentences"
+ spellCheck={false}
+ className="w-full rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400"
+ />
+
+
+
+
+ {t("upload.pastedNotes")}
+
+
+
+
+ {error ? (
+
+
+ {error.message}
+
+
+
+ ) : null}
+
+
+
+ {/* Desktop action row; phones get the pinned wizard bar below. */}
+
+ void generate()}
+ >
+ {t("podcast.generate")}
+
+
+
+
+ void generate()}
+ >
+ {notesReady ? t("podcast.generate") : t("podcast.addFirst")}
+
+
+
+
{
+ const files = [...(event.target.files ?? [])];
+ if (files.length) void addImages(files.filter(isImageFile));
+ event.target.value = "";
+ }}
+ />
+
{
+ const files = [...(event.target.files ?? [])];
+ if (files.length) void addImages(files.filter(isImageFile));
+ event.target.value = "";
+ }}
+ />
+
{
+ const files = [...(event.target.files ?? [])];
+ if (files.length) void addDocuments(files.filter(isDocumentFile));
+ event.target.value = "";
+ }}
+ />
+
+ );
+}
diff --git a/lib/ai.ts b/lib/ai.ts
index ed86f82..295b9ce 100644
--- a/lib/ai.ts
+++ b/lib/ai.ts
@@ -20,6 +20,8 @@ import {
HOMEWORK_CHECK_SYSTEM_PROMPT,
HOMEWORK_GUIDE_SYSTEM_PROMPT,
HOMEWORK_MAKE_SYSTEM_PROMPT,
+ PODCAST_SYSTEM_PROMPT,
+ buildPodcastUserPrompt,
REFINE_SYSTEM_PROMPT,
VISION_SYSTEM_PROMPT,
VISION_USER_INSTRUCTION,
@@ -27,6 +29,7 @@ import {
import {
extractedNotesSchema,
generatedActivitySchema,
+ generatedPodcastSchema,
gradeResultSchema,
homeworkCheckSchema,
homeworkMakeSchema,
@@ -38,6 +41,7 @@ import {
type HomeworkCheck,
type HomeworkGuide,
type HomeworkMake,
+ type GeneratedPodcast,
} from "./schemas";
import { parseJsonLoose } from "./utils";
import type { OllamaAuth } from "./config";
@@ -401,6 +405,58 @@ export async function generateActivities(input: {
return { deck: value, requested };
}
+/* ------------------------------------------------------------------ */
+/* Podcast */
+/* ------------------------------------------------------------------ */
+
+const PODCAST_SHAPE_HINT = `{"title":"Nothing but the sky","language":"English","summary":"One sentence.","sections":[{"heading":"Why blue","text":"Sixty to a hundred and forty words of narration."}]}`;
+
+export async function generatePodcast(input: {
+ model: string;
+ auth: OllamaAuth;
+ provider?: ProviderTarget;
+ notes: string;
+ minutes: number;
+ language?: string;
+ focus?: string;
+ signal?: AbortSignal;
+}): Promise<{ podcast: GeneratedPodcast }> {
+ const minutes = Math.min(Math.max(input.minutes, 1), 15);
+ const { value } = await runJsonStep({
+ model: input.model,
+ auth: input.auth,
+ provider: input.provider,
+ system: PODCAST_SYSTEM_PROMPT,
+ user: buildPodcastUserPrompt({
+ notes: input.notes,
+ minutes,
+ language: input.language,
+ focus: input.focus,
+ }),
+ /* One usable section beats a retry loop: the repair pass fills gaps. */
+ validate: (parsed) => {
+ const result = generatedPodcastSchema.safeParse(parsed);
+ if (!result.success) return { ok: false as const, error: summariseZodError(result.error) };
+ const sections = result.data.sections
+ .map((section) => ({
+ heading: section.heading.trim(),
+ text: section.text.trim().replace(/\s+/g, " "),
+ }))
+ .filter((section) => section.text.length > 0);
+ if (sections.length === 0) {
+ return { ok: false as const, error: "No sections with spoken text were found." };
+ }
+ return { ok: true as const, value: { ...result.data, sections } };
+ },
+ shapeHint: PODCAST_SHAPE_HINT,
+ label: "Writing the script",
+ signal: input.signal,
+ temperature: 0.75,
+ });
+
+ return { podcast: value };
+}
+
/* ------------------------------------------------------------------ */
/* Grading */
/* ------------------------------------------------------------------ */
diff --git a/lib/backup.ts b/lib/backup.ts
index 6d74535..9514998 100644
--- a/lib/backup.ts
+++ b/lib/backup.ts
@@ -15,9 +15,10 @@ import { applyTheme, type Theme } from "./theme";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { replaceFolders, folderStore } from "./folders";
import { homeworkStore, replaceHomework } from "./homework";
+import { podcastStore, replacePodcasts } from "./podcasts";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { setLocale, t } from "./i18n";
-import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
+import { folderSchema, deckSchema, homeworkRecordSchema, podcastSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
import { saveSettings, settingsStore } from "./settings";
import { deckStore, replaceDecks } from "./storage";
@@ -34,6 +35,8 @@ export const backupDataSchema = z.object({
decks: z.array(deckSchema).max(2000),
folders: z.array(folderSchema).max(500),
homework: z.array(homeworkRecordSchema).max(2000),
+ /** Podcast episodes. Optional for old backups. */
+ podcasts: z.array(podcastSchema).max(2000).optional(),
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
history: z.array(answerRecordSchema).max(20_000).optional(),
srs: z.array(srsRecordSchema).max(50_000).optional(),
@@ -67,6 +70,7 @@ export async function buildBackup(): Promise {
decks: deckStore.getSnapshot(),
folders: folderStore.getSnapshot(),
homework: homeworkStore.getSnapshot(),
+ podcasts: podcastStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
@@ -143,6 +147,7 @@ export type ImportPreview = {
decks: ImportCounts;
folders: ImportCounts;
homework: ImportCounts;
+ podcasts: ImportCounts;
chats: ImportCounts;
};
@@ -151,6 +156,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
+ podcasts: countMerge(podcastStore.getSnapshot(), backup.data.podcasts ?? []),
chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []),
};
}
@@ -167,12 +173,15 @@ export async function applyImport(
mode === "replace" ? data.folders : mergeById(folderStore.getSnapshot(), data.folders);
const homework =
mode === "replace" ? data.homework : mergeById(homeworkStore.getSnapshot(), data.homework);
+ const podcasts =
+ mode === "replace" ? data.podcasts ?? [] : mergeById(podcastStore.getSnapshot(), data.podcasts ?? []);
// Folders first so imported decks can point at them. Importing is a real
// local change, so it also marks sync dirty (via the stores' write()).
replaceFolders(folders);
replaceDecks(decks);
replaceHomework(homework);
+ replacePodcasts(podcasts);
// Answer log + schedule: merge by id (answers) and by key (schedules), or
// replace wholesale when mode === "replace".
diff --git a/lib/client-api.ts b/lib/client-api.ts
index 3467b1b..4162d25 100644
--- a/lib/client-api.ts
+++ b/lib/client-api.ts
@@ -37,7 +37,13 @@ const RESPONSE_GUARD_MS = 45_000;
* routes heartbeat every 15s, so 90s of silence really is silence. */
const STREAM_GUARD_MS = 90_000;
-type StreamEvent =
+type StreamEvent = {
+ type: "podcast";
+ podcast: StreamedPodcast;
+ meta?: Record;
+} | StreamEventBase;
+
+type StreamEventBase =
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
| { type: "deck"; deck: StreamedDeck; meta: Record }
| { type: "error"; error: string; kind?: string }
@@ -200,6 +206,91 @@ export async function generateDeck(
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta };
}
+/* ------------------------------------------------------------------ */
+/* Podcast mode */
+/* ------------------------------------------------------------------ */
+
+export type PodcastInput = {
+ images?: string[];
+ text?: string;
+ /** Target episode length in minutes. */
+ minutes: number;
+ focus?: string;
+ language?: string;
+};
+
+export type StreamedPodcast = {
+ title: string;
+ language?: string;
+ summary?: string;
+ sourceText: string;
+ sections: { heading: string; text: string }[];
+};
+
+/** Notes → a spoken episode. Same stream shape as the deck generator. */
+export async function generatePodcast(
+ input: PodcastInput,
+ options: { onProgress?: (progress: GenerateProgress) => void; signal?: AbortSignal } = {},
+): Promise<{ podcast: StreamedPodcast; meta: Record }> {
+ const generation = providersStore.getSnapshot().generation;
+ const controller = new AbortController();
+ const forwarded = options.signal;
+ let headersTimedOut = false;
+ if (forwarded) {
+ if (forwarded.aborted) controller.abort();
+ else forwarded.addEventListener("abort", () => controller.abort(), { once: true });
+ }
+ const headersTimer = setTimeout(() => {
+ headersTimedOut = true;
+ controller.abort();
+ }, RESPONSE_GUARD_MS);
+ let response: Response;
+ try {
+ response = await fetch("/api/podcast", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", ...authHeaders(), ...providerHeaders(generation?.provider), ...localeHeader() },
+ body: JSON.stringify(
+ generation ? { ...input, textModel: generation.textModel, visionModel: generation.visionModel } : input,
+ ),
+ signal: controller.signal,
+ });
+ } catch {
+ if (forwarded?.aborted) throw new ApiError(t("api.cancelled"), "aborted");
+ if (headersTimedOut) throw new ApiError(t("api.stalled"), "timeout");
+ throw new ApiError(t("api.unreachable"), "network");
+ } finally {
+ clearTimeout(headersTimer);
+ }
+
+ if (!response.ok) {
+ const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
+ throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
+ }
+
+ let podcast: StreamedPodcast | null = null;
+ let streamError: ApiError | null = null;
+
+ await consumeNdjson(response, (raw) => {
+ const event = raw as StreamEvent;
+ if (event.type === "stage") {
+ options.onProgress?.({ stage: event.stage, message: event.message });
+ } else if (event.type === "podcast") {
+ podcast = event.podcast as StreamedPodcast;
+ options.onProgress?.({ stage: "done", message: t("api.almost") });
+ } else if (event.type === "error") {
+ streamError = new ApiError(event.error, event.kind);
+ }
+ }, { stallMs: STREAM_GUARD_MS });
+
+ if (streamError) throw streamError;
+ const ready = podcast as StreamedPodcast | null;
+ if (!ready || !Array.isArray(ready.sections) || ready.sections.length === 0) {
+ throw new ApiError(t("api.noPodcast"), "bad_response");
+ }
+
+ return { podcast: ready, meta: {} };
+}
+
/* ------------------------------------------------------------------ */
/* Make my homework */
/* ------------------------------------------------------------------ */
diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts
index 4e25a0a..b44368e 100644
--- a/lib/i18n-dict.ts
+++ b/lib/i18n-dict.ts
@@ -162,7 +162,7 @@ const en: Record = {
"settings.exportAll": "Export all data",
"settings.exportNote": "Decks, homework, progress and settings — never your API key.",
"settings.importBackup": "Import backup",
- "settings.importPreview": "This backup has {decks} decks, {homework} homework results, {folders} folders and {chats} chats.",
+ "settings.importPreview": "This backup has {decks} decks, {homework} homework results, {podcasts} podcasts, {folders} folders and {chats} chats.",
"settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.",
"settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
"settings.importMerge": "Merge",
@@ -274,7 +274,61 @@ const en: Record = {
"homework.nav": "New homework",
"wizard.mode.deck": "Study deck",
"wizard.mode.homework": "Make my homework",
- "wizard.mode.aria": "Study deck or homework",
+ "wizard.mode.podcast": "Podcast",
+ "wizard.mode.aria": "Study deck, homework or podcast",
+
+ /* Podcast mode (notes → a spoken episode with a highlighted transcript) */
+ "podcast.title": "New podcast",
+ "podcast.subtitle": "Freezmo reads your notes back to you as a short episode — and highlights every word as it is spoken, like lyrics.",
+ "podcast.addNotes": "Add your notes",
+ "podcast.addPhotos": "Photos",
+ "podcast.addDocs": "Documents",
+ "podcast.addFirst": "Add notes first",
+ "podcast.generate": "Create the episode",
+ "podcast.lengthAria": "Episode length",
+ "podcast.lengthHelp": "A rough target — the script stays as long as the notes need.",
+ "podcast.focusLabel": "What should it focus on? (optional)",
+ "podcast.minutes.one": "{n} minute",
+ "podcast.minutes.few": "{n} minutes",
+ "podcast.minutes.many": "{n} minutes",
+ "podcast.sectionCount.one": "{n} section",
+ "podcast.sectionCount.few": "{n} sections",
+ "podcast.sectionCount.many": "{n} sections",
+ "podcast.wordCount.one": "{n} word",
+ "podcast.wordCount.few": "{n} words",
+ "podcast.wordCount.many": "{n} words",
+ "podcast.sectionOf": "Part {n} of {total}",
+ "podcast.play": "Play the episode",
+ "podcast.pause": "Pause the episode",
+ "podcast.restart": "Start from the beginning",
+ "podcast.speedAria": "Playback speed",
+ "podcast.tapToJump": "Tap any word to jump there.",
+ "podcast.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
+ "podcast.loadingEpisode": "Loading the episode",
+ "podcast.notFound": "Episode not found",
+ "podcast.notFoundBody": "It was probably deleted, or it lives in another browser's storage.",
+ "podcast.loading.read1": "Reading your notes…",
+ "podcast.loading.read2": "Making sense of the pages…",
+ "podcast.loading.write1": "Writing the script…",
+ "podcast.loading.write2": "Finding the through-line…",
+ "podcast.loading.write3": "Polishing the spoken bits…",
+ "podcast.tip1": "The transcript highlights every word as it is read — tap any word to jump there.",
+ "podcast.tip2": "A four-minute walk is a great length for one subject.",
+
+ /* Library: the podcasts tab */
+ "tabPodcasts": "Podcasts",
+ "podcast.library.empty": "No episodes yet",
+ "podcast.library.emptyBody": "Turn a page of notes into a spoken episode and it will wait here.",
+ "podcast.library.new": "New podcast",
+ "podcast.card.sections": "{n} parts",
+ "podcast.card.minutes": "~{n} min",
+ "podcast.card.actions": "Episode actions",
+ "podcast.card.open": "Listen",
+ "podcast.card.rename": "Rename",
+ "podcast.card.delete": "Delete episode",
+ "podcast.card.deleted": "Episode deleted",
+ "podcast.card.renamed": "Episode renamed",
+ "podcast.regenerate": "Write it again",
"homeworkHeroTitle": "Make my homework",
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
"homework.phone.assignment": "The assignment",
@@ -1175,7 +1229,7 @@ const cs: Record = {
"settings.exportAll": "Exportovat všechna data",
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
"settings.importBackup": "Importovat zálohu",
- "settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {folders} složek a {chats} chatů.",
+ "settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {podcasts} podcastů, {folders} složek a {chats} chatů.",
"settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.",
"settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
"settings.importMerge": "Sloučit",
@@ -1287,7 +1341,61 @@ const cs: Record = {
"homework.nav": "Nový úkol",
"wizard.mode.deck": "Studijní sada",
"wizard.mode.homework": "Udělej mi úkol",
- "wizard.mode.aria": "Studijní sada nebo úkol",
+ "wizard.mode.podcast": "Podcast",
+ "wizard.mode.aria": "Studijní sada, úkol nebo podcast",
+
+ /* Podcast mode (notes → a spoken episode with a highlighted transcript) */
+ "podcast.title": "Nový podcast",
+ "podcast.subtitle": "Freezmo ti tvoje poznámky přečte jako krátkou epizodu — a každé slovo při čtení zvýrazní, jako texty písní.",
+ "podcast.addNotes": "Přidej poznámky",
+ "podcast.addPhotos": "Fotky",
+ "podcast.addDocs": "Dokumenty",
+ "podcast.addFirst": "Nejdřív přidej poznámky",
+ "podcast.generate": "Vytvořit epizodu",
+ "podcast.lengthAria": "Délka epizody",
+ "podcast.lengthHelp": "Jen orientační cíl — scénář zůstane tak dlouhý, jak poznámky potřebují.",
+ "podcast.focusLabel": "Na co se má zaměřit? (nepovinné)",
+ "podcast.minutes.one": "{n} minuta",
+ "podcast.minutes.few": "{n} minuty",
+ "podcast.minutes.many": "{n} minut",
+ "podcast.sectionCount.one": "{n} část",
+ "podcast.sectionCount.few": "{n} části",
+ "podcast.sectionCount.many": "{n} částí",
+ "podcast.wordCount.one": "{n} slovo",
+ "podcast.wordCount.few": "{n} slova",
+ "podcast.wordCount.many": "{n} slov",
+ "podcast.sectionOf": "Část {n} z {total}",
+ "podcast.play": "Přehrát epizodu",
+ "podcast.pause": "Pozastavit epizodu",
+ "podcast.restart": "Začít od začátku",
+ "podcast.speedAria": "Rychlost přehrávání",
+ "podcast.tapToJump": "Klikni na kterékoli slovo a přeskočíš tam.",
+ "podcast.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",
+ "podcast.loadingEpisode": "Načítání epizody",
+ "podcast.notFound": "Epizoda nenalezena",
+ "podcast.notFoundBody": "Nejspíš byla smazaná, nebo je uložená v jiném prohlížeči.",
+ "podcast.loading.read1": "Čtu tvoje poznámky…",
+ "podcast.loading.read2": "Dávám stránky dohromady…",
+ "podcast.loading.write1": "Píšu scénář…",
+ "podcast.loading.write2": "Hledám hlavní linii…",
+ "podcast.loading.write3": "Ladím mluvené pasáže…",
+ "podcast.tip1": "Přepis zvýrazňuje každé čtené slovo — kliknutím na slovo se tam přesuneš.",
+ "podcast.tip2": "Čtyřminutová procházka je pro jedno téma tak akorát.",
+
+ /* Library: the podcasts tab */
+ "tabPodcasts": "Podcasty",
+ "podcast.library.empty": "Zatím žádné epizody",
+ "podcast.library.emptyBody": "Proměň stránku poznámek v mluvenou epizodu a počká tady na tebe.",
+ "podcast.library.new": "Nový podcast",
+ "podcast.card.sections": "{n} částí",
+ "podcast.card.minutes": "~{n} min",
+ "podcast.card.actions": "Akce epizody",
+ "podcast.card.open": "Poslouchat",
+ "podcast.card.rename": "Přejmenovat",
+ "podcast.card.delete": "Smazat epizodu",
+ "podcast.card.deleted": "Epizoda smazána",
+ "podcast.card.renamed": "Epizoda přejmenována",
+ "podcast.regenerate": "Napsat znovu",
"homeworkHeroTitle": "Udělej mi úkol",
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
"homework.phone.assignment": "Zadání",
diff --git a/lib/podcast-speech.ts b/lib/podcast-speech.ts
new file mode 100644
index 0000000..f08d874
--- /dev/null
+++ b/lib/podcast-speech.ts
@@ -0,0 +1,247 @@
+"use client";
+
+/**
+ * The podcast player's voice: one section at a time, with the spoken word
+ * reported back so the transcript can highlight it (the Spotify-lyrics bit).
+ *
+ * Two clocks drive the highlight. Where the browser fires `onboundary`
+ * (desktop Chrome and friends) that is exact — the character index maps onto
+ * the word list. Where it does not (iOS Safari, several Android engines) a
+ * timer estimates each word from its length and the playback rate, so the
+ * highlight still moves at roughly speaking speed. The first boundary seen
+ * retires the timer for the rest of the session.
+ *
+ * Pause/seek are implemented by cancelling and re-speaking from a word: the
+ * platform `pause()` is unreliable across browsers, and re-speaking also makes
+ * "jump to a chapter" and "resume where you left off" the same operation.
+ */
+
+import { pickVoice, speechAvailable } from "./speech";
+import type { PodcastSection } from "./schemas";
+
+export type PodcastVoice = { section: number; word: number };
+
+export type PodcastSpeechEvents = {
+ sections: PodcastSection[];
+ locale: string;
+ rate: number;
+ /** Global word position changed (word = index inside its section). */
+ onWord: (position: PodcastVoice) => void;
+ /** Playback actually began (after the engine accepted it). */
+ onPlay?: () => void;
+ onPause?: () => void;
+ onEnd: () => void;
+ onError?: (message: string) => void;
+};
+
+export type Token = {
+ section: number;
+ /** Index of the word inside its section. */
+ index: number;
+ /** Index across the whole episode (the transcript's data-word). */
+ global: number;
+ start: number;
+ end: number;
+ text: string;
+};
+
+/** Split into words, keeping each one's character span inside its section. */
+export function tokenizeSections(sections: PodcastSection[]): Token[] {
+ const tokens: Token[] = [];
+ sections.forEach((section, sectionIndex) => {
+ const text = section.text;
+ const pattern = /\S+/g;
+ let index = 0;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(text)) !== null) {
+ tokens.push({
+ section: sectionIndex,
+ index,
+ global: tokens.length,
+ start: match.index,
+ end: match.index + match[0].length,
+ text: match[0],
+ });
+ index += 1;
+ }
+ });
+ return tokens;
+}
+
+/** Estimated spoken time of one word at rate 1 (ms). */
+function wordDuration(text: string): number {
+ return 95 + 42 * Math.max(1, text.replace(/[^\p{L}\p{N}]/gu, "").length);
+}
+
+export type PodcastSpeech = {
+ play: (from?: PodcastVoice) => void;
+ pause: () => void;
+ stop: () => void;
+ playing: () => boolean;
+ position: () => PodcastVoice | null;
+ words: Token[];
+};
+
+export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech {
+ const tokens = tokenizeSections(events.sections);
+ let playing = false;
+ let position: PodcastVoice | null = null;
+ let boundariesWork: boolean | null = null;
+ let timer: number | null = null;
+ /** Cleared by cancel(): stops stale timers/events from a previous utterance. */
+ let generation = 0;
+
+ const clearTimer = () => {
+ if (timer !== null) {
+ window.clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ const finishPass = () => {
+ playing = false;
+ events.onPause?.();
+ };
+
+ const wordsIn = (section: number) => tokens.filter((token) => token.section === section);
+
+ /** Speak the section from `word` on. Empty word list → the next section. */
+ const speakFrom = (target: PodcastVoice) => {
+ if (!speechAvailable()) {
+ events.onError?.("unsupported");
+ return;
+ }
+ const section = events.sections[target.section];
+ if (!section) {
+ position = null;
+ finishPass();
+ events.onEnd();
+ return;
+ }
+ const sectionTokens = wordsIn(target.section);
+ if (sectionTokens.length === 0) {
+ speakFrom({ section: target.section + 1, word: 0 });
+ return;
+ }
+ const word = Math.min(Math.max(0, target.word), sectionTokens.length - 1);
+ position = { section: target.section, word };
+
+ const from = sectionTokens[word].start;
+ const text = section.text.slice(from);
+ const current = ++generation;
+ try {
+ window.speechSynthesis.cancel();
+ const utterance = new SpeechSynthesisUtterance(text);
+ utterance.lang = events.locale;
+ utterance.rate = events.rate;
+ const voice = pickVoice(events.locale);
+ if (voice) utterance.voice = voice;
+
+ const offsetFor = (charIndex: number) => {
+ /* First token whose span ends after the reported character. */
+ let found = word;
+ for (let i = word; i < sectionTokens.length; i += 1) {
+ const token = sectionTokens[i];
+ if (token.end > from + charIndex) {
+ found = i;
+ break;
+ }
+ found = i;
+ }
+ return found;
+ };
+
+ utterance.onboundary = (event) => {
+ if (generation !== current) return;
+ if (event.name && event.name !== "word") return;
+ boundariesWork = true;
+ clearTimer();
+ const index = offsetFor(event.charIndex);
+ position = { section: target.section, word: index };
+ events.onWord(position);
+ };
+
+ utterance.onstart = () => {
+ if (generation !== current) return;
+ playing = true;
+ events.onPlay?.();
+ events.onWord(position ?? { section: target.section, word });
+ /* The fallback clock: only while this browser has never reported a
+ * boundary. Each tick schedules the next word by its length. */
+ if (boundariesWork !== true) {
+ let index = word;
+ const tick = () => {
+ if (generation !== current) return;
+ index += 1;
+ if (index >= sectionTokens.length) {
+ /* Let onend take over; a long word list can outlast the audio
+ * by a hair, and stopping the tick avoids double-advancing. */
+ timer = null;
+ return;
+ }
+ position = { section: target.section, word: index };
+ events.onWord(position);
+ timer = window.setTimeout(tick, wordDuration(sectionTokens[index].text) / events.rate);
+ };
+ timer = window.setTimeout(tick, wordDuration(sectionTokens[word].text) / events.rate);
+ }
+ };
+
+ utterance.onend = () => {
+ if (generation !== current) return;
+ clearTimer();
+ /* An utterance that ended without a single boundary means the browser
+ does not report them: the fallback clock was the right call. */
+ if (boundariesWork === null) boundariesWork = false;
+ if (target.section + 1 >= events.sections.length) {
+ position = null;
+ finishPass();
+ events.onEnd();
+ return;
+ }
+ speakFrom({ section: target.section + 1, word: 0 });
+ };
+
+ utterance.onerror = (event) => {
+ if (generation !== current) return;
+ clearTimer();
+ playing = false;
+ /* "interrupted"/"canceled" are our own stops, not failures. */
+ const reason = (event as SpeechSynthesisErrorEvent).error;
+ if (reason !== "interrupted" && reason !== "canceled") {
+ events.onError?.(String(reason ?? "error"));
+ events.onPause?.();
+ }
+ };
+
+ window.speechSynthesis.speak(utterance);
+ } catch {
+ playing = false;
+ events.onError?.("error");
+ }
+ };
+
+ return {
+ play(from) {
+ speakFrom(from ?? position ?? { section: 0, word: 0 });
+ },
+ pause() {
+ generation += 1;
+ clearTimer();
+ try {
+ window.speechSynthesis.cancel();
+ } catch {
+ /* already gone */
+ }
+ playing = false;
+ events.onPause?.();
+ },
+ stop() {
+ this.pause();
+ position = null;
+ },
+ playing: () => playing,
+ position: () => position,
+ words: tokens,
+ };
+}
diff --git a/lib/podcasts.ts b/lib/podcasts.ts
new file mode 100644
index 0000000..0c28d35
--- /dev/null
+++ b/lib/podcasts.ts
@@ -0,0 +1,172 @@
+"use client";
+
+/**
+ * Podcast episodes backed by localStorage — the same tiny observable store as
+ * the homework history. An episode is the script the voice reads, the notes it
+ * came from (so it can be regenerated) and the word the listener got to.
+ *
+ * Photos and documents were reduced to text on the client before this point;
+ * originals are never stored.
+ */
+
+import { podcastSchema, type Podcast } from "./schemas";
+import { markSyncDirty } from "./sync-state";
+
+const STORAGE_KEY = "freezmo.podcasts.v1";
+const EMPTY: Podcast[] = [];
+/** Enough to regenerate from, while staying well under the storage quota. */
+export const MAX_STORED_SOURCE = 24_000;
+
+type Listener = () => void;
+
+let episodes: Podcast[] | null = null;
+let cached: Podcast[] = EMPTY;
+const listeners = new Set();
+
+function read(): Podcast[] {
+ if (typeof window === "undefined") return EMPTY;
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return EMPTY;
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return EMPTY;
+ const valid: Podcast[] = [];
+ for (const entry of parsed) {
+ const result = podcastSchema.safeParse(entry);
+ if (result.success) valid.push(result.data);
+ else console.warn("[freezmo] skipping invalid podcast", result.error.issues);
+ }
+ return valid;
+ } catch (error) {
+ console.warn("[freezmo] could not read the podcasts", error);
+ return EMPTY;
+ }
+}
+
+function ensureLoaded(): void {
+ if (episodes !== null) return;
+ episodes = read();
+ cached = episodes;
+}
+
+function write(next: Podcast[]): void {
+ episodes = next;
+ cached = next;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch (error) {
+ console.warn("[freezmo] could not save the podcasts (storage full?)", error);
+ }
+ /* An episode change with sync on pushes itself (see lib/sync-auto). */
+ if (markSyncDirty()) void import("./sync-auto").then((module) => module.scheduleAutoSync());
+ listeners.forEach((listener) => listener());
+}
+
+/** Used by backup import and sync: wholesale replacement of the list. */
+export function replacePodcasts(next: Podcast[]): void {
+ const valid: Podcast[] = [];
+ for (const entry of next) {
+ const result = podcastSchema.safeParse(entry);
+ if (result.success) valid.push(entry);
+ else console.warn("[freezmo] skipping invalid podcast in import", result.error.issues);
+ }
+ write(valid);
+}
+
+function onStorage(event: StorageEvent): void {
+ if (event.key === STORAGE_KEY) {
+ episodes = null;
+ ensureLoaded();
+ listeners.forEach((listener) => listener());
+ }
+}
+
+export const podcastStore = {
+ subscribe(listener: Listener): () => void {
+ ensureLoaded();
+ if (listeners.size === 0) window.addEventListener("storage", onStorage);
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ if (listeners.size === 0) window.removeEventListener("storage", onStorage);
+ };
+ },
+ getSnapshot(): Podcast[] {
+ ensureLoaded();
+ return cached;
+ },
+ getServerSnapshot(): Podcast[] {
+ return EMPTY;
+ },
+};
+
+/** Newest first. */
+export function sortPodcasts(list: Podcast[]): Podcast[] {
+ return [...list].sort((a, b) => b.updatedAt - a.updatedAt);
+}
+
+export function getPodcast(id: string): Podcast | undefined {
+ return podcastStore.getSnapshot().find((episode) => episode.id === id);
+}
+
+export function upsertPodcast(episode: Podcast): void {
+ ensureLoaded();
+ const current = podcastStore.getSnapshot();
+ const index = current.findIndex((entry) => entry.id === episode.id);
+ const next = index === -1 ? [episode, ...current] : current.map((entry) => (entry.id === episode.id ? episode : entry));
+ write(next);
+}
+
+export function renamePodcast(id: string, title: string): void {
+ ensureLoaded();
+ write(
+ podcastStore.getSnapshot().map((entry) =>
+ entry.id === id ? { ...entry, title, updatedAt: Date.now() } : entry,
+ ),
+ );
+}
+
+export function deletePodcast(id: string): void {
+ ensureLoaded();
+ write(podcastStore.getSnapshot().filter((entry) => entry.id !== id));
+}
+
+/**
+ * Where the listener got to. Written on pause/leave rather than per word, so
+ * a long episode does not thrash localStorage.
+ */
+export function savePodcastProgress(id: string, progress: { section: number; word: number }): void {
+ ensureLoaded();
+ const current = podcastStore.getSnapshot();
+ const episode = current.find((entry) => entry.id === id);
+ if (!episode) return;
+ const at = Date.now();
+ const previous = episode.progress;
+ /* First words of a section are the same place a retry would start: skip the
+ write when nothing meaningfully moved. */
+ if (previous && previous.section === progress.section && Math.abs(previous.word - progress.word) < 2) {
+ return;
+ }
+ const next = current.map((entry) =>
+ entry.id === id ? { ...entry, progress: { ...progress, at } } : entry,
+ );
+ /* updatedAt deliberately untouched: resuming must not reorder the library. */
+ episodes = next;
+ cached = next;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ /* storage full — progress is a nicety, not worth a warning */
+ }
+}
+
+/** Rough spoken length of the whole script, in minutes (150 words a minute). */
+export function podcastMinutes(episode: Podcast): number {
+ const words = episode.sections.reduce((total, section) => total + section.text.split(/\s+/).filter(Boolean).length, 0);
+ return Math.max(1, Math.round(words / 150));
+}
+
+/** The episode's script as one string (used by tests and fallbacks). */
+export function podcastScript(episode: Podcast): string {
+ return episode.sections.map((section) => section.text).join("\n\n");
+}
diff --git a/lib/prompts.ts b/lib/prompts.ts
index a4793ec..9d4f873 100644
--- a/lib/prompts.ts
+++ b/lib/prompts.ts
@@ -171,6 +171,69 @@ export function buildGenerationUserPrompt({
return lines.filter(Boolean).join("\n");
}
+/* ------------------------------------------------------------------ */
+/* Podcast mode */
+/* ------------------------------------------------------------------ */
+
+/**
+ * The episode is heard, not read: no markdown, no lists, no headings inside
+ * the prose. Sections are chapters (shown in the player); the text of each one
+ * is what the voice reads, so it has to survive being spoken aloud.
+ */
+export const PODCAST_SYSTEM_PROMPT = `You are Freezmo's podcast writer. You turn a student's own study notes into a short spoken episode — one friendly, curious host talking the listener through the material, the way a good explainer podcast does.
+
+${LANGUAGE_RULES}
+
+Hard rules:
+- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes are thin, the episode is shorter — never padded with outside knowledge.
+- Write for the ear. Short sentences. Natural spoken rhythm. Contractions are fine.
+- Plain prose only: no markdown, no bullet points, no headings inside a section's text, no stage directions, no speaker labels, no sound effects, no "in this episode" boilerplate beyond a short spoken greeting if it fits naturally.
+- No visual references ("as you can see", "the table above"), no page numbers, no "the notes" — talk to the listener about the subject itself.
+- Explain, don't just list: connect ideas, give the why, use one small analogy when the notes support it, and call back to earlier parts of the episode where it helps.
+- Do not read the notes back verbatim; rewrite them into spoken language while keeping every fact and number exactly as written.
+- Spell out anything that is painful to hear as symbols (say "for example", not "e.g."); keep formulas and units spoken naturally.
+- Cover the whole notes, in a sensible teaching order.
+
+Required JSON shape:
+{
+ "title": "short episode title, max 8 words, in the notes' language",
+ "language": "English name of the language you wrote in",
+ "summary": "one spoken-style sentence about the episode",
+ "sections": [
+ { "heading": "2-4 word chapter label", "text": "60-140 words of narration" }
+ ]
+}`;
+
+export function buildPodcastUserPrompt({
+ notes,
+ minutes,
+ language,
+ focus,
+}: {
+ notes: string;
+ /** Target episode length in minutes (words are estimated at ~150/min). */
+ minutes: number;
+ language?: string;
+ focus?: string;
+}): string {
+ const words = Math.round(minutes * 150);
+ const sections = Math.max(3, Math.min(8, Math.round(minutes)));
+ const lines = [
+ `Write a ${minutes}-minute podcast episode (about ${words} words, ${sections} sections) from the notes below.`,
+ language
+ ? `LANGUAGE: write everything in ${language}. Do not translate into English.`
+ : "LANGUAGE: detect the language of the notes below and write everything in that same language.",
+ focus ? `The listener especially wants the episode to cover: ${focus}` : "",
+ "",
+ "=== STUDENT NOTES (the only allowed source of facts) ===",
+ notes.slice(0, 48_000),
+ "=== END OF NOTES ===",
+ "",
+ "Respond with the JSON object only.",
+ ];
+ return lines.filter(Boolean).join("\n");
+}
+
/* ------------------------------------------------------------------ */
/* Short-answer grading */
/* ------------------------------------------------------------------ */
diff --git a/lib/schemas.ts b/lib/schemas.ts
index e3c863f..032d69f 100644
--- a/lib/schemas.ts
+++ b/lib/schemas.ts
@@ -261,6 +261,60 @@ export type HomeworkMakeRecord = Extract;
export type HomeworkGuideRecord = Extract;
export type HomeworkCheckRecord = Extract;
+/* ------------------------------------------------------------------ */
+/* Podcasts (notes turned into a spoken episode) */
+/* ------------------------------------------------------------------ */
+
+export type PodcastSection = { heading: string; text: string };
+
+/** As produced by the model (no id yet). */
+export const generatedPodcastSchema = z.object({
+ title: z.string().min(1).max(200),
+ language: z.string().max(60).optional(),
+ summary: z.string().max(400).optional(),
+ sections: z
+ .array(
+ z.object({
+ heading: z.string().min(1).max(120),
+ text: z.string().min(1).max(4000),
+ }),
+ )
+ .min(1)
+ .max(12),
+});
+export type GeneratedPodcast = z.infer;
+
+/** Stored episode: the script plus where it came from and where the listener
+ * got to (so reopening it resumes, Spotify-style). */
+export type Podcast = {
+ id: string;
+ title: string;
+ summary?: string;
+ language?: string;
+ sourceText: string;
+ createdAt: number;
+ updatedAt: number;
+ sections: PodcastSection[];
+ progress?: { section: number; word: number; at: number };
+};
+
+export const podcastSchema: z.ZodType = z.object({
+ id: z.string().min(1).max(80),
+ title: z.string().min(1).max(200),
+ summary: z.string().max(400).optional(),
+ language: z.string().max(60).optional(),
+ sourceText: z.string().max(40_000),
+ createdAt: z.number(),
+ updatedAt: z.number(),
+ sections: z
+ .array(z.object({ heading: z.string().min(1).max(120), text: z.string().min(1).max(4000) }))
+ .min(1)
+ .max(12),
+ progress: z
+ .object({ section: z.number().int().min(0), word: z.number().int().min(0), at: z.number() })
+ .optional(),
+});
+
/* ------------------------------------------------------------------ */
/* Client-side deck types (activity + id/xp, plus storage metadata) */
/* ------------------------------------------------------------------ */
diff --git a/lib/speech.ts b/lib/speech.ts
index 822f0de..97cfea1 100644
--- a/lib/speech.ts
+++ b/lib/speech.ts
@@ -76,8 +76,9 @@ export function speechLocale(language: string | undefined, fallback: string): st
return fallback === "cs" ? "cs-CZ" : "en-US";
}
-/** The installed voice closest to `locale` (exact match, then language). */
-function pickVoice(locale: string): SpeechSynthesisVoice | null {
+/** The installed voice closest to `locale` (exact match, then language).
+ * Shared with the podcast player (lib/podcast-speech). */
+export function pickVoice(locale: string): SpeechSynthesisVoice | null {
try {
const voices = window.speechSynthesis.getVoices();
if (voices.length === 0) return null;
diff --git a/lib/sync.ts b/lib/sync.ts
index dea14c9..65df75e 100644
--- a/lib/sync.ts
+++ b/lib/sync.ts
@@ -17,6 +17,7 @@ import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-stor
import { ApiError } from "./client-api";
import { replaceFolders } from "./folders";
import { replaceHomework } from "./homework";
+import { podcastStore, replacePodcasts } from "./podcasts";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { localeHeader, t } from "./i18n";
import { authHeaders } from "./settings";
@@ -173,6 +174,7 @@ async function buildPayload(): Promise {
decks: deckStore.getSnapshot(),
folders: folderStore.getSnapshot(),
homework: homeworkStore.getSnapshot(),
+ podcasts: podcastStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(),
@@ -188,6 +190,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
replaceFolders(parsed.data.folders);
replaceDecks(parsed.data.decks);
replaceHomework(parsed.data.homework);
+ replacePodcasts(parsed.data.podcasts ?? []);
replaceChats(parsed.data.chats ?? []);
});
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);
diff --git a/package.json b/package.json
index 0c0f673..3587d36 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "freezmo",
- "version": "0.7.2",
+ "version": "0.7.3",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
diff --git a/public/sw.js b/public/sw.js
index 0faa220..4c3a44e 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
-const CACHE_VERSION = "freezmo-v94";
+const CACHE_VERSION = "freezmo-v95";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
diff --git a/tests/README.md b/tests/README.md
index dffafe4..112ed2e 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -35,6 +35,7 @@ prints a per-suite summary at the end.
| `native-test.mjs` | Manifest/PWA head, offline banner + disabled generate, share target GET + POST, Back-gesture layers, undo toast, tab re-tap, haptics, draft restore. |
| `i18n-test.mjs` | EN/CS dictionary parity, locale switch, Czech desktop shell, Czech phone tab labels and plurals. |
| `freeze-loading-test.mjs` | The generation loading screen appears immediately, hides the action bar, Cancel returns with the notice. |
+| `podcast-test.mjs` | Podcast mode against a stub Ollama and a stubbed speech engine: the third wizard segment, notes → a saved episode, the word-wrapped transcript, boundary-driven highlighting (and the timer fallback's contract), pause/resume-from-word, chapter jumps, speed changes that keep playing, the position saved on page-hide and resumed on reopen, the library tab, and the API's 400/stream guards. |
| `review-test.mjs` | Smart Review loading feedback under a stalled request, the session end-to-end, mistakes, and the health-probe cache. |
| `phase1-test.mjs` | Backup export/import, persistence row, nudge, encrypted sync (enable/join/push/delete + API validation), usage-limit handling (client + a stub Ollama). |
| `phase2-test.mjs` | Answer history + SRS from a real study run, review/mistakes, stats, exam mode, push reminders hidden without VAPID and validated with it. |
diff --git a/tests/contrast.mjs b/tests/contrast.mjs
index 1e4b604..320d02f 100644
--- a/tests/contrast.mjs
+++ b/tests/contrast.mjs
@@ -43,6 +43,7 @@ const screens = [
["chat", "/chat", null],
["stats", "/stats", null],
["exam", "/exam", null],
+ ["podcast", "/podcast/pod_contrast", null],
["settings", "/", "settings"],
["notfound", "/no-such-page", null],
];
@@ -51,7 +52,27 @@ for (const [label, path, mode] of screens) {
if (filter && !label.includes(filter)) continue;
for (const theme of ["dark", "light", "aero"]) {
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 });
- await seed(page, { decks: [DECK], theme });
+ await seed(page, {
+ decks: [DECK],
+ theme,
+ extra: {
+ "freezmo.podcasts.v1": JSON.stringify([
+ {
+ id: "pod_contrast",
+ title: "Why the sky is blue",
+ summary: "Rayleigh scattering, explained out loud.",
+ language: "English",
+ sourceText: "Rayleigh scattering makes the sky blue.",
+ createdAt: 1,
+ updatedAt: 1,
+ sections: [
+ { heading: "Daylight", text: "Look up on a clear day. The sky is blue — a blue so obvious that it is easy to stop asking why." },
+ { heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones." },
+ ],
+ },
+ ]),
+ },
+ });
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
if (mode === "settings") {
diff --git a/tests/podcast-test.mjs b/tests/podcast-test.mjs
new file mode 100644
index 0000000..60dae2a
--- /dev/null
+++ b/tests/podcast-test.mjs
@@ -0,0 +1,245 @@
+/**
+ * Podcast mode: notes → a spoken episode with a synced transcript.
+ *
+ * Runs against a stub Ollama (no model needed) and a stubbed speech engine, so
+ * the word highlight can be driven deterministically: the stub records what
+ * was spoken and fires boundary events on demand.
+ */
+import http from "node:http";
+import { mkdirSync } from "node:fs";
+
+import {
+ launchBrowser,
+ makeChecker,
+ makePage,
+ portInUse,
+ seed,
+ spawnApp,
+ waitForHttp,
+} from "./lib.mjs";
+
+const { check, finish } = makeChecker();
+
+const STUB_PORT = 3211;
+const APP_PORT = 3212;
+const STUB_BASE = `http://127.0.0.1:${STUB_PORT}`;
+const APP = `http://127.0.0.1:${APP_PORT}`;
+
+/** Two sections, a handful of words each — enough to watch the highlight walk. */
+const EPISODE_REPLY = {
+ title: "Why the sky is blue",
+ language: "English",
+ summary: "Rayleigh scattering, explained out loud.",
+ sections: [
+ { heading: "Daylight", text: "Look up on a clear day. The sky is blue." },
+ { heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones." },
+ ],
+};
+
+const stub = http.createServer((request, response) => {
+ if (request.url?.startsWith("/api/tags")) {
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ models: [{ name: "stub", model: "stub" }] }));
+ return;
+ }
+ let raw = "";
+ request.on("data", (chunk) => (raw += chunk));
+ request.on("end", () => {
+ let content = "{}";
+ try {
+ const body = JSON.parse(raw);
+ const system = String(body.messages?.[0]?.content ?? "");
+ /* The podcast writer answers slowly on purpose: a real script takes a
+ model a while, and the loading screen must be observable. */
+ if (system.includes("podcast writer")) {
+ content = JSON.stringify(EPISODE_REPLY);
+ setTimeout(() => {
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ message: { content } }));
+ }, 900);
+ return;
+ }
+ if (system.includes("study-note transcriber")) {
+ content = JSON.stringify({
+ title: "Sky notes",
+ language: "English",
+ notes: "Rayleigh scattering makes the sky blue.",
+ keyConcepts: ["scattering"],
+ });
+ }
+ } catch {
+ /* keep the default */
+ }
+ response.writeHead(200, { "Content-Type": "application/json" });
+ response.end(JSON.stringify({ message: { content } }));
+ });
+});
+await new Promise((resolve) => stub.listen(STUB_PORT, "127.0.0.1", resolve));
+
+check("podcast app port is free", !(await portInUse(APP_PORT)));
+const syncDir = new URL("./.tmp/sync-podcast", import.meta.url).pathname;
+mkdirSync(syncDir, { recursive: true });
+const app = spawnApp(APP_PORT, {
+ OLLAMA_BASE_URL: STUB_BASE,
+ OLLAMA_AUTH: "local",
+ OLLAMA_VERIFY_KEY: "false",
+ SYNC_DIR: syncDir,
+});
+
+const browser = await launchBrowser();
+
+/** Records utterances and lets the test fire boundaries/ends by hand. */
+const SPEECH_STUB = `
+window.__spoken = [];
+window.__cancelCount = 0;
+window.__utterances = [];
+class FakeUtterance {
+ constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; }
+}
+Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
+Object.defineProperty(window, "speechSynthesis", {
+ configurable: true,
+ value: {
+ speak(utterance) {
+ window.__spoken.push(utterance.text);
+ window.__utterances.push(utterance);
+ queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 }));
+ },
+ cancel() { window.__cancelCount += 1; },
+ getVoices() { return []; },
+ speaking: false,
+ paused: false,
+ },
+});
+`;
+
+try {
+ const up = await waitForHttp(`${APP}/api/health`);
+ check("podcast app instance is up", up);
+
+ if (up) {
+ /* 1. the wizard: mode switch, intake, generation ---------------- */
+ const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
+ await page.addInitScript(SPEECH_STUB);
+ await seed(page, { decks: [], theme: "dark" });
+
+ await page.goto(`${APP}/create`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(1000);
+ const switchLink = page.getByRole("link", { name: "Podcast" });
+ check("wizard: the mode switch carries a podcast segment", (await switchLink.count()) === 1);
+ await switchLink.click();
+ await page.waitForTimeout(1200);
+ check("wizard: the podcast intake opens", page.url().endsWith("/podcast"));
+ check("wizard: the length options are offered", (await page.getByRole("button", { name: "4 minutes" }).count()) === 1);
+
+ await page.fill("#podcast-text", "Rayleigh scattering makes the sky blue: molecules scatter short wavelengths far more than long ones.");
+ await page.waitForTimeout(200);
+ await page.getByRole("button", { name: "Create the episode" }).first().click();
+ /* Loading screen while the stub answers. */
+ await page.waitForTimeout(600);
+ const loading = (await page.getByText(/Writing the script|Reading your notes/).count()) >= 1;
+ await page.waitForURL(/\/podcast\/pod_/, { timeout: 30_000 }).catch(() => {});
+ await page.waitForTimeout(1200);
+ check("wizard: it lands on the player", /\/podcast\/pod_/.test(page.url()), page.url());
+ check("wizard: the loading screen showed a script stage", loading);
+
+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.podcasts.v1") ?? "[]"));
+ check("wizard: the episode is saved with both sections", stored.length === 1 && stored[0].sections.length === 2, JSON.stringify(stored[0]?.sections?.length));
+ check("wizard: the notes ride along for a rewrite", (stored[0]?.sourceText ?? "").includes("Rayleigh"));
+
+ /* 2. the player: transcript, highlight, controls ----------------- */
+ const words = await page.locator("[data-word]").count();
+ /* "Look up on a clear day. The sky is blue." (10) + "Tiny molecules
+ scatter short wavelengths far more than long ones." (10). */
+ check("player: the transcript is word-wrapped", words === 20, String(words));
+ check("player: nothing is highlighted before playing", (await page.locator("[data-word-active]").count()) === 0);
+
+ await page.getByRole("button", { name: "Play the episode" }).click();
+ await page.waitForTimeout(400);
+ const firstSpoken = await page.evaluate(() => window.__spoken[0]);
+ check("player: the first section is what gets spoken", String(firstSpoken).startsWith("Look up on a clear day."), String(firstSpoken).slice(0, 40));
+ check("player: the first word is highlighted", (await page.locator("[data-word-active]").count()) === 1);
+
+ /* The boundary events are the exact clock: jump to a later word. */
+ await page.evaluate(() => {
+ const utterance = window.__utterances.at(-1);
+ utterance.onboundary({ name: "word", charIndex: 12 });
+ });
+ await page.waitForTimeout(300);
+ const active = await page.locator("[data-word-active]").first();
+ check("player: a boundary event moves the highlight", (await active.textContent()) === "clear", String(await active.textContent()));
+
+ /* Pausing stops the voice and keeps the place. */
+ await page.getByRole("button", { name: "Pause the episode" }).click();
+ await page.waitForTimeout(300);
+ const cancelled = await page.evaluate(() => window.__cancelCount);
+ check("player: pause cancels the voice", cancelled >= 1, String(cancelled));
+
+ /* Resuming speaks from the highlighted word, not from the top. */
+ await page.getByRole("button", { name: "Play the episode" }).click();
+ await page.waitForTimeout(400);
+ const resumed = await page.evaluate(() => window.__spoken.at(-1));
+ check("player: resume starts at the current word", String(resumed).startsWith("clear day."), String(resumed).slice(0, 30));
+
+ /* 3. chapters, speed, progress save ------------------------------ */
+ await page.getByRole("button", { name: "Why", exact: true }).click();
+ await page.waitForTimeout(500);
+ const chapterSpoken = await page.evaluate(() => window.__spoken.at(-1));
+ check("player: a chapter jump speaks that section", String(chapterSpoken).startsWith("Tiny molecules"), String(chapterSpoken).slice(0, 30));
+ check(
+ "player: the header names the part",
+ (await page.getByText("Part 2 of 2").count()) === 1,
+ );
+
+ await page.getByRole("button", { name: "Playback speed" }).click();
+ await page.waitForTimeout(500);
+ const rate = await page.evaluate(() => {
+ const utterance = window.__utterances.at(-1);
+ return utterance?.rate;
+ });
+ check("player: the speed applies to the voice", rate === 1.15, String(rate));
+
+ /* Speed changes rebuild the engine mid-play; it keeps speaking. */
+ const spokenCount = await page.evaluate(() => window.__spoken.length);
+ check("player: changing speed keeps playing", spokenCount >= 4, String(spokenCount));
+
+ /* Leaving saves the position for the next visit. */
+ await page.goto(`${APP}/?tab=podcasts`, { waitUntil: "networkidle" });
+ await page.waitForTimeout(900);
+ const saved = await page.evaluate(() => JSON.parse(localStorage.getItem("freezmo.podcasts.v1") ?? "[]")[0]?.progress);
+ check("player: the position is saved on leave", Boolean(saved) && saved.section === 1, JSON.stringify(saved));
+
+ /* 4. the library tab -------------------------------------------- */
+ check("library: the podcasts tab lists the episode", (await page.getByText("Why the sky is blue").count()) === 1);
+ await page.getByRole("link", { name: "Listen" }).click();
+ await page.waitForTimeout(1200);
+ check("library: Listen opens the player", /\/podcast\/pod_/.test(page.url()), page.url());
+ check(
+ "player: reopening resumes at the saved part",
+ (await page.getByText("Part 2 of 2").count()) === 1,
+ );
+ await context.close();
+
+ /* 5. server guards ---------------------------------------------- */
+ const bad = await fetch(`${APP}/api/podcast`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "" }),
+ });
+ check("api: empty notes are refused", bad.status === 400, String(bad.status));
+
+ const streamed = await fetch(`${APP}/api/podcast`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: "Some notes about the sky.", minutes: 3 }),
+ });
+ const body = await streamed.text();
+ check("api: a script streams back with a podcast event", body.includes('"type":"podcast"') && body.includes('"sections"'), body.slice(0, 120));
+ }
+} finally {
+ app.stop();
+ stub.close();
+ await browser.close();
+}
+
+finish();
diff --git a/tests/run-all.mjs b/tests/run-all.mjs
index 3b5478e..3226625 100644
--- a/tests/run-all.mjs
+++ b/tests/run-all.mjs
@@ -24,6 +24,7 @@ const SUITES = [
"phase1-test",
"phase2-test",
"phase3-test",
+ "podcast-test",
"security-test",
"chat-test",
"providers-test",
diff --git a/tests/wizard-test.mjs b/tests/wizard-test.mjs
index 5f2f44e..9e3e6b7 100644
--- a/tests/wizard-test.mjs
+++ b/tests/wizard-test.mjs
@@ -99,7 +99,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await page.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(800);
- const switcher = page.getByRole("group", { name: "Study deck or homework" });
+ const switcher = page.getByRole("group", { name: "Study deck, homework or podcast" });
check("mode switch on the New deck screen", await switcher.isVisible());
check("mode switch marks the deck side active", (await switcher.locator('[aria-current="page"]').innerText()) === "Study deck");
await switcher.getByRole("link", { name: "Make my homework" }).click();
@@ -107,9 +107,9 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
check("mode switch lands on the homework wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check(
"mode switch marks the homework side active",
- (await page.getByRole("group", { name: "Study deck or homework" }).locator('[aria-current="page"]').innerText()) === "Make my homework",
+ (await page.getByRole("group", { name: "Study deck, homework or podcast" }).locator('[aria-current="page"]').innerText()) === "Make my homework",
);
- await page.getByRole("group", { name: "Study deck or homework" }).getByRole("link", { name: "Study deck" }).click();
+ await page.getByRole("group", { name: "Study deck, homework or podcast" }).getByRole("link", { name: "Study deck" }).click();
await page.waitForTimeout(900);
check("mode switch returns to the deck wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible());
await context.close();
@@ -125,7 +125,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
const switcherTop = async () => {
await page.waitForTimeout(700);
return page.evaluate(() => {
- const switcher = document.querySelector('[role="group"][aria-label="Study deck or homework"]');
+ const switcher = document.querySelector('[role="group"][aria-label="Study deck, homework or podcast"]');
return switcher ? Math.round(switcher.getBoundingClientRect().top) : null;
});
};
@@ -170,18 +170,18 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300);
- check("loading: mode switch is there before the run", await page.getByRole("group", { name: "Study deck or homework" }).isVisible());
+ check("loading: mode switch is there before the run", await page.getByRole("group", { name: "Study deck, homework or podcast" }).isVisible());
await page.getByRole("button", { name: "Freeze my deck" }).click();
await page.waitForTimeout(800);
check("loading: deck run reaches the loading screen", await page.getByRole("button", { name: "Cancel" }).isVisible());
- check("loading: deck run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);
+ check("loading: deck run hides the mode switch", (await page.getByRole("group", { name: "Study deck, homework or podcast" }).count()) === 0);
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(900);
check(
"loading: deck Cancel returns to the wizard (notice + retry)",
(await page.getByRole("heading", { name: "Ready to freeze" }).isVisible()) && (await page.getByRole("button", { name: "Retry" }).isVisible()),
);
- check("loading: mode switch returns after the cancel", await page.getByRole("group", { name: "Study deck or homework" }).isVisible());
+ check("loading: mode switch returns after the cancel", await page.getByRole("group", { name: "Study deck, homework or podcast" }).isVisible());
await page.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
await page.waitForTimeout(700);
@@ -192,7 +192,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
check("homework: AI disclaimer near the start cards", await page.getByText("Freezmo uses AI and can make mistakes").isVisible());
await page.getByRole("button", { name: /Just make it/ }).click();
await page.waitForTimeout(800);
- check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck or homework" }).count()) === 0);
+ check("loading: homework run hides the mode switch", (await page.getByRole("group", { name: "Study deck, homework or podcast" }).count()) === 0);
await page.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(900);
check("loading: homework Cancel returns to the wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());