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 into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged. 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 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 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 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; } .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 -------------------- */ /* The AI note under a surface that shows model output -------------------- */
.ai-disclaimer { .ai-disclaimer {
font-size: 12px; font-size: 12px;

View file

@ -19,7 +19,9 @@ export default async function HomePage({
<AppHeader /> <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"> <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> <RouteTransition>
<LibraryScreen initialTab={tab === "homework" ? "homework" : "decks"} /> <LibraryScreen
initialTab={tab === "homework" ? "homework" : tab === "podcasts" ? "podcasts" : "decks"}
/>
</RouteTransition> </RouteTransition>
</div> </div>
</main> </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]); }, [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; if (!show) return null;
const title = track?.title ?? FALLBACK_TITLE; const title = track?.title ?? FALLBACK_TITLE;

View file

@ -95,6 +95,7 @@ export function DataSection() {
homework: pending.preview.homework.added + pending.preview.homework.replaced, homework: pending.preview.homework.added + pending.preview.homework.replaced,
folders: pending.preview.folders.added + pending.preview.folders.replaced, folders: pending.preview.folders.added + pending.preview.folders.replaced,
chats: pending.preview.chats.added + pending.preview.chats.replaced, chats: pending.preview.chats.added + pending.preview.chats.replaced,
podcasts: pending.preview.podcasts.added + pending.preview.podcasts.replaced,
})} })}
</p> </p>
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</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 { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { FolderBar } from "@/components/FolderBar"; import { FolderBar } from "@/components/FolderBar";
import { HomeworkCard } from "@/components/HomeworkCard"; import { HomeworkCard } from "@/components/HomeworkCard";
import { PodcastList } from "@/components/podcast/PodcastList";
import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons"; import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
import { LinkLoadingScreen } from "@/components/LinkLoadingScreen"; import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
import { SegmentedPill } from "@/components/SegmentedPill"; 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, * saved homework (solutions and step-by-step guides). The tab bar picks one,
* so the two never mix in a single list. * 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 reduceMotion = useReducedMotion();
const tab = initialTab; const tab = initialTab;
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot); 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} {isPhone && tab === "homework" ? <SegmentedPill layoutId="library-tabs" /> : null}
<span className="seg-label relative z-10">{t("tabHomework")}</span> <span className="seg-label relative z-10">{t("tabHomework")}</span>
</Link> </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> </div>
<Link <Link
href="/chat" href="/chat"
@ -585,6 +594,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
</ul> </ul>
) : null} ) : null}
{tab === "podcasts" ? <PodcastList /> : null}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>

View file

