Podcast mode: notes read aloud with a lyric-style word highlight

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.

- prompts: PODCAST_SYSTEM_PROMPT writes for the ear (plain prose, no markdown,
  no visual references), chaptered into 3-8 sections around a length target.
- /api/podcast mirrors /api/generate's plumbing (rate limit, model gate,
  provider keys, heartbeats, the 2x deadline, the optional vision pass).
- storage: frezmo.podcasts.v1, zod-validated, included in the backup and the
  encrypted sync payload; a new library tab lists episodes with listen /
  rename / delete-with-undo, and the wizard switch deep-links in.
- player: the transcript is word-wrapped spans and the word under the voice
  rides a lyric-style highlight that keeps itself centred. Two clocks: exact
  onboundary events where the browser reports them, a per-word length timer
  where it does not (iOS Safari); the first boundary retires the timer.
  Pause = cancel + re-speak from the word, which makes resume, chapter jumps
  and tap-any-word-to-seek the same operation; the position saves on pause,
  page-hide and unmount, so reopening resumes. Speed rebuilds the engine and
  keeps playing; the Aero soundtrack pauses itself when the voice starts; a
  browser with no voice gets an honest note and a readable transcript.
- the script sits on a glass pane (plain paragraphs over Aero's grass failed
  contrast; the scan now covers the player too).

tests/podcast-test.mjs (17th suite) stubs the model and the speech engine and
drives the highlight by hand: 26 checks. All suites green, contrast clean.
Version 0.7.3, SW v95.
This commit is contained in:
Martin Dvořák 2026-10-16 11:42:15 +00:00
commit d95c5c7151
30 changed files with 2575 additions and 26 deletions

View file

@ -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

234
app/api/podcast/route.ts Normal file
View file

@ -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<string, unknown> }
| { 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<typeof setInterval> | null = null;
const stream = new ReadableStream<Uint8Array>({
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;
}

View file

@ -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;

View file

@ -19,7 +19,9 @@ export default async function HomePage({
<AppHeader />
<div className="mx-auto w-full max-w-6xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<RouteTransition>
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} />
<LibraryScreen
initialTab={tab === "homework" ? "homework" : tab === "podcasts" ? "podcasts" : "decks"}
/>
</RouteTransition>
</div>
</main>

72
app/podcast/[id]/page.tsx Normal file
View file

@ -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<Podcast | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (!id) {
setLoaded(true);
return;
}
setEpisode(getPodcast(id) ?? null);
setLoaded(true);
}, [id]);
if (!loaded) {
return (
<main className="frost-bg grid min-h-dvh place-items-center px-4">
<div className="w-full max-w-xl space-y-4" aria-busy="true" aria-label={t("podcast.loadingEpisode")}>
<div className="skeleton h-14 rounded-full" />
<div className="skeleton h-28 rounded-3xl" />
<div className="skeleton h-40 rounded-3xl" />
</div>
</main>
);
}
if (!episode) {
return (
<main className="frost-bg grid min-h-dvh place-items-center px-4">
<div className="card max-w-md space-y-3 p-6 text-center">
<p className="text-4xl" aria-hidden>
🎙️
</p>
<h1 className="text-xl font-extrabold text-[var(--text)]">{t("podcast.notFound")}</h1>
<p className="text-sm text-[var(--text-muted)]">{t("podcast.notFoundBody")}</p>
<div className="flex justify-center gap-2 pt-1">
<Link href="/?tab=podcasts" className="btn-secondary touch-target inline-flex h-11 items-center px-4 text-sm font-semibold">
{t("review.done")}
</Link>
<Button size="lg" className="touch-target" onClick={() => router.push("/podcast")}>
{t("podcast.title")}
</Button>
</div>
</div>
</main>
);
}
return (
<ApiKeyGate>
<main>
<PodcastPlayer episode={episode} />
</main>
</ApiKeyGate>
);
}

34
app/podcast/page.tsx Normal file
View file

@ -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 (
<ApiKeyGate>
<div className="flex min-h-dvh">
<Sidebar />
<SidebarOffset />
<main className="flex min-h-dvh min-w-0 flex-1 flex-col">
<AppHeader />
<div className="mx-auto w-full max-w-3xl flex-1 px-4 pb-tabbar lg:px-10 lg:pb-16 lg:pt-10">
<WizardModeSwitch mode="podcast" />
<RouteTransition>
<PodcastWizard />
</RouteTransition>
</div>
</main>
</div>
</ApiKeyGate>
);
}

