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.
179 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|