freezmo/components/podcast/PodcastPlayer.tsx
Martin Dvorak 9882d775e7 Podcast voices: an iPhone note on where the other voices live
Safari hands web pages one voice per language, whatever the iPhone has
installed, so the picker cannot fix that on iOS. It now carries the way out
instead: Accessibility -> Spoken Content -> Voices -> your language, download
an Enhanced/Premium voice and make it preferred. The speaker button wears an
amber dot while the note is waiting, and the voice-less banner appends the
same guidance. Detection covers iPadOS too (maxTouchPoints, since it reports
as a Mac).

The suite fakes an iPhone user agent, a single Daniel voice and pins the dot
and the note's actual steps (37 checks). Version 0.7.5, SW v97.
2026-10-16 13:51:12 +00:00

625 lines
24 KiB
TypeScript
Raw 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 { useI18n } from "@/lib/i18n";
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 {
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);
const [voiceOpen, setVoiceOpen] = useState(false);
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 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(), []);
const speech = useMemo(() => {
if (!supported) return null;
return createPodcastSpeech({
sections: episode.sections,
locale: voiceLocale,
rate,
voice,
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, rate, supported, voiceLocale, voice]);
/* 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). 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]);
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 seek = (next: PodcastVoice) => {
if (!speech) return;
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();
speech.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 ? (
<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}
<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")}
{isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}
</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">
{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>
{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>
</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={!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>
);
}