Podcast player: say why it cannot speak, with a way out

"The podcasts don't even work on my Linux setup on Firefox." Firefox on
Linux speaks through speech-dispatcher; without it speechSynthesis is present
but empty and silent - getVoices() is empty, speak() does nothing, no events
- so the player looked broken.

- pressing play with zero installed voices (or a start that never arrives
  within 1.7s) raises a banner naming the cause, with the Linux fix
  (sudo apt install speech-dispatcher) and the iOS one where it applies;
- the banner offers "Use the natural voice" when a provider key exists (it
  switches the source, keeps playing and remembers it), "Add a provider key"
  when not (opens Settings) and a link into the picker;
- natural-voice errors (refused autoplay, provider refusal) show in that same
  banner instead of hiding inside the picker.

podcast-test fakes a present-but-empty speechSynthesis and pins the banner,
the Linux line and the one-tap switch. 48 checks. Version 0.7.8, SW v100.
This commit is contained in:
Martin Dvořák 2026-10-16 18:32:36 +00:00
commit 08a145b087
6 changed files with 147 additions and 8 deletions

View file

@ -3,6 +3,7 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui";
import { useI18n } from "@/lib/i18n";
import { createPodcastAudio, type PodcastAudio } from "@/lib/podcast-audio";
import {
@ -32,6 +33,7 @@ import {
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
import type { Podcast } from "@/lib/schemas";
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { openSettings } from "@/lib/ui-events";
import {
CheckCircleIcon,
ChevronLeftIcon,
@ -63,6 +65,9 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
const [playing, setPlaying] = useState(false);
const [position, setPosition] = useState<PodcastVoice | null>(null);
const [unsupported, setUnsupported] = useState(false);
/** The system voice never started (no engine installed) — Firefox/Linux,
* a bare Windows install: the banner explains and offers the way out. */
const [voiceSilent, setVoiceSilent] = useState(false);
const [voiceOpen, setVoiceOpen] = useState(false);
/** "system" = the platform's voices; "natural" = the provider's TTS. */
const [voiceSource, setVoiceSource] = useState<"system" | "natural">(() => readNaturalPref().source);
@ -430,13 +435,28 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
persist();
return;
}
if (!naturalActive && availableVoices.length === 0) {
/* Nothing to speak with: say so instead of doing nothing. */
setVoiceSilent(true);
return;
}
/* Other readers (a card's read-aloud) yield to the episode. */
stopSpeaking();
harness()?.play(position ?? { section: 0, word: 0 });
/* The system engine can also be *present* and still do nothing (no voice
data behind it). If nothing starts, the banner takes over. */
if (!naturalActive) {
const attempt = Date.now();
window.setTimeout(() => {
if (Date.now() - attempt < 1500) return;
if (!playingRef.current) setVoiceSilent(true);
}, 1700);
}
};
const seek = (next: PodcastVoice) => {
if (!canPlay) return;
setVoiceSilent(false);
latest.current = next;
setPosition(next);
/* A seek is an instruction to show that place: follow it at once. */
@ -568,13 +588,59 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
className="no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-40 pt-24"
>
<div className="mx-auto w-full max-w-3xl space-y-6">
{(!supported || unsupported) && !naturalActive ? (
<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")}` : ""}
{naturalError ? (
<p
role="alert"
className="rounded-2xl border border-rose-300/50 bg-rose-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-rose-800 dark:border-rose-400/25 dark:bg-rose-400/10 dark:text-rose-200"
>
{naturalError}
</p>
) : null}
{(!naturalActive && (!supported || unsupported || voiceSilent)) || (naturalActive && !natural) ? (
<div className="space-y-2.5 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">
<p>
{naturalActive && !natural
? t("podcast.voiceNeedsKey")
: `${
voiceSilent || unsupported
? t("podcast.voiceSilent")
: t("podcast.noVoice")
}${isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}`}
</p>
<div className="flex flex-wrap items-center gap-2">
{ttsProviderIds.length > 0 ? (
<Button
size="sm"
onClick={() => {
setVoiceSilent(false);
setUnsupported(false);
setNaturalError(null);
setVoiceSource("natural");
writeNaturalPref({ source: "natural" });
}}
>
{t("podcast.voiceUseNatural")}
</Button>
) : (
<Button size="sm" onClick={() => openSettings()}>
{t("podcast.voiceOpenProviders")}
</Button>
)}
<button
type="button"
onClick={() => {
setVoiceSilent(false);
setVoiceOpen(true);
}}
className="text-[12.5px] font-semibold underline decoration-dotted"
>
{t("podcast.voiceAria")}
</button>
</div>
</div>
) : null}
<div className="card space-y-2 p-5">
<h1 className="text-[22px] font-extrabold leading-tight tracking-[-0.01em] text-[var(--text)]">
{episode.title}