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.
72 lines
2.3 KiB
TypeScript
72 lines
2.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|