@ -9,12 +9,13 @@ import { useWizardBusy } from "@/lib/wizard-busy";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** /**
* In-page switch between the two wizards (New deck ⇄ Make my homework). * In-page switch between the three note-powered wizards (New deck ⇄ Make my
* Phones reach both through this control and the centre tab; desktop keeps * homework ⇄ New podcast). Phones reach the first two through this control and
* the rail rows as well. Hidden while either wizard is generating: leaving a * the centre tab; desktop keeps the rail rows as well. Hidden while a wizard
* loading run through it would abandon the request mid-flight. * 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 { t } = useI18n();
const isPhone = useIsPhone(); const isPhone = useIsPhone();
const busy = useWizardBusy(); const busy = useWizardBusy();
@ -39,6 +40,14 @@ export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
{isPhone && mode === "homework" ? <SegmentedPill layoutId="wizard-mode" /> : null} {isPhone && mode === "homework" ? <SegmentedPill layoutId="wizard-mode" /> : null}
<span className="seg-label relative z-10">{t("wizard.mode.homework")}</span> <span className="seg-label relative z-10">{t("wizard.mode.homework")}</span>
</Link> </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> </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_CHECK_SYSTEM_PROMPT,
HOMEWORK_GUIDE_SYSTEM_PROMPT, HOMEWORK_GUIDE_SYSTEM_PROMPT,
HOMEWORK_MAKE_SYSTEM_PROMPT, HOMEWORK_MAKE_SYSTEM_PROMPT,
PODCAST_SYSTEM_PROMPT,
buildPodcastUserPrompt,
REFINE_SYSTEM_PROMPT, REFINE_SYSTEM_PROMPT,
VISION_SYSTEM_PROMPT, VISION_SYSTEM_PROMPT,
VISION_USER_INSTRUCTION, VISION_USER_INSTRUCTION,
@ -27,6 +29,7 @@ import {
import { import {
extractedNotesSchema, extractedNotesSchema,
generatedActivitySchema, generatedActivitySchema,
generatedPodcastSchema,
gradeResultSchema, gradeResultSchema,
homeworkCheckSchema, homeworkCheckSchema,
homeworkMakeSchema, homeworkMakeSchema,
@ -38,6 +41,7 @@ import {
type HomeworkCheck, type HomeworkCheck,
type HomeworkGuide, type HomeworkGuide,
type HomeworkMake, type HomeworkMake,
type GeneratedPodcast,
} from "./schemas"; } from "./schemas";
import { parseJsonLoose } from "./utils"; import { parseJsonLoose } from "./utils";
import type { OllamaAuth } from "./config"; import type { OllamaAuth } from "./config";
@ -401,6 +405,58 @@ export async function generateActivities(input: {
return { deck: value, requested }; 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 */ /* Grading */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -15,9 +15,10 @@ import { applyTheme, type Theme } from "./theme";
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store"; import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
import { replaceFolders, folderStore } from "./folders"; import { replaceFolders, folderStore } from "./folders";
import { homeworkStore, replaceHomework } from "./homework"; import { homeworkStore, replaceHomework } from "./homework";
import { podcastStore, replacePodcasts } from "./podcasts";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { setLocale, t } from "./i18n"; 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 { saveSettings, settingsStore } from "./settings";
import { deckStore, replaceDecks } from "./storage"; import { deckStore, replaceDecks } from "./storage";
@ -34,6 +35,8 @@ export const backupDataSchema = z.object({
decks: z.array(deckSchema).max(2000), decks: z.array(deckSchema).max(2000),
folders: z.array(folderSchema).max(500), folders: z.array(folderSchema).max(500),
homework: z.array(homeworkRecordSchema).max(2000), 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. */ /** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
history: z.array(answerRecordSchema).max(20_000).optional(), history: z.array(answerRecordSchema).max(20_000).optional(),
srs: z.array(srsRecordSchema).max(50_000).optional(), srs: z.array(srsRecordSchema).max(50_000).optional(),
@ -67,6 +70,7 @@ export async function buildBackup(): Promise<BackupFile> {
decks: deckStore.getSnapshot(), decks: deckStore.getSnapshot(),
folders: folderStore.getSnapshot(), folders: folderStore.getSnapshot(),
homework: homeworkStore.getSnapshot(), homework: homeworkStore.getSnapshot(),
podcasts: podcastStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(), history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(), srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(), chats: chatHistoryStore.getSnapshot(),
@ -143,6 +147,7 @@ export type ImportPreview = {
decks: ImportCounts; decks: ImportCounts;
folders: ImportCounts; folders: ImportCounts;
homework: ImportCounts; homework: ImportCounts;
podcasts: ImportCounts;
chats: ImportCounts; chats: ImportCounts;
}; };
@ -151,6 +156,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
decks: countMerge(deckStore.getSnapshot(), backup.data.decks), decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
folders: countMerge(folderStore.getSnapshot(), backup.data.folders), folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework), homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
podcasts: countMerge(podcastStore.getSnapshot(), backup.data.podcasts ?? []),
chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []), chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []),
}; };
} }
@ -167,12 +173,15 @@ export async function applyImport(
mode === "replace" ? data.folders : mergeById(folderStore.getSnapshot(), data.folders); mode === "replace" ? data.folders : mergeById(folderStore.getSnapshot(), data.folders);
const homework = const homework =
mode === "replace" ? data.homework : mergeById(homeworkStore.getSnapshot(), data.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 // 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()). // local change, so it also marks sync dirty (via the stores' write()).
replaceFolders(folders); replaceFolders(folders);
replaceDecks(decks); replaceDecks(decks);
replaceHomework(homework); replaceHomework(homework);
replacePodcasts(podcasts);
// Answer log + schedule: merge by id (answers) and by key (schedules), or // Answer log + schedule: merge by id (answers) and by key (schedules), or
// replace wholesale when mode === "replace". // 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. */ * routes heartbeat every 15s, so 90s of silence really is silence. */
const STREAM_GUARD_MS = 90_000; 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: "stage"; stage: "vision" | "generation" | "done"; message: string }
| { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> } | { type: "deck"; deck: StreamedDeck; meta: Record<string, unknown> }
| { type: "error"; error: string; kind?: string } | { type: "error"; error: string; kind?: string }
@ -200,6 +206,91 @@ export async function generateDeck(
return { deck: { ...(deck as StreamedDeck), activities: valid }, meta }; 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 */ /* Make my homework */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */

View file

@ -162,7 +162,7 @@ const en: Record<string, string> = {
"settings.exportAll": "Export all data", "settings.exportAll": "Export all data",
"settings.exportNote": "Decks, homework, progress and settings — never your API key.", "settings.exportNote": "Decks, homework, progress and settings — never your API key.",
"settings.importBackup": "Import backup", "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.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.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
"settings.importMerge": "Merge", "settings.importMerge": "Merge",
@ -274,7 +274,61 @@ const en: Record<string, string> = {
"homework.nav": "New homework", "homework.nav": "New homework",
"wizard.mode.deck": "Study deck", "wizard.mode.deck": "Study deck",
"wizard.mode.homework": "Make my homework", "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", "homeworkHeroTitle": "Make my homework",
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.", "homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
"homework.phone.assignment": "The assignment", "homework.phone.assignment": "The assignment",
@ -1175,7 +1229,7 @@ const cs: Record<string, string> = {
"settings.exportAll": "Exportovat všechna data", "settings.exportAll": "Exportovat všechna data",
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.", "settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
"settings.importBackup": "Importovat zálohu", "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.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.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
"settings.importMerge": "Sloučit", "settings.importMerge": "Sloučit",
@ -1287,7 +1341,61 @@ const cs: Record<string, string> = {
"homework.nav": "Nový úkol", "homework.nav": "Nový úkol",
"wizard.mode.deck": "Studijní sada", "wizard.mode.deck": "Studijní sada",
"wizard.mode.homework": "Udělej mi úkol", "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", "homeworkHeroTitle": "Udělej mi úkol",
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.", "homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
"homework.phone.assignment": "Zadání", "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"); 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 */ /* 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 HomeworkGuideRecord = Extract<HomeworkRecord, { mode: "guide" }>;
export type HomeworkCheckRecord = Extract<HomeworkRecord, { mode: "check" }>; 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) */ /* 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"; return fallback === "cs" ? "cs-CZ" : "en-US";
} }
/** The installed voice closest to `locale` (exact match, then language). */ /** The installed voice closest to `locale` (exact match, then language).
function pickVoice(locale: string): SpeechSynthesisVoice | null { * Shared with the podcast player (lib/podcast-speech). */
export function pickVoice(locale: string): SpeechSynthesisVoice | null {
try { try {
const voices = window.speechSynthesis.getVoices(); const voices = window.speechSynthesis.getVoices();
if (voices.length === 0) return null; 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 { ApiError } from "./client-api";
import { replaceFolders } from "./folders"; import { replaceFolders } from "./folders";
import { replaceHomework } from "./homework"; import { replaceHomework } from "./homework";
import { podcastStore, replacePodcasts } from "./podcasts";
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history"; import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
import { localeHeader, t } from "./i18n"; import { localeHeader, t } from "./i18n";
import { authHeaders } from "./settings"; import { authHeaders } from "./settings";
@ -173,6 +174,7 @@ async function buildPayload(): Promise<SyncPayload> {
decks: deckStore.getSnapshot(), decks: deckStore.getSnapshot(),
folders: folderStore.getSnapshot(), folders: folderStore.getSnapshot(),
homework: homeworkStore.getSnapshot(), homework: homeworkStore.getSnapshot(),
podcasts: podcastStore.getSnapshot(),
history: learningStore.getAnswersSnapshot(), history: learningStore.getAnswersSnapshot(),
srs: learningStore.getSrsSnapshot(), srs: learningStore.getSrsSnapshot(),
chats: chatHistoryStore.getSnapshot(), chats: chatHistoryStore.getSnapshot(),
@ -188,6 +190,7 @@ async function applyRemote(key: CryptoKey, remote: { updatedAt: number; envelope
replaceFolders(parsed.data.folders); replaceFolders(parsed.data.folders);
replaceDecks(parsed.data.decks); replaceDecks(parsed.data.decks);
replaceHomework(parsed.data.homework); replaceHomework(parsed.data.homework);
replacePodcasts(parsed.data.podcasts ?? []);
replaceChats(parsed.data.chats ?? []); replaceChats(parsed.data.chats ?? []);
}); });
await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []); await replaceLearning(parsed.data.history ?? [], parsed.data.srs ?? []);

View file

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

View file

@ -9,7 +9,7 @@
* *
* Bumping CACHE_VERSION is how a new deploy invalidates old assets. * 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 SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; 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. | | `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. | | `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. | | `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. | | `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). | | `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. | | `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], ["chat", "/chat", null],
["stats", "/stats", null], ["stats", "/stats", null],
["exam", "/exam", null], ["exam", "/exam", null],
["podcast", "/podcast/pod_contrast", null],
["settings", "/", "settings"], ["settings", "/", "settings"],
["notfound", "/no-such-page", null], ["notfound", "/no-such-page", null],
]; ];
@ -51,7 +52,27 @@ for (const [label, path, mode] of screens) {
if (filter && !label.includes(filter)) continue; if (filter && !label.includes(filter)) continue;
for (const theme of ["dark", "light", "aero"]) { for (const theme of ["dark", "light", "aero"]) {
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1 }); 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.goto(`${BASE}${path}`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
if (mode === "settings") { 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", "phase1-test",
"phase2-test", "phase2-test",
"phase3-test", "phase3-test",
"podcast-test",
"security-test", "security-test",
"chat-test", "chat-test",
"providers-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.goto(`${BASE}/create`, { waitUntil: "networkidle" });
await page.waitForTimeout(800); 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 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"); 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(); 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 lands on the homework wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());
check( check(
"mode switch marks the homework side active", "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); await page.waitForTimeout(900);
check("mode switch returns to the deck wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible()); check("mode switch returns to the deck wizard", await page.getByRole("heading", { name: "Your notes" }).isVisible());
await context.close(); await context.close();
@ -125,7 +125,7 @@ for (const viewport of [{ width: 390, height: 844 }, { width: 360, height: 740 }
const switcherTop = async () => { const switcherTop = async () => {
await page.waitForTimeout(700); await page.waitForTimeout(700);
return page.evaluate(() => { 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; 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.waitForTimeout(300);
await page.getByRole("button", { name: "Next" }).click(); await page.getByRole("button", { name: "Next" }).click();
await page.waitForTimeout(300); 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.getByRole("button", { name: "Freeze my deck" }).click();
await page.waitForTimeout(800); await page.waitForTimeout(800);
check("loading: deck run reaches the loading screen", await page.getByRole("button", { name: "Cancel" }).isVisible()); 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.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(900); await page.waitForTimeout(900);
check( check(
"loading: deck Cancel returns to the wizard (notice + retry)", "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()), (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.goto(`${BASE}/homework`, { waitUntil: "networkidle" });
await page.waitForTimeout(700); 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()); 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.getByRole("button", { name: /Just make it/ }).click();
await page.waitForTimeout(800); 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.getByRole("button", { name: "Cancel" }).click();
await page.waitForTimeout(900); await page.waitForTimeout(900);
check("loading: homework Cancel returns to the wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible()); check("loading: homework Cancel returns to the wizard", await page.getByRole("heading", { name: "The assignment" }).isVisible());