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.
This commit is contained in:
Martin Dvořák 2026-10-16 13:51:12 +00:00
commit 9882d775e7
7 changed files with 83 additions and 7 deletions

View file

@ -1525,7 +1525,13 @@ Things I chose while building, with the reasoning:
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).
persisting a pick, same-line silence, line following, manual-scroll suspension). **Follow-up
2 (0.7.5):** iOS is the special case the picker cannot fix — Safari hands web pages one voice
per language, so on an iPhone the panel now carries the settings path instead (Accessibility →
Spoken Content → Voices → language → Enhanced/Premium, set as preferred) and the speaker
button wears an amber dot while that note is waiting; the voice-less banner appends the same
guidance. Detected via the user agent (iPadOS included through `maxTouchPoints`); the suite
fakes an iPhone and pins the note, its steps and the dot (37 checks).
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

View file

@ -13,6 +13,7 @@ import { savePodcastProgress } from "@/lib/podcasts";
import {
bestVoice,
getStoredVoice,
isAppleMobile,
onlyRoboticVoices,
platformVoices,
setStoredVoice,
@ -103,6 +104,12 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
() => 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);
@ -376,13 +383,19 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
aria-expanded={voiceOpen}
title={voice?.name ?? t("podcast.voiceAria")}
className={cn(
"grid size-10 shrink-0 place-items-center rounded-full transition",
"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
@ -416,6 +429,7 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
{!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}
@ -533,7 +547,9 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
);
})}
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{roboticOnly
{iosLimited
? t("podcast.voiceIos")
: roboticOnly
? t("podcast.voiceRobotic")
: t("podcast.voiceNote")}
</p>

View file

@ -306,6 +306,7 @@ const en: Record<string, string> = {
"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.voiceIos": "On iPhone, Safari offers web pages a single voice per language — the system's preferred one. For more (and better) voices: Settings → Accessibility → Spoken Content → Voices → your language, download a voice in Enhanced or Premium quality and make it the preferred one. Freezmo then reads with it.",
"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.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
@ -1378,6 +1379,7 @@ const cs: Record<string, string> = {
"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.voiceIos": "Na iPhonu nabízí Safari webovým stránkám jen jeden hlas na jazyk — ten preferovaný v systému. Pro další (a lepší) hlasy: Nastavení → Zpřístupnění → Mluvený obsah → Hlasy → tvůj jazyk, stáhni si hlas ve verzi Vylepšený nebo Prémiový a nastav ho jako preferovaný. Freezmo pak čte s ním.",
"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.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",

View file

@ -229,3 +229,16 @@ export function onlyRoboticVoices(locale: string, voices?: SpeechSynthesisVoice[
}
export const MALE_VOICE_HINTS = MALE_NAMES;
/**
* iPhone/iPad (all iOS browsers are WebKit): Safari hands web pages a single
* voice per language — the system's preferred one — no matter how many are
* installed, so the picker needs to say where to get a better one.
*/
export function isAppleMobile(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
if (/iPhone|iPod/.test(ua)) return true;
/* iPadOS 13+ reports as a Mac; the touch points give it away. */
return /iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
}

View file

@ -1,6 +1,6 @@
{
"name": "freezmo",
"version": "0.7.4",
"version": "0.7.5",
"private": true,
"license": "GPL-3.0-only",
"scripts": {

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v96";
const CACHE_VERSION = "freezmo-v97";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -329,6 +329,45 @@ try {
await context.close();
}
/* 4b2. an iPhone gets told where the other voices live -------------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
/* Safari's shape: one voice for the language, whatever is installed. */
await page.addInitScript(() => {
Object.defineProperty(navigator, "userAgent", {
configurable: true,
get: () => "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1",
});
window.__spoken = [];
const voices = [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }];
class FakeUtterance {
constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; this.voice = null; }
}
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
Object.defineProperty(window, "speechSynthesis", {
configurable: true,
value: { speak(u) { queueMicrotask(() => u.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);
const voiceButton = page.getByRole("button", { name: "Voice" });
const badge = await voiceButton.evaluate((element) => Boolean(element.querySelector("span[aria-hidden]")));
check("ios: the voice button carries a hint dot", badge);
await voiceButton.click();
await page.waitForTimeout(300);
const note = await page.getByText(/Spoken Content/).count();
check("ios: the picker explains where to add voices", note === 1, String(note));
const steps = await page.evaluate(() => {
const text = [...document.querySelectorAll("p")].map((el) => el.textContent ?? "").find((entry) => entry.includes("Spoken Content")) ?? "";
return { settings: text.includes("Settings"), accessibility: text.includes("Accessibility"), premium: text.includes("Enhanced") || text.includes("Premium") };
});
check("ios: the note names the actual settings path", steps.settings && steps.accessibility && steps.premium, JSON.stringify(steps));
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 });