"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(1); const [playing, setPlaying] = useState(false); const [position, setPosition] = useState(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(() => readNaturalPref().provider); const [naturalVoiceId, setNaturalVoiceId] = useState(() => readNaturalPref().voice); const [naturalError, setNaturalError] = useState(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(() => { 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