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
11
README.md
11
README.md
|
|
@ -1516,7 +1516,16 @@ Things I chose while building, with the reasoning:
|
||||||
contrast, and the scan now covers this screen too. `tests/podcast-test.mjs` (17th suite)
|
contrast, and the scan now covers this screen too. `tests/podcast-test.mjs` (17th suite)
|
||||||
stubs both the model and the speech engine: it drives a boundary event, checks the
|
stubs both the model and the speech engine: it drives a boundary event, checks the
|
||||||
pause/resume word, the chapter jump, the speed change, the stored position and the plugin's
|
pause/resume word, the chapter jump, the speed change, the stored position and the plugin's
|
||||||
400/stream guards (26 checks).
|
400/stream guards. **Follow-up (0.7.4):** the scroll 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 instead of animated, and a manual wheel,
|
||||||
|
touch or drag pauses the following for four seconds instead of fighting the reader; a seek
|
||||||
|
(chapter, word tap) re-centres at once. And the voice is now a choice: `lib/podcast-voices`
|
||||||
|
ranks what the platform has installed (natural/neural/online names up, espeak/SAM/compact
|
||||||
|
down, a male name preferred — "preferably male" was the ask), the player picks the best one
|
||||||
|
automatically and offers a picker for everything in the episode's language, and the choice is
|
||||||
|
remembered per language. The suite grew to 34 checks (voice ranking, the filtered picker,
|
||||||
|
persisting a pick, same-line silence, line following, manual-scroll suspension).
|
||||||
|
|
||||||
102. **An API key is not a login.** "When trying to use a different provider on the web app it
|
102. **An API key is not a login.** "When trying to use a different provider on the web app it
|
||||||
glitches out completely" — a photo of the first-run gate with iOS's saved-password sheet laid
|
glitches out completely" — a photo of the first-run gate with iOS's saved-password sheet laid
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,32 @@ import Link from "next/link";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useI18n } from "@/lib/i18n";
|
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 { savePodcastProgress } from "@/lib/podcasts";
|
||||||
|
import {
|
||||||
|
bestVoice,
|
||||||
|
getStoredVoice,
|
||||||
|
onlyRoboticVoices,
|
||||||
|
platformVoices,
|
||||||
|
setStoredVoice,
|
||||||
|
voicesFor,
|
||||||
|
} from "@/lib/podcast-voices";
|
||||||
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
|
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
|
||||||
import type { Podcast } from "@/lib/schemas";
|
import type { Podcast } from "@/lib/schemas";
|
||||||
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Playback speeds, cycled by one button. */
|
/** Playback speeds, cycled by one button. */
|
||||||
|
|
@ -32,20 +52,73 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [position, setPosition] = useState<PodcastVoice | null>(null);
|
const [position, setPosition] = useState<PodcastVoice | null>(null);
|
||||||
const [unsupported, setUnsupported] = useState(false);
|
const [unsupported, setUnsupported] = useState(false);
|
||||||
|
const [voiceOpen, setVoiceOpen] = useState(false);
|
||||||
const transcript = useRef<HTMLDivElement>(null);
|
const transcript = useRef<HTMLDivElement>(null);
|
||||||
const activeWord = useRef<HTMLElement | null>(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. */
|
/** Latest position for the unmount/pause save, without re-creating callbacks. */
|
||||||
const latest = useRef<PodcastVoice | null>(episode.progress ?? null);
|
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 supported = useMemo(() => speechAvailable(), []);
|
||||||
|
|
||||||
const speech = useMemo(() => {
|
const speech = useMemo(() => {
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
return createPodcastSpeech({
|
return createPodcastSpeech({
|
||||||
sections: episode.sections,
|
sections: episode.sections,
|
||||||
locale: speechLocale(episode.language, locale),
|
locale: voiceLocale,
|
||||||
rate,
|
rate,
|
||||||
|
voice,
|
||||||
onWord: (next) => {
|
onWord: (next) => {
|
||||||
latest.current = next;
|
latest.current = next;
|
||||||
setPosition(next);
|
setPosition(next);
|
||||||
|
|
@ -53,7 +126,9 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
onPlay: () => {
|
onPlay: () => {
|
||||||
setPlaying(true);
|
setPlaying(true);
|
||||||
/* The Aero soundtrack steps aside for the voice (see AeroMusic). */
|
/* 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),
|
onPause: () => setPlaying(false),
|
||||||
onEnd: () => {
|
onEnd: () => {
|
||||||
|
|
@ -67,14 +142,19 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
setPlaying(false);
|
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, voiceLocale, voice]);
|
||||||
}, [episode.id, episode.sections, rate, supported, locale]);
|
|
||||||
|
|
||||||
/* Resume where the listener left off (a reopened episode starts there). */
|
/* Resume where the listener left off (a reopened episode starts there). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (episode.progress) {
|
if (episode.progress) {
|
||||||
latest.current = { section: episode.progress.section, word: episode.progress.word };
|
latest.current = {
|
||||||
setPosition({ section: episode.progress.section, word: episode.progress.word });
|
section: episode.progress.section,
|
||||||
|
word: episode.progress.word,
|
||||||
|
};
|
||||||
|
setPosition({
|
||||||
|
section: episode.progress.section,
|
||||||
|
word: episode.progress.word,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [episode.id, episode.progress]);
|
}, [episode.id, episode.progress]);
|
||||||
|
|
||||||
|
|
@ -127,7 +207,10 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
}, [speech]);
|
}, [speech]);
|
||||||
|
|
||||||
/* The highlight: one class on the active word (imperative, so a few hundred
|
/* 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(() => {
|
useEffect(() => {
|
||||||
const container = transcript.current;
|
const container = transcript.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -137,9 +220,14 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
activeWord.current = null;
|
activeWord.current = null;
|
||||||
return;
|
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;
|
if (index === -1) return;
|
||||||
const element = container.querySelector<HTMLElement>(`[data-word="${index}"]`);
|
const element = container.querySelector<HTMLElement>(
|
||||||
|
`[data-word="${index}"]`,
|
||||||
|
);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
element.setAttribute("data-word-active", "1");
|
element.setAttribute("data-word-active", "1");
|
||||||
element.classList.add("podcast-word-active");
|
element.classList.add("podcast-word-active");
|
||||||
|
|
@ -148,20 +236,50 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
const box = element.getBoundingClientRect();
|
const box = element.getBoundingClientRect();
|
||||||
const view = container.getBoundingClientRect();
|
const view = container.getBoundingClientRect();
|
||||||
const top = box.top - view.top;
|
const top = box.top - view.top;
|
||||||
const comfortable = view.height * 0.38;
|
/* Same line as last time (a wrapped word shifts a pixel or two): nothing
|
||||||
if (top < view.height * 0.18 || top > view.height * 0.62) {
|
to do — this is what stops the jitter under every word. */
|
||||||
container.scrollTo({
|
const previous = lastLineTop.current;
|
||||||
top: container.scrollTop + top - comfortable,
|
const sameLine = previous !== null && Math.abs(top - previous) < 8;
|
||||||
behavior: reduceMotion ? "auto" : "smooth",
|
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]);
|
}, [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 sections = episode.sections;
|
||||||
const words = tokens.length;
|
const words = tokens.length;
|
||||||
const done = useMemo(() => {
|
const done = useMemo(() => {
|
||||||
if (!position) return 0;
|
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]);
|
}, [position, tokens]);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
|
|
@ -176,37 +294,43 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
speech.play(position ?? { section: 0, word: 0 });
|
speech.play(position ?? { section: 0, word: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const jump = (section: number) => {
|
const seek = (next: PodcastVoice) => {
|
||||||
if (!speech) return;
|
if (!speech) return;
|
||||||
const next = { section, word: 0 };
|
|
||||||
latest.current = next;
|
latest.current = next;
|
||||||
setPosition(next);
|
setPosition(next);
|
||||||
|
/* A seek is an instruction to show that place: follow it at once. */
|
||||||
|
lastLineTop.current = null;
|
||||||
|
followPausedUntil.current = 0;
|
||||||
stopSpeaking();
|
stopSpeaking();
|
||||||
speech.play(next);
|
speech.play(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const jump = (section: number) => seek({ section, word: 0 });
|
||||||
|
|
||||||
const jumpWord = (index: number) => {
|
const jumpWord = (index: number) => {
|
||||||
if (!speech) return;
|
|
||||||
const token = tokens[index];
|
const token = tokens[index];
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const next = { section: token.section, word: token.index };
|
seek({ 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. */
|
/* Word spans, built once per section: the highlight is a class flip. */
|
||||||
const rendered = useMemo(() => {
|
const rendered = useMemo(() => {
|
||||||
return sections.map((section, sectionIndex) => {
|
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[] = [];
|
const nodes: React.ReactNode[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
sectionTokens.forEach((token) => {
|
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;
|
const globalIndex = token.global;
|
||||||
nodes.push(
|
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)}
|
{section.text.slice(token.start, token.end)}
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
|
|
@ -232,13 +356,35 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
<ChevronLeftIcon className="size-4" />
|
<ChevronLeftIcon className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="min-w-0 flex-1">
|
<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)]">
|
<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 ?? ""}
|
{sections[currentSection]?.heading ?? ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -278,11 +424,14 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
{episode.title}
|
{episode.title}
|
||||||
</h1>
|
</h1>
|
||||||
{episode.summary ? (
|
{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}
|
) : null}
|
||||||
<p className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-subtle)]">
|
<p className="flex items-center gap-1.5 text-[12px] font-medium text-[var(--text-subtle)]">
|
||||||
<ClockIcon className="size-3.5" />
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -310,18 +459,98 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls: chapter list on the left, transport in the middle. */}
|
{/* 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-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">
|
<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="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
|
<div
|
||||||
className="h-full rounded-full grad-ice transition-[width] duration-200"
|
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>
|
||||||
<div className="flex items-center gap-2">
|
<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")}
|
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"
|
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>
|
</button>
|
||||||
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||||
{sections.map((section, index) => (
|
{sections.map((section, index) => (
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,11 @@ const en: Record<string, string> = {
|
||||||
"podcast.pause": "Pause the episode",
|
"podcast.pause": "Pause the episode",
|
||||||
"podcast.restart": "Start from the beginning",
|
"podcast.restart": "Start from the beginning",
|
||||||
"podcast.speedAria": "Playback speed",
|
"podcast.speedAria": "Playback speed",
|
||||||
|
"podcast.voiceAria": "Voice",
|
||||||
|
"podcast.voiceAuto": "Automatic (best available)",
|
||||||
|
"podcast.voiceLocal": "installed",
|
||||||
|
"podcast.voiceNote": "Voices come from your browser and system — pick the one that sounds best to you.",
|
||||||
|
"podcast.voiceRobotic": "Only the basic system voices are installed. For a natural host voice, add one in your system settings (Windows: Speech; macOS: Spoken Content; Chrome ships the Google voices).",
|
||||||
"podcast.tapToJump": "Tap any word to jump there.",
|
"podcast.tapToJump": "Tap any word to jump there.",
|
||||||
"podcast.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
|
"podcast.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
|
||||||
"podcast.loadingEpisode": "Loading the episode",
|
"podcast.loadingEpisode": "Loading the episode",
|
||||||
|
|
@ -1369,6 +1374,11 @@ const cs: Record<string, string> = {
|
||||||
"podcast.pause": "Pozastavit epizodu",
|
"podcast.pause": "Pozastavit epizodu",
|
||||||
"podcast.restart": "Začít od začátku",
|
"podcast.restart": "Začít od začátku",
|
||||||
"podcast.speedAria": "Rychlost přehrávání",
|
"podcast.speedAria": "Rychlost přehrávání",
|
||||||
|
"podcast.voiceAria": "Hlas",
|
||||||
|
"podcast.voiceAuto": "Automaticky (nejlepší dostupný)",
|
||||||
|
"podcast.voiceLocal": "nainstalovaný",
|
||||||
|
"podcast.voiceNote": "Hlasy dodává prohlížeč a systém — vyber si ten, co zní nejlíp.",
|
||||||
|
"podcast.voiceRobotic": "Jsou nainstalované jen základní systémové hlasy. Pro přirozený hlas moderátora si nějaký přidej v nastavení systému (Windows: Řeč; macOS: Mluvený obsah; Chrome má hlasy Google).",
|
||||||
"podcast.tapToJump": "Klikni na kterékoli slovo a přeskočíš tam.",
|
"podcast.tapToJump": "Klikni na kterékoli slovo a přeskočíš tam.",
|
||||||
"podcast.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",
|
"podcast.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",
|
||||||
"podcast.loadingEpisode": "Načítání epizody",
|
"podcast.loadingEpisode": "Načítání epizody",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
* "jump to a chapter" and "resume where you left off" the same operation.
|
* "jump to a chapter" and "resume where you left off" the same operation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { pickVoice, speechAvailable } from "./speech";
|
import { speechAvailable } from "./speech";
|
||||||
import type { PodcastSection } from "./schemas";
|
import type { PodcastSection } from "./schemas";
|
||||||
|
|
||||||
export type PodcastVoice = { section: number; word: number };
|
export type PodcastVoice = { section: number; word: number };
|
||||||
|
|
@ -25,6 +25,8 @@ export type PodcastSpeechEvents = {
|
||||||
sections: PodcastSection[];
|
sections: PodcastSection[];
|
||||||
locale: string;
|
locale: string;
|
||||||
rate: number;
|
rate: number;
|
||||||
|
/** The chosen voice (lib/podcast-voices); null lets the platform decide. */
|
||||||
|
voice?: SpeechSynthesisVoice | null;
|
||||||
/** Global word position changed (word = index inside its section). */
|
/** Global word position changed (word = index inside its section). */
|
||||||
onWord: (position: PodcastVoice) => void;
|
onWord: (position: PodcastVoice) => void;
|
||||||
/** Playback actually began (after the engine accepted it). */
|
/** Playback actually began (after the engine accepted it). */
|
||||||
|
|
@ -134,8 +136,7 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
utterance.lang = events.locale;
|
utterance.lang = events.locale;
|
||||||
utterance.rate = events.rate;
|
utterance.rate = events.rate;
|
||||||
const voice = pickVoice(events.locale);
|
if (events.voice) utterance.voice = events.voice;
|
||||||
if (voice) utterance.voice = voice;
|
|
||||||
|
|
||||||
const offsetFor = (charIndex: number) => {
|
const offsetFor = (charIndex: number) => {
|
||||||
/* First token whose span ends after the reported character. */
|
/* First token whose span ends after the reported character. */
|
||||||
|
|
|
||||||
231
lib/podcast-voices.ts
Normal file
231
lib/podcast-voices.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choosing a voice for the podcast player.
|
||||||
|
*
|
||||||
|
* The Web Speech API ships no voices of its own: the list comes from the OS
|
||||||
|
* and the browser, and it swings wildly between platforms — from espeak's
|
||||||
|
* robot on a bare Linux box to Google's network voices, Microsoft's neural
|
||||||
|
* ones and macOS's Alex. This module ranks what is installed, prefers a male
|
||||||
|
* voice (a podcast host, per the listener's wish) among the good ones, and
|
||||||
|
* remembers the listener's own pick per language.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "freezmo.podcast-voice.v1";
|
||||||
|
|
||||||
|
/** Names that usually mean "a human actually recorded this". */
|
||||||
|
const QUALITY_HINTS = ["natural", "neural", "online", "premium", "enhanced", "siri", "google", "microsoft"];
|
||||||
|
/** Engines that always sound like a 1980s answering machine. */
|
||||||
|
const ROBOTIC_HINTS = ["espeak", "sam", "pico", "festival", "flite", "compact", "robot"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Male first names that appear in speech-voice catalogues. Not exhaustive —
|
||||||
|
* it only has to beat "pick the first voice for the language".
|
||||||
|
*/
|
||||||
|
const MALE_NAMES = [
|
||||||
|
"male", // "Google UK English Male"
|
||||||
|
"alex",
|
||||||
|
"daniel",
|
||||||
|
"fred",
|
||||||
|
"thomas",
|
||||||
|
"oliver",
|
||||||
|
"david",
|
||||||
|
"mark",
|
||||||
|
"james",
|
||||||
|
"ryan",
|
||||||
|
"guy",
|
||||||
|
"george",
|
||||||
|
"pavel",
|
||||||
|
"jakub",
|
||||||
|
"marek",
|
||||||
|
"ondrej",
|
||||||
|
"vojtech",
|
||||||
|
"jirka",
|
||||||
|
"petr",
|
||||||
|
"tomas",
|
||||||
|
"martin",
|
||||||
|
"lukas",
|
||||||
|
"jan",
|
||||||
|
"antonin",
|
||||||
|
"josef",
|
||||||
|
"jorge",
|
||||||
|
"diego",
|
||||||
|
"carlos",
|
||||||
|
"juan",
|
||||||
|
"pablo",
|
||||||
|
"luca",
|
||||||
|
"matteo",
|
||||||
|
"marco",
|
||||||
|
"pierre",
|
||||||
|
"henri",
|
||||||
|
"klaus",
|
||||||
|
"hans",
|
||||||
|
"stefan",
|
||||||
|
"felix",
|
||||||
|
"magnus",
|
||||||
|
"erik",
|
||||||
|
"yuri",
|
||||||
|
"dmitri",
|
||||||
|
"ivan",
|
||||||
|
"xander",
|
||||||
|
"zhihao",
|
||||||
|
"takumi",
|
||||||
|
"haruto",
|
||||||
|
"minho",
|
||||||
|
"ji-hoon",
|
||||||
|
"wei",
|
||||||
|
"liang",
|
||||||
|
"mikhail",
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEMALE_NAMES = [
|
||||||
|
"female", // "Google UK English Female"
|
||||||
|
"samantha",
|
||||||
|
"karen",
|
||||||
|
"moira",
|
||||||
|
"tessa",
|
||||||
|
"victoria",
|
||||||
|
"allison",
|
||||||
|
"ava",
|
||||||
|
"susan",
|
||||||
|
"zira",
|
||||||
|
"siri",
|
||||||
|
"zuzana",
|
||||||
|
"iveta",
|
||||||
|
"eliska",
|
||||||
|
"tana",
|
||||||
|
"katerina",
|
||||||
|
"monika",
|
||||||
|
"lucie",
|
||||||
|
"pavla",
|
||||||
|
"anna",
|
||||||
|
"maria",
|
||||||
|
"laura",
|
||||||
|
"julia",
|
||||||
|
"carmen",
|
||||||
|
"paulina",
|
||||||
|
"zofia",
|
||||||
|
"elsa",
|
||||||
|
"katja",
|
||||||
|
"ingrid",
|
||||||
|
"freya",
|
||||||
|
"yelena",
|
||||||
|
"svetlana",
|
||||||
|
"olga",
|
||||||
|
"yuki",
|
||||||
|
"sakura",
|
||||||
|
"mei",
|
||||||
|
"ling",
|
||||||
|
"xiaoxiao",
|
||||||
|
];
|
||||||
|
|
||||||
|
type StoredVoices = Record<string, string>;
|
||||||
|
|
||||||
|
let stored: StoredVoices | null = null;
|
||||||
|
|
||||||
|
function loadStored(): StoredVoices {
|
||||||
|
if (stored) return stored;
|
||||||
|
stored = {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
for (const [locale, uri] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
|
if (typeof uri === "string") stored[locale] = uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const languageOf = (voice: SpeechSynthesisVoice) => voice.lang.toLowerCase().replace("_", "-");
|
||||||
|
|
||||||
|
/** Voices usable for `locale` (exact region first, then the language). */
|
||||||
|
export function voicesFor(locale: string, voices?: SpeechSynthesisVoice[]): SpeechSynthesisVoice[] {
|
||||||
|
const all = voices ?? platformVoices();
|
||||||
|
if (all.length === 0) return [];
|
||||||
|
const wanted = locale.toLowerCase().replace("_", "-");
|
||||||
|
const prefix = wanted.split("-")[0];
|
||||||
|
const matching = all.filter((voice) => {
|
||||||
|
const lang = languageOf(voice);
|
||||||
|
return lang === wanted || lang.startsWith(`${prefix}-`) || lang === prefix;
|
||||||
|
});
|
||||||
|
/* Some platforms tag voices oddly (e.g. "en-GB" for a US voice): never show
|
||||||
|
* an empty list when voices exist at all. */
|
||||||
|
return matching.length > 0 ? matching : all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The platform's voice list (empty offline/unsupported). */
|
||||||
|
export function platformVoices(): SpeechSynthesisVoice[] {
|
||||||
|
try {
|
||||||
|
if (typeof window === "undefined" || !("speechSynthesis" in window)) return [];
|
||||||
|
return window.speechSynthesis.getVoices();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A larger score wins. `male` tilts the ranking towards male names. */
|
||||||
|
export function scoreVoice(voice: SpeechSynthesisVoice, locale: string, male: boolean): number {
|
||||||
|
const name = voice.name.toLowerCase();
|
||||||
|
const lang = languageOf(voice);
|
||||||
|
const wanted = locale.toLowerCase().replace("_", "-");
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (lang === wanted) score += 6;
|
||||||
|
else if (lang.split("-")[0] === wanted.split("-")[0]) score += 3;
|
||||||
|
|
||||||
|
if (QUALITY_HINTS.some((hint) => name.includes(hint))) score += 10;
|
||||||
|
if (ROBOTIC_HINTS.some((hint) => name.includes(hint))) score -= 12;
|
||||||
|
|
||||||
|
if (MALE_NAMES.some((entry) => name.includes(entry))) score += male ? 8 : 1;
|
||||||
|
if (FEMALE_NAMES.some((entry) => name.includes(entry))) score += male ? 1 : 4;
|
||||||
|
|
||||||
|
/* Network voices are usually the neural ones; local ones are the fallback. */
|
||||||
|
if (!voice.localService) score += 3;
|
||||||
|
if (voice.default) score += 1;
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The best voice for the episode: the listener's stored pick, else the best
|
||||||
|
* male, else the best of whatever there is. */
|
||||||
|
export function bestVoice(locale: string, voices?: SpeechSynthesisVoice[]): SpeechSynthesisVoice | null {
|
||||||
|
const list = voicesFor(locale, voices);
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
const chosen = getStoredVoice(locale);
|
||||||
|
if (chosen) {
|
||||||
|
const match = list.find((voice) => voice.voiceURI === chosen);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
const ranked = [...list].sort((a, b) => scoreVoice(b, locale, true) - scoreVoice(a, locale, true));
|
||||||
|
return ranked[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredVoice(locale: string): string | null {
|
||||||
|
return loadStored()[locale.toLowerCase().replace("_", "-")] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredVoice(locale: string, voiceURI: string): void {
|
||||||
|
const key = locale.toLowerCase().replace("_", "-");
|
||||||
|
const next = { ...loadStored(), [key]: voiceURI };
|
||||||
|
stored = next;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when nothing but the platform's robotic voices exist for `locale`. */
|
||||||
|
export function onlyRoboticVoices(locale: string, voices?: SpeechSynthesisVoice[]): boolean {
|
||||||
|
const list = voicesFor(locale, voices);
|
||||||
|
if (list.length === 0) return false;
|
||||||
|
return list.every((voice) => ROBOTIC_HINTS.some((hint) => voice.name.toLowerCase().includes(hint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MALE_VOICE_HINTS = MALE_NAMES;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.7.3",
|
"version": "0.7.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v95";
|
const CACHE_VERSION = "freezmo-v96";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,48 @@ const EPISODE_REPLY = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** An episode seeded straight into storage, for the voice/scroll sections. */
|
||||||
|
const EPISODE_RECORD = {
|
||||||
|
id: "pod_local",
|
||||||
|
title: "The sky, out loud",
|
||||||
|
summary: "Rayleigh scattering.",
|
||||||
|
language: "English",
|
||||||
|
sourceText: "Rayleigh scattering.",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
sections: [
|
||||||
|
{ heading: "Daylight", text: "Look up on a clear day. The sky is blue. Every direction you look, the same blue." },
|
||||||
|
{ heading: "Why", text: "Tiny molecules scatter short wavelengths far more than long ones, so blue reaches your eyes from everywhere at once." },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Long enough to scroll: eight paragraphs of a few lines each. */
|
||||||
|
const LONG_RECORD = {
|
||||||
|
...EPISODE_RECORD,
|
||||||
|
id: "pod_long",
|
||||||
|
title: "A long walk through the atmosphere",
|
||||||
|
sections: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
heading: `Part ${index + 1}`,
|
||||||
|
text: `Paragraph ${index + 1}. ${"The atmosphere scatters light in every direction, and the shorter wavelengths bounce far more often than the longer ones. ".repeat(3)}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A speech engine that never reports boundaries: the timer clock drives it. */
|
||||||
|
const WORD_TIMER_SPEECH = `
|
||||||
|
window.__utterances = [];
|
||||||
|
class FakeUtterance {
|
||||||
|
constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.onstart = null; this.onend = null; this.onerror = null; this.onboundary = null; }
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
|
||||||
|
Object.defineProperty(window, "speechSynthesis", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
speak(utterance) { window.__utterances.push(utterance); queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); },
|
||||||
|
cancel() {}, getVoices() { return []; }, addEventListener() {}, removeEventListener() {}, speaking: false, paused: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
const stub = http.createServer((request, response) => {
|
const stub = http.createServer((request, response) => {
|
||||||
if (request.url?.startsWith("/api/tags")) {
|
if (request.url?.startsWith("/api/tags")) {
|
||||||
response.writeHead(200, { "Content-Type": "application/json" });
|
response.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
|
@ -220,7 +262,119 @@ try {
|
||||||
);
|
);
|
||||||
await context.close();
|
await context.close();
|
||||||
|
|
||||||
/* 5. server guards ---------------------------------------------- */
|
/* 4b. voices: the best installed one, and a picker that sticks ------ */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||||
|
/* A catalogue that looks like a bare Linux box next to Chrome: the robot
|
||||||
|
must lose to the natural male voice. */
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__spoken = [];
|
||||||
|
const voices = [
|
||||||
|
{ name: "eSpeak English", lang: "en-US", voiceURI: "espeak", localService: true, default: true },
|
||||||
|
{ name: "Google US English", lang: "en-US", voiceURI: "g-us", localService: false, default: false },
|
||||||
|
{ name: "Google UK English Male", lang: "en-GB", voiceURI: "g-uk-male", localService: false, default: false },
|
||||||
|
{ name: "Czech (Czech Republic)", lang: "cs-CZ", voiceURI: "cs-cz", localService: true, default: false },
|
||||||
|
];
|
||||||
|
class FakeUtterance {
|
||||||
|
constructor(text) {
|
||||||
|
this.text = String(text);
|
||||||
|
this.rate = 1;
|
||||||
|
this.lang = "en-US";
|
||||||
|
this.voice = null;
|
||||||
|
this.onstart = null;
|
||||||
|
this.onend = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.onboundary = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
|
||||||
|
Object.defineProperty(window, "speechSynthesis", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
speak(utterance) {
|
||||||
|
window.__spoken.push({ text: utterance.text, voice: utterance.voice?.voiceURI ?? null });
|
||||||
|
queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 }));
|
||||||
|
},
|
||||||
|
cancel() {},
|
||||||
|
getVoices: () => voices,
|
||||||
|
addEventListener() {},
|
||||||
|
removeEventListener() {},
|
||||||
|
speaking: false,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } });
|
||||||
|
await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.getByRole("button", { name: "Play the episode" }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const firstVoice = await page.evaluate(() => window.__spoken.at(-1)?.voice);
|
||||||
|
check("voice: the natural male voice beats the robot", firstVoice === "g-uk-male", String(firstVoice));
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Voice" }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const listed = await page.evaluate(() =>
|
||||||
|
[...document.querySelectorAll("[aria-pressed]")].map((el) => el.textContent?.trim() ?? ""),
|
||||||
|
);
|
||||||
|
check("voice: the picker lists the voices for the language", listed.some((entry) => entry.includes("Google UK English Male")), JSON.stringify(listed.slice(0, 4)));
|
||||||
|
check("voice: other languages are filtered out", !listed.some((entry) => entry.includes("Czech")), JSON.stringify(listed));
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /Google US English/ }).click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const chosen = await page.evaluate(() => window.__spoken.at(-1)?.voice);
|
||||||
|
check("voice: choosing one speaks with it", chosen === "g-us", String(chosen));
|
||||||
|
const stored = await page.evaluate(() => localStorage.getItem("freezmo.podcast-voice.v1"));
|
||||||
|
check("voice: the choice is remembered", String(stored).includes("g-us"), String(stored));
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4c. the transcript follows by line, and yields to a manual scroll --- */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 390, height: 700, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||||
|
await page.addInitScript(WORD_TIMER_SPEECH);
|
||||||
|
await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([LONG_RECORD]) } });
|
||||||
|
await page.goto(`${APP}/podcast/pod_long`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
await page.getByRole("button", { name: "Play the episode" }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
const scrollTop = () => page.evaluate(() => document.querySelector("[data-testid=podcast-transcript]")?.scrollTop ?? -1);
|
||||||
|
const boundary = (charIndex) =>
|
||||||
|
page.evaluate((index) => {
|
||||||
|
const utterance = window.__utterances.at(-1);
|
||||||
|
utterance.onboundary({ name: "word", charIndex: index });
|
||||||
|
}, charIndex);
|
||||||
|
|
||||||
|
/* Three words into the same line: the page must not move. */
|
||||||
|
const start = await scrollTop();
|
||||||
|
await boundary(3);
|
||||||
|
await boundary(6);
|
||||||
|
await boundary(9);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
check("scroll: words on the same line do not move the page", (await scrollTop()) === start, `${start} -> ${await scrollTop()}`);
|
||||||
|
|
||||||
|
/* A word far into the script: the page follows it. */
|
||||||
|
await boundary(900);
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const followed = await scrollTop();
|
||||||
|
check("scroll: a new line is followed", followed > start, `${start} -> ${followed}`);
|
||||||
|
|
||||||
|
/* The reader scrolls on purpose: following pauses instead of fighting. */
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const container = document.querySelector("[data-testid=podcast-transcript]");
|
||||||
|
container.scrollTop = 0;
|
||||||
|
container.dispatchEvent(new WheelEvent("wheel", { bubbles: true }));
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const manual = await scrollTop();
|
||||||
|
await boundary(1400);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
check("scroll: a manual scroll is left alone", (await scrollTop()) === manual, `${manual} -> ${await scrollTop()}`);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. server guards ---------------------------------------------- */
|
||||||
const bad = await fetch(`${APP}/api/podcast`, {
|
const bad = await fetch(`${APP}/api/podcast`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue