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.
53 lines
1.9 KiB
TypeScript
53 lines
1.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
|
|
import { SegmentedPill } from "@/components/SegmentedPill";
|
|
import { useI18n } from "@/lib/i18n";
|
|
import { useIsPhone } from "@/lib/use-phone";
|
|
import { useWizardBusy } from "@/lib/wizard-busy";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* In-page switch between the three note-powered wizards (New deck ⇄ Make my
|
|
* homework ⇄ New podcast). Phones reach the first two through this control and
|
|
* the centre tab; desktop keeps the rail rows as well. Hidden while a wizard
|
|
* is generating: leaving a loading run through it would abandon the request
|
|
* mid-flight.
|
|
*/
|
|
export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" | "podcast" }) {
|
|
const { t } = useI18n();
|
|
const isPhone = useIsPhone();
|
|
const busy = useWizardBusy();
|
|
|
|
if (busy) return null;
|
|
|
|
return (
|
|
<div className="segmented mb-5" role="group" aria-label={t("wizard.mode.aria")}>
|
|
<Link
|
|
href="/create"
|
|
aria-current={mode === "deck" ? "page" : undefined}
|
|
className={cn("relative", isPhone && "seg-slide")}
|
|
>
|
|
{isPhone && mode === "deck" ? <SegmentedPill layoutId="wizard-mode" /> : null}
|
|
<span className="seg-label relative z-10">{t("wizard.mode.deck")}</span>
|
|
</Link>
|
|
<Link
|
|
href="/homework"
|
|
aria-current={mode === "homework" ? "page" : undefined}
|
|
className={cn("relative", isPhone && "seg-slide")}
|
|
>
|
|
{isPhone && mode === "homework" ? <SegmentedPill layoutId="wizard-mode" /> : null}
|
|
<span className="seg-label relative z-10">{t("wizard.mode.homework")}</span>
|
|
</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>
|
|
);
|
|
}
|