Podcast player: calmer line-following scroll, and a voice you can pick

"The scrolling works kinda weirdly" + "maybe a less robotic voice, preferably
male".

Scroll: the transcript now follows by LINE, not by word - words on the same
line never move the page (that was the jitter), a line only scrolls once it
leaves a comfortable band, small corrections are instant rather than animated,
and a wheel/touch/drag on the transcript pauses the following for four seconds
instead of fighting the reader. Chapter jumps and word taps re-centre at once.

Voice: lib/podcast-voices ranks what the platform installed (natural / neural
/ online names up, espeak / SAM / compact down, a male name preferred) and the
player picks the best one automatically; a new picker in the chrome lists
everything in the episode's language, applies it live and remembers the choice
per language. A note points at the system settings when only robotic voices
exist. The engine takes the chosen voice instead of guessing.

podcast-test: 34 checks now (voice ranking, filtered picker, persisted pick,
same-line silence, line following, manual-scroll suspension). Version 0.7.4,
SW v96.
This commit is contained in:
Martin Dvořák 2026-10-16 13:05:57 +00:00
commit 4ec986da12
8 changed files with 684 additions and 46 deletions

View file

@ -4,12 +4,32 @@ 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 {
createPodcastSpeech,
tokenizeSections,
type PodcastVoice,
} from "@/lib/podcast-speech";
import { savePodcastProgress } from "@/lib/podcasts";
import {
bestVoice,
getStoredVoice,
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 { ChevronLeftIcon, ClockIcon, PauseIcon, PlayIcon, RefreshIcon } from "@/components/icons";
import {
CheckCircleIcon,
ChevronLeftIcon,
ClockIcon,
PauseIcon,
PlayIcon,
RefreshIcon,
SpeakerIcon,
} from "@/components/icons";
import { cn } from "@/lib/utils";
/** Playback speeds, cycled by one button. */
@ -32,20 +52,73 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
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],
);
/** 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 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),
locale: voiceLocale,
rate,
voice,
onWord: (next) => {
latest.current = next;
setPosition(next);
@ -53,7 +126,9 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
onPlay: () => {
setPlaying(true);
/* The Aero soundtrack steps aside for the voice (see AeroMusic). */
window.dispatchEvent(new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }));
window.dispatchEvent(
new CustomEvent("freezmo:foreground-audio", { detail: "podcast" }),
);
},
onPause: () => setPlaying(false),
onEnd: () => {
@ -67,14 +142,19 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
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]);
}, [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 });
latest.current = {
section: episode.progress.section,
word: episode.progress.word,
};
setPosition({
section: episode.progress.section,
word: episode.progress.word,
});
}
}, [episode.id, episode.progress]);
@ -127,7 +207,10 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
}, [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. */
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;
@ -137,9 +220,14 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
activeWord.current = null;
return;
}
const index = tokens.findIndex((token) => token.section === position.section && token.index === position.word);
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}"]`);
const element = container.querySelector<HTMLElement>(
`[data-word="${index}"]`,
);
if (!element) return;
element.setAttribute("data-word-active", "1");
element.classList.add("podcast-word-active");
@ -148,20 +236,50 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
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",
});
}
/* 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);
return tokens.findIndex(
(token) =>
token.section === position.section && token.index === position.word,
);
}, [position, tokens]);
const toggle = () => {
@ -176,37 +294,43 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
speech.play(position ?? { section: 0, word: 0 });
};
const jump = (section: number) => {
const seek = (next: PodcastVoice) => {
if (!speech) return;
const next = { section, word: 0 };
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) => {
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);
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 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));
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">
<span
key={`${token.start}`}
data-word={globalIndex}
className="podcast-word"
>
{section.text.slice(token.start, token.end)}
</span>,
);
@ -232,13 +356,35 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
<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-[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 })}
{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(
"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" />
</button>
) : null}
<button
type="button"
onClick={() => {
@ -278,11 +424,14 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
{episode.title}
</h1>
{episode.summary ? (
<p className="text-[13.5px] leading-relaxed text-[var(--text-muted)]">{episode.summary}</p>
<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")}
{plural(episode.sections.length, "podcast.sectionCount")} ·{" "}
{plural(words, "podcast.wordCount")}
</p>
</div>
@ -310,18 +459,98 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
))}
</div>
<p className="pb-6 text-center text-[11.5px] text-[var(--text-subtle)]">{t("podcast.tapToJump")}</p>
<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)]">
{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="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%" }}
style={{
width: words ? `${Math.round((done / words) * 100)}%` : "0%",
}}
/>
</div>
<div className="flex items-center gap-2">
@ -333,7 +562,11 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
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" />}
{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) => (