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:
parent
84a94581dc
commit
d95c5c7151
30 changed files with 2575 additions and 26 deletions
26
README.md
26
README.md
|
|
@ -1492,6 +1492,32 @@ Things I chose while building, with the reasoning:
|
||||||
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
|
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
234
app/api/podcast/route.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
72
app/podcast/[id]/page.tsx
Normal 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
34
app/podcast/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
179
components/podcast/PodcastList.tsx
Normal file
179
components/podcast/PodcastList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
376
components/podcast/PodcastPlayer.tsx
Normal file
376
components/podcast/PodcastPlayer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
505
components/podcast/PodcastWizard.tsx
Normal file
505
components/podcast/PodcastWizard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
lib/ai.ts
56
lib/ai.ts
|
|
@ -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 */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
|
||||||
|
|
@ -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".
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
|
||||||
116
lib/i18n-dict.ts
116
lib/i18n-dict.ts
|
|
@ -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
247
lib/podcast-speech.ts
Normal 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
172
lib/podcasts.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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 */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 ?? []);
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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
245
tests/podcast-test.mjs
Normal 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();
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue