freezmo/components/podcast/PodcastPlayer.tsx
Martin Dvorak 724a068bec Aero music: the button works on the podcast player
"The music thing button with the Frutiger Aero doesn't work on the podcasts."
The podcast page is a full-screen overlay at z-45 and the control sat at z-40:
visible through the transparent page, but every tap landed on the overlay.

The control now takes a data-raised="player" position on /podcast/[id] -
z-index 46, docked to the header's corner (top under the header pill, right
mirroring the max-w-3xl geometry) where neither the transport bar nor the
voice panel lives - and the transcript gained pt-32 so the episode card clears
it. Study/review/exam feeds keep their "steps aside" behaviour.

aero-music-test: a podcast section asserts the control exists, that
elementFromPoint at its centre hits the button, and that a tap expands the
credit bar. 31 checks. Version 0.7.9, SW v101.
2026-10-16 21:04:39 +00:00

920 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { createPodcastAudio, type PodcastAudio } from "@/lib/podcast-audio";
import {
defaultTtsProvider,
readNaturalPref,
ttsProviderLabel,
voiceForProvider,
writeNaturalPref,
} from "@/lib/podcast-natural";
import { TTS_PROFILES } from "@/lib/providers";
import { useProviders } from "@/lib/providers-client";
import {
createPodcastSpeech,
tokenizeSections,
type PodcastVoice,
} from "@/lib/podcast-speech";
import { savePodcastProgress } from "@/lib/podcasts";
import {
bestVoice,
getStoredVoice,
isAppleMobile,
onlyRoboticVoices,
platformVoices,
setStoredVoice,
voicesFor,
} from "@/lib/podcast-voices";
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
import type { Podcast } from "@/lib/schemas";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { openSettings } from "@/lib/ui-events";
import {
CheckCircleIcon,
ChevronLeftIcon,
ClockIcon,
PauseIcon,
PlayIcon,
RefreshIcon,
SpeakerIcon,
} 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);
/** The system voice never started (no engine installed) — Firefox/Linux,
* a bare Windows install: the banner explains and offers the way out. */
const [voiceSilent, setVoiceSilent] = useState(false);
const [voiceOpen, setVoiceOpen] = useState(false);
/** "system" = the platform's voices; "natural" = the provider's TTS. */
const [voiceSource, setVoiceSource] = useState<"system" | "natural">(() => readNaturalPref().source);
const [naturalProvider, setNaturalProvider] = useState<string>(() => readNaturalPref().provider);
const [naturalVoiceId, setNaturalVoiceId] = useState<string | null>(() => readNaturalPref().voice);
const [naturalError, setNaturalError] = useState<string | null>(null);
const [naturalBusy, setNaturalBusy] = useState(false);
const providers = useProviders();
/* Either engine drives the same onWord/onEnd events; the transport below
routes to whichever one the listeners picked. */
const natural = useMemo<PodcastAudio | null>(() => {
if (voiceSource !== "natural" || !naturalProvider) return null;
return createPodcastAudio({
sections: episode.sections,
providerId: naturalProvider,
voice: voiceForProvider(naturalProvider, naturalVoiceId),
model: readNaturalPref().model ?? undefined,
rate,
onWord: (next) => {
latest.current = next;
setPosition(next);
},
onPlay: () => {
setPlaying(true);
setNaturalError(null);
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) => {
setPlaying(false);
setNaturalError(message);
},
onBusy: setNaturalBusy,
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- speed is applied live (setRate below), so it must not rebuild the engine
}, [voiceSource, naturalProvider, naturalVoiceId, episode.id, episode.sections]);
/* Speed changes apply to the element, not to the engine's cache. */
useEffect(() => {
natural?.setRate(rate);
}, [natural, rate]);
/* The engine owns an <audio> element: release it when the engine is
replaced (a different voice) or the player closes. */
useEffect(() => {
if (!natural) return;
return () => {
try {
natural.dispose();
} catch {
/* already gone */
}
};
}, [natural]);
useEffect(() => {
return () => naturalRef.current?.dispose();
}, []);
/* The default pick the first time someone opens the natural panel. */
useEffect(() => {
if (naturalProvider) return;
const keys = providers.keys;
const fallback = defaultTtsProvider(keys, providers.generation?.provider);
if (fallback) setNaturalProvider(fallback);
// eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount
}, [providers.keys]);
const transcript = useRef<HTMLDivElement>(null);
const activeWord = useRef<HTMLElement | null>(null);
/** Line-following state: the last followed line and a manual-scroll pause. */
const lastLineTop = useRef<number | null>(null);
const followPausedUntil = useRef(0);
const voiceLocale = useMemo(
() => speechLocale(episode.language, locale),
[episode.language, locale],
);
const [voiceList, setVoiceList] = useState<SpeechSynthesisVoice[]>([]);
const [voiceURI, setVoiceURI] = useState<string | null>(() =>
getStoredVoice(voiceLocale),
);
/* The voice catalogue arrives asynchronously (and changes when the OS
installs one), so subscribe rather than read it once. */
useEffect(() => {
const read = () => setVoiceList(platformVoices());
read();
try {
window.speechSynthesis?.addEventListener("voiceschanged", read);
} catch {
/* no voices API */
}
return () => {
try {
window.speechSynthesis?.removeEventListener("voiceschanged", read);
} catch {
/* already gone */
}
};
}, []);
const availableVoices = useMemo(
() => voicesFor(voiceLocale, voiceList),
[voiceLocale, voiceList],
);
const voice = useMemo(() => {
if (voiceURI) {
const match = availableVoices.find(
(entry) => entry.voiceURI === voiceURI,
);
if (match) return match;
}
return bestVoice(voiceLocale, voiceList);
}, [voiceURI, availableVoices, voiceLocale, voiceList]);
const ttsProviderIds = useMemo(
() => Object.keys(TTS_PROFILES).filter((id) => Boolean(providers.keys[id])),
[providers.keys],
);
const roboticOnly = useMemo(
() => onlyRoboticVoices(voiceLocale, voiceList),
[voiceLocale, voiceList],
);
/* iOS hands the page one voice per language; point at the settings that
change it (the note is the whole reason this section exists there). */
const iosLimited = useMemo(
() => isAppleMobile() && availableVoices.length <= 1,
[availableVoices.length],
);
/** 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(), []);
/* Live getters: changing the speed or the voice must never rebuild a
speaking engine (a rebuilt one used to leave the old voice running). */
const rateRef = useRef(rate);
const voiceRef = useRef<SpeechSynthesisVoice | null>(null);
useEffect(() => {
rateRef.current = rate;
}, [rate]);
useEffect(() => {
voiceRef.current = voice;
}, [voice]);
const speech = useMemo(() => {
if (!supported) return null;
return createPodcastSpeech({
sections: episode.sections,
locale: voiceLocale,
getRate: () => rateRef.current,
getVoice: () => voiceRef.current,
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);
},
});
}, [episode.id, episode.sections, supported, voiceLocale]);
/* 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 naturalRef = useRef<PodcastAudio | null>(natural);
const playingRef = useRef(false);
useEffect(() => {
speechRef.current = speech;
}, [speech]);
useEffect(() => {
naturalRef.current = natural;
}, [natural]);
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). The scroll follows by *line*: words on
the same line never move anything, a line only scrolls once it leaves the
comfortable band, and a manual scroll pauses the following for a moment
instead of fighting the reader. */
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;
/* Same line as last time (a wrapped word shifts a pixel or two): nothing
to do — this is what stops the jitter under every word. */
const previous = lastLineTop.current;
const sameLine = previous !== null && Math.abs(top - previous) < 8;
lastLineTop.current = top;
if (sameLine) return;
/* The reader scrolled away on purpose: let them read for a moment. */
if (Date.now() < followPausedUntil.current) return;
const comfortable = view.height * 0.42;
const delta = top - comfortable;
/* Inside the band already: leave the page alone. */
if (Math.abs(delta) < view.height * 0.14) return;
container.scrollTo({
top: container.scrollTop + delta,
behavior: Math.abs(delta) > 80 && !reduceMotion ? "smooth" : "auto",
});
}, [position, tokens, reduceMotion]);
/* A wheel, a drag or a touch on the transcript is the reader taking over. */
useEffect(() => {
const container = transcript.current;
if (!container) return;
const suspend = () => {
followPausedUntil.current = Date.now() + 4000;
};
container.addEventListener("wheel", suspend, { passive: true });
container.addEventListener("touchstart", suspend, { passive: true });
container.addEventListener("pointerdown", suspend, { passive: true });
return () => {
container.removeEventListener("wheel", suspend);
container.removeEventListener("touchstart", suspend);
container.removeEventListener("pointerdown", suspend);
};
}, []);
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]);
/* The system voice has neither a live rate nor a live voice: restart the
current part so a change is heard at once (the natural engine above is
live, so this only runs on the system path). */
const firstRestart = useRef(true);
useEffect(() => {
if (firstRestart.current) {
firstRestart.current = false;
return;
}
if (!playingRef.current || naturalActive || !speech) return;
speech.play(latest.current ?? { section: 0, word: 0 });
// eslint-disable-next-line react-hooks/exhaustive-deps -- restarts only when the rate or the voice changes
}, [rate, voice]);
/* A rebuilt natural engine (a different voice) resumes where it was. */
const firstNatural = useRef(true);
useEffect(() => {
if (firstNatural.current) {
firstNatural.current = false;
return;
}
if (!playingRef.current || !natural) return;
natural.play(latest.current ?? { section: 0, word: 0 });
}, [natural]);
const naturalActive = voiceSource === "natural" && Boolean(natural);
/** The engines share their shape; the transport routes by source. */
const canPlay = naturalActive ? true : supported;
const harness = () => (naturalActive ? natural : speech);
const toggle = () => {
if (!canPlay) return;
if (playing) {
harness()?.pause();
persist();
return;
}
if (!naturalActive && availableVoices.length === 0) {
/* Nothing to speak with: say so instead of doing nothing. */
setVoiceSilent(true);
return;
}
/* Other readers (a card's read-aloud) yield to the episode. */
stopSpeaking();
harness()?.play(position ?? { section: 0, word: 0 });
/* The system engine can also be *present* and still do nothing (no voice
data behind it). If nothing starts, the banner takes over. */
if (!naturalActive) {
const attempt = Date.now();
window.setTimeout(() => {
if (Date.now() - attempt < 1500) return;
if (!playingRef.current) setVoiceSilent(true);
}, 1700);
}
};
const seek = (next: PodcastVoice) => {
if (!canPlay) return;
setVoiceSilent(false);
latest.current = next;
setPosition(next);
/* A seek is an instruction to show that place: follow it at once. */
lastLineTop.current = null;
followPausedUntil.current = 0;
stopSpeaking();
harness()?.play(next);
};
const jump = (section: number) => seek({ section, word: 0 });
const jumpWord = (index: number) => {
const token = tokens[index];
if (!token) return;
seek({ section: token.section, word: token.index });
};
/* 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>
{availableVoices.length > 0 || ttsProviderIds.length > 0 ? (
<button
type="button"
onClick={() => setVoiceOpen((open) => !open)}
aria-label={t("podcast.voiceAria")}
aria-expanded={voiceOpen}
title={voice?.name ?? t("podcast.voiceAria")}
className={cn(
"relative grid size-10 shrink-0 place-items-center rounded-full transition",
voiceOpen
? "bg-[var(--nav-active-bg)] text-[var(--text)]"
: "bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
<SpeakerIcon className="size-4" />
{iosLimited ? (
<span
aria-hidden
className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full bg-amber-400 ring-2 ring-[var(--page)]"
/>
) : null}
</button>
) : null}
{naturalBusy ? (
<span
role="status"
className="shrink-0 rounded-full bg-[var(--surface-sunken)] px-2.5 py-2 text-[11px] font-semibold text-[var(--text-muted)]"
>
{t("podcast.voiceGenerating")}
</span>
) : null}
<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);
}}
/* pt-32 clears the Aero music control, which rides above this overlay
in the header's corner while the theme is on. */
className="no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-40 pt-32"
>
<div className="mx-auto w-full max-w-3xl space-y-6">
{naturalError ? (
<p
role="alert"
className="rounded-2xl border border-rose-300/50 bg-rose-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-rose-800 dark:border-rose-400/25 dark:bg-rose-400/10 dark:text-rose-200"
>
{naturalError}
</p>
) : null}
{(!naturalActive && (!supported || unsupported || voiceSilent)) || (naturalActive && !natural) ? (
<div className="space-y-2.5 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">
<p>
{naturalActive && !natural
? t("podcast.voiceNeedsKey")
: `${
voiceSilent || unsupported
? t("podcast.voiceSilent")
: t("podcast.noVoice")
}${isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}`}
</p>
<div className="flex flex-wrap items-center gap-2">
{ttsProviderIds.length > 0 ? (
<Button
size="sm"
onClick={() => {
setVoiceSilent(false);
setUnsupported(false);
setNaturalError(null);
setVoiceSource("natural");
writeNaturalPref({ source: "natural" });
}}
>
{t("podcast.voiceUseNatural")}
</Button>
) : (
<Button size="sm" onClick={() => openSettings()}>
{t("podcast.voiceOpenProviders")}
</Button>
)}
<button
type="button"
onClick={() => {
setVoiceSilent(false);
setVoiceOpen(true);
}}
className="text-[12.5px] font-semibold underline decoration-dotted"
>
{t("podcast.voiceAria")}
</button>
</div>
</div>
) : 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">
{voiceOpen ? (
<div className="pointer-events-auto mx-auto mb-2 w-full max-w-md px-0">
<div className="glass-panel max-h-72 space-y-1 overflow-y-auto overscroll-contain p-3">
<div className="flex items-center justify-between gap-2 px-1">
<p className="text-[12px] font-semibold uppercase tracking-wide text-[var(--text-subtle)]">
{t("podcast.voiceAria")}
</p>
<button
type="button"
onClick={() => setVoiceOpen(false)}
className="text-[12.5px] font-semibold text-accent-700 dark:text-accent-200"
>
{t("common.close")}
</button>
</div>
{/* Source: the platform's voices, or the provider's TTS. */}
<div className="grid grid-cols-2 gap-1.5 px-1 pb-1">
{(["system", "natural"] as const).map((source) => (
<button
key={source}
type="button"
aria-pressed={voiceSource === source}
onClick={() => {
setVoiceSource(source);
writeNaturalPref({ source });
setNaturalError(null);
/* Swapping engines mid-sentence: stop the other one. */
if (playing) {
harness()?.pause();
setPlaying(false);
}
}}
className={cn(
"touch-target rounded-xl border px-3 py-2 text-[12.5px] font-semibold transition",
voiceSource === source
? "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)] text-[var(--text)]"
: "border-[var(--hairline)] bg-[var(--surface-sunken)] text-[var(--text-muted)] hover:bg-[var(--hover)]",
)}
>
{source === "system" ? t("podcast.voiceSystem") : t("podcast.voiceNatural")}
</button>
))}
</div>
{voiceSource === "system" ? (
<>
{availableVoices.length === 0 ? (
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{t("podcast.voiceNoSystem")}
</p>
) : null}
{voiceURI ? (
<button
type="button"
onClick={() => {
setVoiceURI(null);
window.localStorage.removeItem("freezmo.podcast-voice.v1");
}}
className="nav-row touch-target w-full text-left text-[13px]"
>
{t("podcast.voiceAuto")}
</button>
) : null}
{availableVoices.map((entry) => {
const active = entry.voiceURI === voice?.voiceURI;
return (
<button
key={entry.voiceURI}
type="button"
aria-pressed={active}
onClick={() => {
setVoiceURI(entry.voiceURI);
setStoredVoice(voiceLocale, entry.voiceURI);
setVoiceOpen(false);
}}
className={cn(
"nav-row touch-target flex w-full items-center gap-2 text-left text-[13px]",
active && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-[var(--text)]">{entry.name}</span>
<span className="block text-[11.5px] text-[var(--text-subtle)]">
{entry.lang}
{entry.localService ? ` · ${t("podcast.voiceLocal")}` : ""}
</span>
</span>
{active ? <CheckCircleIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" /> : null}
</button>
);
})}
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{iosLimited ? t("podcast.voiceIos") : roboticOnly ? t("podcast.voiceRobotic") : t("podcast.voiceNote")}
</p>
</>
) : ttsProviderIds.length === 0 ? (
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{t("podcast.voiceNeedsKey")}
</p>
) : (
<>
{ttsProviderIds.length > 1 ? (
<div className="flex flex-wrap gap-1.5 px-1 pb-1 pt-1">
{ttsProviderIds.map((id) => (
<button
key={id}
type="button"
aria-pressed={naturalProvider === id}
onClick={() => {
setNaturalProvider(id);
setNaturalVoiceId(null);
writeNaturalPref({ provider: id, voice: null });
}}
className={cn(
"rounded-full px-2.5 py-1.5 text-[11.5px] font-semibold transition",
naturalProvider === id
? "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)]",
)}
>
{ttsProviderLabel(id)}
</button>
))}
</div>
) : null}
{(TTS_PROFILES[naturalProvider]?.voices ?? []).map((entry) => {
const active = entry.id === voiceForProvider(naturalProvider, naturalVoiceId);
return (
<button
key={entry.id}
type="button"
aria-pressed={active}
onClick={() => {
setNaturalVoiceId(entry.id);
writeNaturalPref({ voice: entry.id });
}}
className={cn(
"nav-row touch-target flex w-full items-center gap-2 text-left text-[13px]",
active && "border-[var(--nav-active-border)] bg-[var(--nav-active-bg)]",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-[var(--text)]">{entry.label}</span>
<span className="block text-[11.5px] text-[var(--text-subtle)]">
{entry.male ? t("podcast.voiceMale") : t("podcast.voiceFemale")}
</span>
</span>
{active ? <CheckCircleIcon className="size-4 shrink-0 text-accent-600 dark:text-accent-200" /> : null}
</button>
);
})}
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{naturalError ?? t("podcast.voiceNaturalNote")}
</p>
</>
)}
</div>
</div>
) : null}
<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={!canPlay}
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={() => {
harness()?.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>
);
}