View file

@ -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;

View file

@ -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,
})}
</p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>

View file

@ -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" ? <SegmentedPill layoutId="library-tabs" /> : null}
<span className="seg-label relative z-10">{t("tabHomework")}</span>
</Link>
<Link
href="/?tab=podcasts"
aria-current={tab === "podcasts" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && tab === "podcasts" ? <SegmentedPill layoutId="library-tabs" /> : null}
<span className="seg-label relative z-10">{t("tabPodcasts")}</span>
</Link>
</div>
<Link
href="/chat"
@ -585,6 +594,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</ul>
) : null}
{tab === "podcasts" ? <PodcastList /> : null}
</motion.div>
</AnimatePresence>

View file

@ -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" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.homework")}</span>
</Link>
<Link
href="/podcast"
aria-current={mode === "podcast" ? "page" : undefined}
className={cn("relative", isPhone && "seg-slide")}
>
{isPhone && mode === "podcast" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.podcast")}</span>
</Link>
</div>
);
}

View file

@ -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<string | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
if (sorted.length === 0) {
return (
<EmptyState
title={t("podcast.library.empty")}
action={
<Link href="/podcast" className="btn-primary touch-target h-11 px-5 text-sm">
{t("podcast.library.new")}
</Link>
}
>
{t("podcast.library.emptyBody")}
</EmptyState>
);
}
return (
<ul className="grid list-none grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4 lg:grid-cols-[repeat(auto-fill,minmax(320px,1fr))]">
<AnimatePresence initial={false}>
{sorted.map((episode, index) => {
const minutes = podcastMinutes(episode);
const progress = episode.progress;
return (
<motion.li
key={episode.id}
layout={!reduceMotion}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.24, delay: Math.min(index * 0.03, 0.18) }}
className="card card-hover relative flex flex-col gap-3 p-4"
>
<div className="flex items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200">
<MusicIcon className="size-5" />
</span>
<div className="min-w-0 flex-1">
{renaming === episode.id ? (
<input
autoFocus
value={draft}
onChange={(event) => 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"
/>
) : (
<h2 className="truncate text-[16px] font-bold text-[var(--text)]" title={episode.title}>
{episode.title}
</h2>
)}
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px] text-[var(--text-subtle)]">
<span className="inline-flex items-center gap-1">
<ClockIcon className="size-3.5" />
{t("podcast.card.minutes", { n: minutes })}
</span>
<span aria-hidden>·</span>
<span>{t("podcast.card.sections", { n: episode.sections.length })}</span>
{progress ? (
<>
<span aria-hidden>·</span>
<span>{t("podcast.sectionOf", { n: progress.section + 1, total: episode.sections.length })}</span>
</>
) : null}
</p>
</div>
<button
type="button"
onClick={() => 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)]"
>
<EllipsisIcon className="size-4" />
</button>
</div>
{episode.summary ? (
<p className="line-clamp-2 text-[12.5px] leading-relaxed text-[var(--text-muted)]">{episode.summary}</p>
) : null}
{menuFor === episode.id ? (
<div className="absolute right-3 top-14 z-20 w-44 overflow-hidden rounded-2xl border border-[var(--hairline)] bg-[var(--surface-raised)] p-1 shadow-[var(--shadow-card)]">
<button
type="button"
className="nav-row touch-target"
onClick={() => {
setDraft(episode.title);
setRenaming(episode.id);
setMenuFor(null);
}}
>
{t("podcast.card.rename")}
</button>
<button
type="button"
className="nav-row touch-target text-rose-600 dark:text-rose-300"
onClick={() => {
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")}
</button>
</div>
) : null}
<div className="mt-auto flex items-center gap-2">
<Link
href={`/podcast/${episode.id}`}
className={cn("btn-primary touch-target inline-flex h-10 flex-1 items-center justify-center gap-2 text-[13.5px]")}
>
<PlayIcon className="size-4" />
{t("podcast.card.open")}
</Link>
<span className="shrink-0 text-[11.5px] font-medium text-[var(--text-subtle)]">
{plural(episode.sections.length, "podcast.sectionCount")}
</span>
</div>
</motion.li>
);
})}
</AnimatePresence>
{/* Tapping anywhere else closes an open menu. */}
{menuFor ? (
<li aria-hidden className="fixed inset-0 z-10" onClick={() => setMenuFor(null)} />
) : null}
</ul>
);
}

