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:
parent
d95c5c7151
commit
4ec986da12
8 changed files with 684 additions and 46 deletions
|
|
@ -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) => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue