"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(1); const [playing, setPlaying] = useState(false); const [position, setPosition] = useState(null); const [unsupported, setUnsupported] = useState(false); const transcript = useRef(null); const activeWord = useRef(null); /** Latest position for the unmount/pause save, without re-creating callbacks. */ const latest = useRef(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(`[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( {section.text.slice(token.start, token.end)} , ); 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 (

{episode.title}

{t("podcast.sectionOf", { n: Math.min(currentSection + 1, sections.length), total: sections.length })} {" · "} {sections[currentSection]?.heading ?? ""}

{ /* 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" >
{!supported || unsupported ? (

{t("podcast.noVoice")}

) : null}

{episode.title}

{episode.summary ? (

{episode.summary}

) : null}

{plural(episode.sections.length, "podcast.sectionCount")} · {plural(words, "podcast.wordCount")}

{/* 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. */}
{rendered.map(({ section, sectionIndex, nodes }) => (

{sectionIndex + 1} {section.heading}

{nodes}

))}

{t("podcast.tapToJump")}

{/* Controls: chapter list on the left, transport in the middle. */}
{sections.map((section, index) => ( ))}
); }