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

@ -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,9 +547,11 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
);
})}
<p className="px-1 pt-1 text-[11.5px] leading-relaxed text-[var(--text-subtle)]">
{roboticOnly
? t("podcast.voiceRobotic")
: t("podcast.voiceNote")}
{iosLimited
? t("podcast.voiceIos")
: roboticOnly
? t("podcast.voiceRobotic")
: t("podcast.voiceNote")}
</p>
</div>
</div>