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
|
|
@ -187,6 +187,17 @@ export function AeroMusic() {
|
|||
};
|
||||
}, [show, autoStart, playing, startFromGesture]);
|
||||
|
||||
/* Something with a voice started (the podcast player): the soundtrack
|
||||
steps aside rather than talking over it. */
|
||||
useEffect(() => {
|
||||
const onForeground = () => {
|
||||
widget.current?.pause();
|
||||
setAutoStart(false);
|
||||
};
|
||||
window.addEventListener("freezmo:foreground-audio", onForeground);
|
||||
return () => window.removeEventListener("freezmo:foreground-audio", onForeground);
|
||||
}, []);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const title = track?.title ?? FALLBACK_TITLE;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ export function DataSection() {
|
|||
homework: pending.preview.homework.added + pending.preview.homework.replaced,
|
||||
folders: pending.preview.folders.added + pending.preview.folders.replaced,
|
||||
chats: pending.preview.chats.added + pending.preview.chats.replaced,
|
||||
podcasts: pending.preview.podcasts.added + pending.preview.podcasts.replaced,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-subtle)]">{t("settings.importMergeNote")}</p>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import { useWizardBusy } from "@/lib/wizard-busy";
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* In-page switch between the two wizards (New deck ⇄ Make my homework).
|
||||
* Phones reach both through this control and the centre tab; desktop keeps
|
||||
* the rail rows as well. Hidden while either wizard is generating: leaving a
|
||||
* loading run through it would abandon the request mid-flight.
|
||||
* 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" }) {
|
||||
export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" | "podcast" }) {
|
||||
const { t } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const busy = useWizardBusy();
|
||||
|
|
@ -39,6 +40,14 @@ export function WizardModeSwitch({ mode }: { mode: "deck" | "homework" }) {
|
|||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
179
components/podcast/PodcastList.tsx
Normal file
179
components/podcast/PodcastList.tsx
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
376
components/podcast/PodcastPlayer.tsx
Normal file
376
components/podcast/PodcastPlayer.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { createPodcastSpeech, tokenizeSections, type PodcastVoice } from "@/lib/podcast-speech";
|
||||
import { savePodcastProgress } from "@/lib/podcasts";
|
||||
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
|
||||
import type { Podcast } from "@/lib/schemas";
|
||||
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
||||
import { ChevronLeftIcon, ClockIcon, PauseIcon, PlayIcon, RefreshIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Playback speeds, cycled by one button. */
|
||||
const RATES = [1, 1.15, 1.3, 0.9] as const;
|
||||
|
||||
/**
|
||||
* Reads an episode aloud and highlights the word being spoken — the transcript
|
||||
* is the player, Spotify-lyrics style: past words stay dark, upcoming words
|
||||
* are dimmed, the word under the voice rides a rounded highlight that keeps
|
||||
* itself centred as the episode plays.
|
||||
*
|
||||
* The heavy lifting (boundary events, the timer fallback, seeking by
|
||||
* re-speaking) lives in lib/podcast-speech; this component owns the layout,
|
||||
* the controls and the saved position.
|
||||
*/
|
||||
export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||
const { t, plural, locale } = useI18n();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [rate, setRate] = useState<number>(1);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [position, setPosition] = useState<PodcastVoice | null>(null);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
const transcript = useRef<HTMLDivElement>(null);
|
||||
const activeWord = useRef<HTMLElement | null>(null);
|
||||
/** Latest position for the unmount/pause save, without re-creating callbacks. */
|
||||
const latest = useRef<PodcastVoice | null>(episode.progress ?? null);
|
||||
|
||||
const tokens = useMemo(() => tokenizeSections(episode.sections), [episode.sections]);
|
||||
const supported = useMemo(() => speechAvailable(), []);
|
||||
|
||||
const speech = useMemo(() => {
|
||||
if (!supported) return null;
|
||||
return createPodcastSpeech({
|
||||
sections: episode.sections,
|
||||
locale: speechLocale(episode.language, locale),
|
||||
rate,
|
||||
onWord: (next) => {
|
||||
latest.current = next;
|
||||
setPosition(next);
|
||||
},
|
||||
onPlay: () => {
|
||||
setPlaying(true);
|
||||
/* The Aero soundtrack steps aside for the voice (see AeroMusic). */
|
||||
window.dispatchEvent(new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }));
|
||||
},
|
||||
onPause: () => setPlaying(false),
|
||||
onEnd: () => {
|
||||
setPlaying(false);
|
||||
setPosition(null);
|
||||
latest.current = null;
|
||||
savePodcastProgress(episode.id, { section: 0, word: 0 });
|
||||
},
|
||||
onError: (message) => {
|
||||
if (message === "unsupported") setUnsupported(true);
|
||||
setPlaying(false);
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- rebuilt only when the episode, the speed or the locale changes
|
||||
}, [episode.id, episode.sections, rate, supported, locale]);
|
||||
|
||||
/* Resume where the listener left off (a reopened episode starts there). */
|
||||
useEffect(() => {
|
||||
if (episode.progress) {
|
||||
latest.current = { section: episode.progress.section, word: episode.progress.word };
|
||||
setPosition({ section: episode.progress.section, word: episode.progress.word });
|
||||
}
|
||||
}, [episode.id, episode.progress]);
|
||||
|
||||
const persist = useCallback(() => {
|
||||
if (latest.current) savePodcastProgress(episode.id, latest.current);
|
||||
}, [episode.id]);
|
||||
|
||||
/* The engine instance is rebuilt on a speed change; keep a ref so the
|
||||
unmount cleanup always stops whichever one is current. */
|
||||
const speechRef = useRef(speech);
|
||||
const playingRef = useRef(false);
|
||||
useEffect(() => {
|
||||
speechRef.current = speech;
|
||||
}, [speech]);
|
||||
useEffect(() => {
|
||||
playingRef.current = playing;
|
||||
}, [playing]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
persist();
|
||||
speechRef.current?.stop();
|
||||
};
|
||||
}, [persist]);
|
||||
|
||||
/* Closing the tab, switching apps or hard-navigating away: the unmount
|
||||
cleanup never runs there, so the position also rides the page lifecycle. */
|
||||
useEffect(() => {
|
||||
const save = () => persist();
|
||||
const onHide = () => {
|
||||
if (document.visibilityState === "hidden") save();
|
||||
};
|
||||
window.addEventListener("pagehide", save);
|
||||
document.addEventListener("visibilitychange", onHide);
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", save);
|
||||
document.removeEventListener("visibilitychange", onHide);
|
||||
};
|
||||
}, [persist]);
|
||||
|
||||
/* A rebuilt engine (speed change) resumes the same word when it was
|
||||
playing — the listener should not notice the swap. */
|
||||
const firstEngine = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstEngine.current) {
|
||||
firstEngine.current = false;
|
||||
return;
|
||||
}
|
||||
if (!playingRef.current || !speech) return;
|
||||
speech.play(latest.current ?? { section: 0, word: 0 });
|
||||
}, [speech]);
|
||||
|
||||
/* The highlight: one class on the active word (imperative, so a few hundred
|
||||
spans do not re-render per word), then keep it in the comfortable band. */
|
||||
useEffect(() => {
|
||||
const container = transcript.current;
|
||||
if (!container) return;
|
||||
activeWord.current?.removeAttribute("data-word-active");
|
||||
activeWord.current?.classList.remove("podcast-word-active");
|
||||
if (!position) {
|
||||
activeWord.current = null;
|
||||
return;
|
||||
}
|
||||
const index = tokens.findIndex((token) => token.section === position.section && token.index === position.word);
|
||||
if (index === -1) return;
|
||||
const element = container.querySelector<HTMLElement>(`[data-word="${index}"]`);
|
||||
if (!element) return;
|
||||
element.setAttribute("data-word-active", "1");
|
||||
element.classList.add("podcast-word-active");
|
||||
activeWord.current = element;
|
||||
|
||||
const box = element.getBoundingClientRect();
|
||||
const view = container.getBoundingClientRect();
|
||||
const top = box.top - view.top;
|
||||
const comfortable = view.height * 0.38;
|
||||
if (top < view.height * 0.18 || top > view.height * 0.62) {
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + top - comfortable,
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
});
|
||||
}
|
||||
}, [position, tokens, reduceMotion]);
|
||||
|
||||
const sections = episode.sections;
|
||||
const words = tokens.length;
|
||||
const done = useMemo(() => {
|
||||
if (!position) return 0;
|
||||
return tokens.findIndex((token) => token.section === position.section && token.index === position.word);
|
||||
}, [position, tokens]);
|
||||
|
||||
const toggle = () => {
|
||||
if (!speech) return;
|
||||
if (playing) {
|
||||
speech.pause();
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
/* Other readers (a card's read-aloud) yield to the episode. */
|
||||
stopSpeaking();
|
||||
speech.play(position ?? { section: 0, word: 0 });
|
||||
};
|
||||
|
||||
const jump = (section: number) => {
|
||||
if (!speech) return;
|
||||
const next = { section, word: 0 };
|
||||
latest.current = next;
|
||||
setPosition(next);
|
||||
stopSpeaking();
|
||||
speech.play(next);
|
||||
};
|
||||
|
||||
const jumpWord = (index: number) => {
|
||||
if (!speech) return;
|
||||
const token = tokens[index];
|
||||
if (!token) return;
|
||||
const next = { section: token.section, word: token.index };
|
||||
latest.current = next;
|
||||
setPosition(next);
|
||||
stopSpeaking();
|
||||
speech.play(next);
|
||||
};
|
||||
|
||||
/* Word spans, built once per section: the highlight is a class flip. */
|
||||
const rendered = useMemo(() => {
|
||||
return sections.map((section, sectionIndex) => {
|
||||
const sectionTokens = tokens.filter((token) => token.section === sectionIndex);
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
sectionTokens.forEach((token) => {
|
||||
if (token.start > cursor) nodes.push(section.text.slice(cursor, token.start));
|
||||
const globalIndex = token.global;
|
||||
nodes.push(
|
||||
<span key={`${token.start}`} data-word={globalIndex} className="podcast-word">
|
||||
{section.text.slice(token.start, token.end)}
|
||||
</span>,
|
||||
);
|
||||
cursor = token.end;
|
||||
});
|
||||
if (cursor < section.text.length) nodes.push(section.text.slice(cursor));
|
||||
return { section, sectionIndex, nodes };
|
||||
});
|
||||
}, [sections, tokens]);
|
||||
|
||||
const currentSection = position?.section ?? 0;
|
||||
|
||||
return (
|
||||
<div className="frost-bg fixed inset-0 z-[45] flex flex-col fade-in-mobile">
|
||||
<header className="pt-safe absolute inset-x-0 top-0 z-30 px-3">
|
||||
<div className="study-chrome mx-auto flex max-w-3xl items-center gap-2.5 rounded-full px-2.5 py-2">
|
||||
<Link
|
||||
href="/?tab=podcasts"
|
||||
aria-label={t("feed.back")}
|
||||
title={t("feed.back")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-semibold leading-tight text-[var(--text)]">{episode.title}</p>
|
||||
<p className="truncate text-[11.5px] text-[var(--text-muted)]">
|
||||
{t("podcast.sectionOf", { n: Math.min(currentSection + 1, sections.length), total: sections.length })}
|
||||
{" · "}
|
||||
{sections[currentSection]?.heading ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const index = RATES.indexOf(rate as (typeof RATES)[number]);
|
||||
setRate(RATES[(index + 1) % RATES.length]);
|
||||
}}
|
||||
aria-label={t("podcast.speedAria")}
|
||||
title={t("podcast.speedAria")}
|
||||
className="shrink-0 rounded-full bg-[var(--surface-sunken)] px-3 py-2 text-[12px] font-extrabold tabular-nums text-[var(--text)] transition hover:bg-[var(--hover)]"
|
||||
>
|
||||
{rate}×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
ref={transcript}
|
||||
data-testid="podcast-transcript"
|
||||
onClick={(event) => {
|
||||
/* Word taps seek: the transcript is the scrubber, like lyrics. */
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.classList.contains("podcast-word")) return;
|
||||
const index = Number(target.dataset.word);
|
||||
if (Number.isInteger(index)) jumpWord(index);
|
||||
}}
|
||||
className="no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-40 pt-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
{!supported || unsupported ? (
|
||||
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
{t("podcast.noVoice")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="card space-y-2 p-5">
|
||||
<h1 className="text-[22px] font-extrabold leading-tight tracking-[-0.01em] text-[var(--text)]">
|
||||
{episode.title}
|
||||
</h1>
|
||||
{episode.summary ? (
|
||||
<p className="text-[13.5px] leading-relaxed text-[var(--text-muted)]">{episode.summary}</p>
|
||||
) : null}
|
||||
<p className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-subtle)]">
|
||||
<ClockIcon className="size-3.5" />
|
||||
{plural(episode.sections.length, "podcast.sectionCount")} · {plural(words, "podcast.wordCount")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* The script reads on a glass pane: in Aero the wallpaper behind
|
||||
plain paragraphs is bright grass and sun (the contrast scan caught
|
||||
it), and the same treatment keeps every theme consistent. */}
|
||||
<div className="card space-y-6 p-5" data-testid="podcast-script">
|
||||
{rendered.map(({ section, sectionIndex, nodes }) => (
|
||||
<section key={sectionIndex} className="space-y-2">
|
||||
<h2 className="flex items-center gap-2 text-[12px] font-bold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
<span className="grid size-6 place-items-center rounded-full bg-[var(--surface-sunken)] text-[11px] tabular-nums">
|
||||
{sectionIndex + 1}
|
||||
</span>
|
||||
{section.heading}
|
||||
</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[17px] leading-[1.85] text-[var(--text-muted)] transition-colors sm:text-[18px]",
|
||||
sectionIndex === currentSection && "text-[var(--text)]",
|
||||
)}
|
||||
>
|
||||
{nodes}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="pb-6 text-center text-[11.5px] text-[var(--text-subtle)]">{t("podcast.tapToJump")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls: chapter list on the left, transport in the middle. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 pb-safe">
|
||||
<div className="pointer-events-auto mx-auto max-w-3xl px-3 pb-3">
|
||||
<div className="study-chrome rounded-[1.4rem] px-3 py-2.5">
|
||||
<div className="mb-2 h-1 overflow-hidden rounded-full bg-[var(--track)]" aria-hidden>
|
||||
<div
|
||||
className="h-full rounded-full grad-ice transition-[width] duration-200"
|
||||
style={{ width: words ? `${Math.round((done / words) * 100)}%` : "0%" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={!supported}
|
||||
aria-label={playing ? t("podcast.pause") : t("podcast.play")}
|
||||
title={playing ? t("podcast.pause") : t("podcast.play")}
|
||||
className="grid size-12 shrink-0 place-items-center rounded-full grad-ice text-[#06111f] transition disabled:opacity-45"
|
||||
>
|
||||
{playing ? <PauseIcon className="size-5" /> : <PlayIcon className="size-5" />}
|
||||
</button>
|
||||
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||
{sections.map((section, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => jump(index)}
|
||||
aria-current={index === currentSection ? "true" : undefined}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-3 py-2 text-[12px] font-semibold transition",
|
||||
index === currentSection
|
||||
? "bg-[var(--nav-active-bg)] text-[var(--text)] ring-1 ring-[var(--nav-active-border)]"
|
||||
: "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
|
||||
)}
|
||||
>
|
||||
{section.heading}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
speech?.stop();
|
||||
setPosition({ section: 0, word: 0 });
|
||||
latest.current = { section: 0, word: 0 };
|
||||
savePodcastProgress(episode.id, { section: 0, word: 0 });
|
||||
}}
|
||||
aria-label={t("podcast.restart")}
|
||||
title={t("podcast.restart")}
|
||||
className="grid size-10 shrink-0 place-items-center rounded-full bg-[var(--surface-sunken)] text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)]"
|
||||
>
|
||||
<RefreshIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
505
components/podcast/PodcastWizard.tsx
Normal file
505
components/podcast/PodcastWizard.tsx
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { AiDisclaimer } from "@/components/AiDisclaimer";
|
||||
import { ApiError, generatePodcast, type GenerateProgress } from "@/lib/client-api";
|
||||
import {
|
||||
DOCUMENT_ACCEPT_ATTR,
|
||||
KIND_LABELS,
|
||||
describeDocumentError,
|
||||
documentKindOf,
|
||||
extractDocument,
|
||||
isDocumentFile,
|
||||
type DocumentKind,
|
||||
type ExtractedDocument,
|
||||
} from "@/lib/documents";
|
||||
import {
|
||||
ACCEPTED_TYPES,
|
||||
MAX_IMAGES,
|
||||
describeFileError,
|
||||
imageFilesFromDataTransfer,
|
||||
isImageFile,
|
||||
prepareImage,
|
||||
type PreparedImage,
|
||||
} from "@/lib/image";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { ModelErrorActions } from "@/components/ModelErrorActions";
|
||||
import { takePendingShare } from "@/lib/share-pending";
|
||||
import { upsertPodcast } from "@/lib/podcasts";
|
||||
import { useIsPhone } from "@/lib/use-phone";
|
||||
import { useOnline } from "@/lib/use-online";
|
||||
import { uid } from "@/lib/utils";
|
||||
import { wizardBusy } from "@/lib/wizard-busy";
|
||||
import { CameraIcon, ClockIcon, MusicIcon, PlusIcon, SparklesIcon, XIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui";
|
||||
import { LoadingState, type LoadingStage } from "@/components/LoadingState";
|
||||
import { WizardActionBar } from "@/components/WizardActionBar";
|
||||
|
||||
type Phase = "idle" | "loading" | "error";
|
||||
|
||||
type DocItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DocumentKind;
|
||||
status: "extracting" | "ready" | "error";
|
||||
progress?: string;
|
||||
extracted?: ExtractedDocument;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const MAX_DOCUMENTS = 6;
|
||||
const MAX_IMAGES_TO_SEND = 12;
|
||||
const MAX_TEXT_TO_SEND = 100_000;
|
||||
|
||||
/** Episode lengths, in minutes; the prompt turns these into a word target. */
|
||||
const LENGTH_OPTIONS = [2, 4, 7] as const;
|
||||
|
||||
const PODCAST_STAGES: LoadingStage[] = [
|
||||
{ id: "vision", labelKey: "podcast.loading.read1", messageKeys: ["podcast.loading.read1", "podcast.loading.read2"] },
|
||||
{ id: "generation", labelKey: "podcast.loading.write1", messageKeys: ["podcast.loading.write1", "podcast.loading.write2", "podcast.loading.write3"] },
|
||||
];
|
||||
|
||||
const LOADING_TIPS = ["podcast.tip1", "podcast.tip2"];
|
||||
|
||||
/**
|
||||
* Podcast mode: the same notes intake as the deck wizard, but the output is a
|
||||
* spoken episode. One screen — the choices are only "how long" and an optional
|
||||
* topic hint — so it does not need the deck wizard's three steps.
|
||||
*/
|
||||
export function PodcastWizard() {
|
||||
const router = useRouter();
|
||||
const { t, plural } = useI18n();
|
||||
const isPhone = useIsPhone();
|
||||
const online = useOnline();
|
||||
|
||||
const [images, setImages] = useState<PreparedImage[]>([]);
|
||||
const [documents, setDocuments] = useState<DocItem[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [focus, setFocus] = useState("");
|
||||
const [minutes, setMinutes] = useState<number>(4);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [error, setError] = useState<{ message: string; kind?: string } | null>(null);
|
||||
const [fileError, setFileError] = useState<string | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [progress, setProgress] = useState<GenerateProgress>({ stage: "generation", message: "" });
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const docInput = useRef<HTMLInputElement>(null);
|
||||
const cameraInput = useRef<HTMLInputElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const documentText = useMemo(
|
||||
() =>
|
||||
documents
|
||||
.filter((entry) => entry.status === "ready" && entry.extracted)
|
||||
.map((entry) => entry.extracted?.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
[documents],
|
||||
);
|
||||
|
||||
const imagesToSend = useMemo(
|
||||
() =>
|
||||
[
|
||||
...images.map((image) => image.dataUrl),
|
||||
...documents.flatMap((entry) => (entry.extracted?.images ?? []).map((image) => image.dataUrl)),
|
||||
].slice(0, MAX_IMAGES_TO_SEND),
|
||||
[images, documents],
|
||||
);
|
||||
|
||||
const mergedText = [text.trim(), documentText.trim()].filter(Boolean).join("\n\n").slice(0, MAX_TEXT_TO_SEND);
|
||||
const canGenerate = online && phase !== "loading" && (imagesToSend.length > 0 || mergedText.length > 0);
|
||||
const extracting = documents.some((entry) => entry.status === "extracting");
|
||||
|
||||
useEffect(() => {
|
||||
wizardBusy.set(phase === "loading");
|
||||
return () => wizardBusy.set(false);
|
||||
}, [phase]);
|
||||
|
||||
const addImages = useCallback(
|
||||
async (files: File[]) => {
|
||||
setFileError(null);
|
||||
const room = MAX_IMAGES - images.length;
|
||||
if (room <= 0) {
|
||||
setFileError(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
|
||||
return;
|
||||
}
|
||||
const accepted = files.slice(0, room);
|
||||
for (const file of accepted) {
|
||||
const failure = describeFileError(file);
|
||||
if (failure) {
|
||||
setFileError(failure);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const prepared = await prepareImage(file);
|
||||
setImages((current) => (current.length >= MAX_IMAGES ? current : [...current, prepared]));
|
||||
} catch (error) {
|
||||
setFileError(error instanceof Error ? error.message : t("upload.couldNotReadFile"));
|
||||
}
|
||||
}
|
||||
if (files.length > room) setFileError(t("upload.tooManyPhotos", { n: MAX_IMAGES }));
|
||||
},
|
||||
[images.length, t],
|
||||
);
|
||||
|
||||
const addDocuments = useCallback(
|
||||
async (files: File[]) => {
|
||||
setFileError(null);
|
||||
for (const file of files) {
|
||||
const kind = documentKindOf(file);
|
||||
if (!kind) {
|
||||
setFileError(describeDocumentError(file));
|
||||
continue;
|
||||
}
|
||||
if (documents.length >= MAX_DOCUMENTS) {
|
||||
setFileError(t("upload.tooManyDocs", { n: MAX_DOCUMENTS }));
|
||||
break;
|
||||
}
|
||||
const id = uid("doc");
|
||||
setDocuments((current) => [
|
||||
...current,
|
||||
{ id, name: file.name, kind, status: "extracting", progress: t("upload.readingDocs") },
|
||||
]);
|
||||
try {
|
||||
const extracted = await extractDocument(file, (update) =>
|
||||
setDocuments((current) =>
|
||||
current.map((entry) => (entry.id === id ? { ...entry, progress: update.message } : entry)),
|
||||
),
|
||||
);
|
||||
setDocuments((current) =>
|
||||
current.map((entry) => (entry.id === id ? { ...entry, status: "ready", extracted } : entry)),
|
||||
);
|
||||
} catch (error) {
|
||||
setDocuments((current) =>
|
||||
current.map((entry) =>
|
||||
entry.id === id
|
||||
? {
|
||||
...entry,
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : t("upload.couldNotReadDoc"),
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[documents.length, t],
|
||||
);
|
||||
|
||||
/* A share into the app (web share target) lands here as pending notes. */
|
||||
useEffect(() => {
|
||||
const pending = takePendingShare();
|
||||
if (!pending) return;
|
||||
if (pending.text) setText((current) => (current ? `${current}\n\n${pending.text}` : pending.text));
|
||||
if (pending.files.length > 0) void addImages(pending.files.filter(isImageFile));
|
||||
}, [addImages]);
|
||||
|
||||
const generate = async () => {
|
||||
if (!canGenerate) return;
|
||||
setError(null);
|
||||
setPhase("loading");
|
||||
setProgress({
|
||||
stage: imagesToSend.length ? "vision" : "generation",
|
||||
message: imagesToSend.length ? t("podcast.loading.read1") : t("podcast.loading.write1"),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const { podcast } = await generatePodcast(
|
||||
{
|
||||
images: imagesToSend.length ? imagesToSend : undefined,
|
||||
text: mergedText || undefined,
|
||||
minutes,
|
||||
focus: focus.trim() || undefined,
|
||||
},
|
||||
{ onProgress: setProgress, signal: controller.signal },
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const stored = {
|
||||
...podcast,
|
||||
id: uid("pod"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
upsertPodcast(stored);
|
||||
router.push(`/podcast/${stored.id}`);
|
||||
} catch (generateError) {
|
||||
setError({
|
||||
message: generateError instanceof ApiError ? generateError.message : t("upload.genericError"),
|
||||
kind: generateError instanceof ApiError ? generateError.kind : undefined,
|
||||
});
|
||||
setPhase("error");
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<LoadingState
|
||||
stages={PODCAST_STAGES}
|
||||
activeStage={progress.stage}
|
||||
message={progress.message}
|
||||
tipKeys={LOADING_TIPS}
|
||||
onCancel={() => {
|
||||
abortRef.current?.abort();
|
||||
setPhase("idle");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const notesReady = imagesToSend.length > 0 || mergedText.length > 0;
|
||||
|
||||
const dropzone = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (images.length >= MAX_IMAGES ? cameraInput.current?.click() : fileInput.current?.click())}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
void addImages(imageFilesFromDataTransfer(event.dataTransfer).filter(isImageFile));
|
||||
}}
|
||||
className={cn(
|
||||
"press flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-3xl border-2 border-dashed px-4 py-6 text-center transition",
|
||||
dragging ? "border-accent-400 bg-accent-400/10" : "border-[var(--glass-border)] bg-[var(--surface-strong)] hover:border-accent-400",
|
||||
)}
|
||||
>
|
||||
<span className="grid size-12 place-items-center rounded-2xl bg-[var(--surface-sunken)] text-accent-600 dark:text-accent-200">
|
||||
<CameraIcon className="size-6" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[var(--text)]">{t("podcast.addNotes")}</span>
|
||||
<span className="text-[12px] text-[var(--text-subtle)]">{t("upload.formats")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-28 lg:pb-0">
|
||||
<header className="card space-y-1.5 p-4">
|
||||
<h1 className="flex items-center gap-2 text-[17px] font-bold text-[var(--text)]">
|
||||
<MusicIcon className="size-5 text-accent-600 dark:text-accent-200" />
|
||||
{t("podcast.title")}
|
||||
</h1>
|
||||
<p className="text-[13px] leading-relaxed text-[var(--text-muted)]">{t("podcast.subtitle")}</p>
|
||||
</header>
|
||||
|
||||
<section className="card space-y-3 p-4">
|
||||
{dropzone}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<PlusIcon className="size-4" /> {t("podcast.addPhotos")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => docInput.current?.click()}>
|
||||
<PlusIcon className="size-4" /> {t("podcast.addDocs")}
|
||||
</Button>
|
||||
{isPhone ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => cameraInput.current?.click()}>
|
||||
<CameraIcon className="size-4" /> {t("upload.camera")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{images.length > 0 || documents.length > 0 ? (
|
||||
<ul className="flex flex-wrap items-center gap-2">
|
||||
{images.map((image) => (
|
||||
<li key={image.id} className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image.dataUrl} alt="" className="size-14 rounded-xl border border-[var(--hairline)] object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImages((current) => current.filter((entry) => entry.id !== image.id))}
|
||||
aria-label={t("upload.remove", { name: image.name ?? "" })}
|
||||
className="absolute -right-1.5 -top-1.5 grid size-6 place-items-center rounded-full border border-[var(--hairline)] bg-[var(--surface)] text-[var(--text-muted)]"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{documents.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1.5",
|
||||
entry.status === "error" ? "border-rose-300/60 bg-rose-500/10" : "border-[var(--hairline)] bg-[var(--surface-strong)]",
|
||||
)}
|
||||
>
|
||||
<span className="rounded-md bg-[var(--surface-sunken)] px-1.5 py-0.5 text-[10px] font-bold uppercase text-[var(--text-muted)]">
|
||||
{KIND_LABELS[entry.kind]}
|
||||
</span>
|
||||
<span className="max-w-32 truncate text-[12.5px] font-medium text-[var(--text)]" title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="text-[11.5px] text-[var(--text-subtle)]">
|
||||
{entry.status === "extracting" ? entry.progress ?? t("upload.readingDocs") : null}
|
||||
{entry.status === "error" ? entry.error : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDocuments((current) => current.filter((item) => item.id !== entry.id))}
|
||||
aria-label={t("upload.remove", { name: entry.name })}
|
||||
className="grid size-5 place-items-center rounded-full text-[var(--text-subtle)] hover:text-[var(--text)]"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{fileError ? (
|
||||
<p role="alert" className="text-[12.5px] text-rose-700 dark:text-rose-300">
|
||||
{fileError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="card space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("podcast.lengthAria")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LENGTH_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={minutes === option}
|
||||
onClick={() => setMinutes(option)}
|
||||
className={cn(
|
||||
"chip touch-target inline-flex items-center gap-1.5 px-3 text-[13px]",
|
||||
minutes === option && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
|
||||
)}
|
||||
>
|
||||
<ClockIcon className="size-4" />
|
||||
{plural(option, "podcast.minutes")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--text-subtle)]">{t("podcast.lengthHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="podcast-focus" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("podcast.focusLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="podcast-focus"
|
||||
value={focus}
|
||||
onChange={(event) => setFocus(event.target.value)}
|
||||
placeholder={t("upload.topicPlaceholder")}
|
||||
inputMode="text"
|
||||
enterKeyHint="done"
|
||||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="podcast-text" className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
|
||||
{t("upload.pastedNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
id="podcast-text"
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={isPhone ? 4 : 6}
|
||||
placeholder={t("upload.pastedPlaceholder")}
|
||||
className="w-full resize-y rounded-2xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--text)] outline-none transition placeholder:text-[var(--text-subtle)] focus:border-accent-400"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<section className="card space-y-3 border-rose-300/50 p-4">
|
||||
<p role="alert" className="text-[13px] leading-relaxed text-rose-700 dark:text-rose-300">
|
||||
{error.message}
|
||||
</p>
|
||||
<ModelErrorActions kind={error.kind} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<AiDisclaimer />
|
||||
|
||||
{/* Desktop action row; phones get the pinned wizard bar below. */}
|
||||
<div className="hidden sm:block">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full touch-target"
|
||||
disabled={!canGenerate || extracting}
|
||||
onClick={() => void generate()}
|
||||
>
|
||||
<SparklesIcon className="size-4" /> {t("podcast.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<WizardActionBar>
|
||||
<Button
|
||||
size="lg"
|
||||
className="flex-1 touch-target"
|
||||
disabled={!canGenerate || extracting}
|
||||
onClick={() => void generate()}
|
||||
>
|
||||
<SparklesIcon className="size-4" /> {notesReady ? t("podcast.generate") : t("podcast.addFirst")}
|
||||
</Button>
|
||||
</WizardActionBar>
|
||||
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES.join(",")}
|
||||
multiple
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
if (files.length) void addImages(files.filter(isImageFile));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={cameraInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
if (files.length) void addImages(files.filter(isImageFile));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={docInput}
|
||||
type="file"
|
||||
accept={DOCUMENT_ACCEPT_ATTR}
|
||||
multiple
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const files = [...(event.target.files ?? [])];
|
||||
if (files.length) void addDocuments(files.filter(isDocumentFile));
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue