freezmo/components/podcast/PodcastList.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

179 lines
7.8 KiB
TypeScript

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