View file

@ -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<number>(1);
const [playing, setPlaying] = useState(false);
const [position, setPosition] = useState<PodcastVoice | null>(null);
const [unsupported, setUnsupported] = useState(false);
const transcript = useRef<HTMLDivElement>(null);
const activeWord = useRef<HTMLElement | null>(null);
/** Latest position for the unmount/pause save, without re-creating callbacks. */
const latest = useRef<PodcastVoice | null>(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<HTMLElement>(`[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(
<span key={`${token.start}`} data-word={globalIndex} className="podcast-word">
{section.text.slice(token.start, token.end)}
</span>,
);
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 (
<div className="frost-bg fixed inset-0 z-[45] flex flex-col fade-in-mobile">
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
<div className="study-chrome mx-auto flex max-w-3xl items-center gap-2.5 rounded-full px-2.5 py-2">
<Link
href="/?tab=podcasts"
aria-label={t("feed.back")}
title={t("feed.back")}
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
>
<ChevronLeftIcon className="size-4" />
</Link>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold leading-tight text-[var(--text)]">{episode.title}</p>
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
{t("podcast.sectionOf", { n: Math.min(currentSection + 1, sections.length), total: sections.length })}
{" · "}
{sections[currentSection]?.heading ?? ""}
</p>
</div>
<button
type="button"
onClick={() => {
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}×
</button>
</div>
</header>
<div
ref={transcript}
data-testid="podcast-transcript"
onClick={(event) => {
/* 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"
>
<div className="mx-auto w-full max-w-3xl space-y-6">
{!supported || unsupported ? (
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
{t("podcast.noVoice")}
</p>
) : null}
<div className="card space-y-2 p-5">
<h1 className="text-[22px] font-extrabold leading-tight tracking-[-0.01em] text-[var(--text)]">
{episode.title}
</h1>
{episode.summary ? (
<p className="text-[13.5px] leading-relaxed text-[var(--text-muted)]">{episode.summary}</p>
) : null}
<p className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-subtle)]">
<ClockIcon className="size-3.5" />
{plural(episode.sections.length, "podcast.sectionCount")} · {plural(words, "podcast.wordCount")}
</p>
</div>
{/* 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. */}
<div className="card space-y-6 p-5" data-testid="podcast-script">
{rendered.map(({ section, sectionIndex, nodes }) => (
<section key={sectionIndex} className="space-y-2">
<h2 className="flex items-center gap-2 text-[12px] font-bold uppercase tracking-wide text-[var(--text-subtle)]">
<span className="grid size-6 place-items-center rounded-full bg-[var(--surface-sunken)] text-[11px] tabular-nums">
{sectionIndex + 1}
</span>
{section.heading}
</h2>
<p
className={cn(
"text-[17px] leading-[1.85] text-[var(--text-muted)] transition-colors sm:text-[18px]",
sectionIndex === currentSection && "text-[var(--text)]",
)}
>
{nodes}
</p>
</section>
))}
</div>
<p className="pb-6 text-center text-[11.5px] text-[var(--text-subtle)]">{t("podcast.tapToJump")}</p>
</div>
</div>
{/* Controls: chapter list on the left, transport in the middle. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 pb-safe">
<div className="pointer-events-auto mx-auto max-w-3xl px-3 pb-3">
<div className="study-chrome rounded-[1.4rem] px-3 py-2.5">
<div className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--track)]" aria-hidden>
<div
className="h-full rounded-full grad-ice transition-[width] duration-200"
style={{ width: words ? `${Math.round((done / words) * 100)}%` : "0%" }}
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={toggle}
disabled={!supported}
aria-label={playing ? t("podcast.pause") : t("podcast.play")}
title={playing ? t("podcast.pause") : t("podcast.play")}
className="grid size-12 shrink-0 place-items-center rounded-full grad-ice text-[#06111f] transition disabled:opacity-45"
>
{playing ? <PauseIcon className="size-5" /> : <PlayIcon className="size-5" />}
</button>
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{sections.map((section, index) => (
<button
key={index}
type="button"
onClick={() => 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}
</button>
))}
</div>
<button
type="button"
onClick={() => {
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)]"
>
<RefreshIcon className="size-4" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -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<PreparedImage[]>([]);
const [documents, setDocuments] = useState<DocItem[]>([]);
const [text, setText] = useState("");
const [focus, setFocus] = useState("");
const [minutes, setMinutes] = useState<number>(4);
const [phase, setPhase] = useState<Phase>("idle");
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const [progress, setProgress] = useState<GenerateProgress>({ stage: "generation", message: "" });
const fileInput = useRef<HTMLInputElement>(null);
const docInput = useRef<HTMLInputElement>(null);
const cameraInput = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(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 (
<LoadingState
stages={PODCAST_STAGES}
activeStage={progress.stage}
message={progress.message}
tipKeys={LOADING_TIPS}
onCancel={() => {
abortRef.current?.abort();
setPhase("idle");
}}
/>
);
}
const notesReady = imagesToSend.length > 0 || mergedText.length > 0;
const dropzone = (
<button
type="button"
onClick={() => (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",
)}
>
<span className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200">
<CameraIcon className="size-6" />
</span>
<span className="text-sm font-semibold text-[var(--text)]">{t("podcast.addNotes")}</span>
<span className="text-[12px] text-[var(--text-subtle)]">{t("upload.formats")}</span>
</button>
);
return (
<div className="space-y-5 pb-28 lg:pb-0">
<header className="card space-y-1.5 p-4">
<h1 className="flex items-center gap-2 text-[17px] font-bold text-[var(--text)]">
<MusicIcon className="size-5 text-accent-600 dark:text-accent-200" />
{t("podcast.title")}
</h1>
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">{t("podcast.subtitle")}</p>
</header>
<section className="card space-y-3 p-4">
{dropzone}
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => fileInput.current?.click()}>
<PlusIcon className="size-4" /> {t("podcast.addPhotos")}
</Button>
<Button variant="secondary" size="sm" onClick={() => docInput.current?.click()}>
<PlusIcon className="size-4" /> {t("podcast.addDocs")}
</Button>
{isPhone ? (
<Button variant="secondary" size="sm" onClick={() => cameraInput.current?.click()}>
<CameraIcon className="size-4" /> {t("upload.camera")}
</Button>
) : null}
</div>
{images.length > 0 || documents.length > 0 ? (
<ul className="flex flex-wrap items-center gap-2">
{images.map((image) => (
<li key={image.id} className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image.dataUrl} alt="" className="size-14 rounded-xl border border-[var(--hairline)] object-cover" />
<button
type="button"
onClick={() => 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)]"
>
<XIcon className="size-3" />
</button>
</li>
))}
{documents.map((entry) => (
<li
key={entry.id}
className={cn(
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5",
entry.status === "error" ? "border-rose-300/60 bg-rose-500/10" : "border-[var(--hairline)] bg-[var(--surface-strong)]",
)}
>
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
{KIND_LABELS[entry.kind]}
</span>
<span className="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
{entry.name}
</span>
<span className="text-[11.5px] text-[var(--text-subtle)]">
{entry.status === "extracting" ? entry.progress ?? t("upload.readingDocs") : null}
{entry.status === "error" ? entry.error : null}
</span>
<button
type="button"
onClick={() => 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)]"
>
<XIcon className="size-3" />
</button>
</li>
))}
</ul>
) : null}
{fileError ? (
<p role="alert" className="text-[12.5px] text-rose-700 dark:text-rose-300">
{fileError}
</p>
) : null}
</section>
<section className="card space-y-4 p-4">
<div className="space-y-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("podcast.lengthAria")}
</p>
<div className="flex flex-wrap gap-2">
{LENGTH_OPTIONS.map((option) => (
<button
key={option}
type="button"
aria-pressed={minutes === option}
onClick={() => 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)]",
)}
>
<ClockIcon className="size-4" />
{plural(option, "podcast.minutes")}
</button>
))}
</div>
<p className="text-[12px] text-[var(--text-subtle)]">{t("podcast.lengthHelp")}</p>
</div>
<div className="space-y-1.5">
<label htmlFor="podcast-focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("podcast.focusLabel")}
</label>
<input
id="podcast-focus"
value={focus}
onChange={(event) => 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"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="podcast-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("upload.pastedNotes")}
</label>
<textarea
id="podcast-text"
value={text}
onChange={(event) => setText(event.target.value)}
rows={isPhone ? 4 : 6}
placeholder={t("upload.pastedPlaceholder")}
className="w-full resize-y 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"
/>
</div>
</section>
{error ? (
<section className="card space-y-3 border-rose-300/50 p-4">
<p role="alert" className="text-[13px] leading-relaxed text-rose-700 dark:text-rose-300">
{error.message}
</p>
<ModelErrorActions kind={error.kind} />
</section>
) : null}
<AiDisclaimer />
{/* Desktop action row; phones get the pinned wizard bar below. */}
<div className="hidden sm:block">
<Button
size="lg"
className="w-full touch-target"
disabled={!canGenerate || extracting}
onClick={() => void generate()}
>
<SparklesIcon className="size-4" /> {t("podcast.generate")}
</Button>
</div>
<WizardActionBar>
<Button
size="lg"
className="flex-1 touch-target"
disabled={!canGenerate || extracting}
onClick={() => void generate()}
>
<SparklesIcon className="size-4" /> {notesReady ? t("podcast.generate") : t("podcast.addFirst")}
</Button>
</WizardActionBar>
<input
ref={fileInput}
type="file"
accept={ACCEPTED_TYPES.join(",")}
multiple
aria-hidden="true"
tabIndex={-1}
className="sr-only"
onChange={(event) => {
const files = [...(event.target.files ?? [])];
if (files.length) void addImages(files.filter(isImageFile));
event.target.value = "";
}}
/>
<input
ref={cameraInput}
type="file"
accept="image/*"
capture="environment"
aria-hidden="true"
tabIndex={-1}
className="sr-only"
onChange={(event) => {
const files = [...(event.target.files ?? [])];
if (files.length) void addImages(files.filter(isImageFile));
event.target.value = "";
}}
/>
<input
ref={docInput}
type="file"
accept={DOCUMENT_ACCEPT_ATTR}
multiple
aria-hidden="true"
tabIndex={-1}
className="sr-only"
onChange={(event) => {
const files = [...(event.target.files ?? [])];
if (files.length) void addDocuments(files.filter(isDocumentFile));
event.target.value = "";
}}
/>
</div>
);
}

View file

@ -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<GeneratedPodcast>({
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 */
/* ------------------------------------------------------------------ */

View file

@ -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<BackupFile> {
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".

View file

@ -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<string, unknown>;
} | StreamEventBase;
type StreamEventBase =
| { type: "stage"; stage: "vision" | "generation" | "done"; message: string }
| { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> }
| { 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<string, unknown> }> {
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 */
/* ------------------------------------------------------------------ */

View file

@ -162,7 +162,7 @@ const en: Record<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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í",

247
lib/podcast-speech.ts Normal file
View file

@ -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,
};
}

172
lib/podcasts.ts Normal file
View file

@ -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<Listener>();
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");
}

View file

@ -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 */
/* ------------------------------------------------------------------ */

View file

@ -261,6 +261,60 @@ export type HomeworkMakeRecord = Extract<HomeworkRecord, { mode: "make" }>;
export type HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>;
export type HomeworkCheckRecord = Extract<HomeworkRecord, { mode: "check" }>;
/* ------------------------------------------------------------------ */
/* 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<typeof generatedPodcastSchema>;
/** 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<Podcast> = 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) */
/* ------------------------------------------------------------------ */

View file

@ -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;

View file

@ -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<SyncPayload> {
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 ?? []);

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.7.2",
"version": "0.7.3",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

View file

@ -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`;

View file

@ -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. |

View file

@ -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") {

245
tests/podcast-test.mjs Normal file
View file

@ -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();

View file

@ -24,6 +24,7 @@ const SUITES = [
"phase1-test",
"phase2-test",
"phase3-test",
"podcast-test",
"security-test",
"chat-test",
"providers-test",

View file

@ -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());