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