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.
This commit is contained in:
parent
84a94581dc
commit
d95c5c7151
30 changed files with 2575 additions and 26 deletions
|
|
@ -8,6 +8,7 @@ import { DeckCard } from "@/components/DeckCard";
|
|||
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
||||
import { FolderBar } from "@/components/FolderBar";
|
||||
import { HomeworkCard } from "@/components/HomeworkCard";
|
||||
import { PodcastList } from "@/components/podcast/PodcastList";
|
||||
import { ChatIcon, EllipsisIcon, FolderIcon, UploadIcon } from "@/components/icons";
|
||||
import { LinkLoadingScreen } from "@/components/LinkLoadingScreen";
|
||||
import { SegmentedPill } from "@/components/SegmentedPill";
|
||||
|
|
@ -33,7 +34,7 @@ import { cn } from "@/lib/utils";
|
|||
* saved homework (solutions and step-by-step guides). The tab bar picks one,
|
||||
* so the two never mix in a single list.
|
||||
*/
|
||||
export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" }) {
|
||||
export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" | "homework" | "podcasts" }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const tab = initialTab;
|
||||
const decks = useSyncExternalStore(deckStore.subscribe, deckStore.getSnapshot, deckStore.getServerSnapshot);
|
||||
|
|
@ -269,6 +270,14 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
{isPhone && tab === "homework" ? <SegmentedPill layoutId="library-tabs" /> : null}
|
||||
<span className="seg-label relative z-10">{t("tabHomework")}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/?tab=podcasts"
|
||||
aria-current={tab === "podcasts" ? "page" : undefined}
|
||||
className={cn("relative", isPhone && "seg-slide")}
|
||||
>
|
||||
{isPhone && tab === "podcasts" ? <SegmentedPill layoutId="library-tabs" /> : null}
|
||||
<span className="seg-label relative z-10">{t("tabPodcasts")}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/chat"
|
||||
|
|
@ -585,6 +594,8 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
</ul>
|
||||
) : null}
|
||||
|
||||
{tab === "podcasts" ? <PodcastList /> : null}
|
||||
|
||||
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue