freezmo/app/podcast/[id]/page.tsx
Martin Dvorak d95c5c7151 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.
2026-10-16 11:42:15 +00:00

72 lines
2.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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