From 08a145b0872970b0840bb32ee8006bf7bdfe4ce7 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Fri, 16 Oct 2026 18:32:36 +0000 Subject: [PATCH] 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. --- README.md | 14 ++++++ components/podcast/PodcastPlayer.tsx | 74 ++++++++++++++++++++++++++-- lib/i18n-dict.ts | 6 +++ package.json | 2 +- public/sw.js | 2 +- tests/podcast-test.mjs | 57 ++++++++++++++++++++- 6 files changed, 147 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 533c87a..e76b92b 100644 --- a/README.md +++ b/README.md @@ -1492,6 +1492,20 @@ Things I chose while building, with the reasoning: into view as you type. No strings, so no i18n; the additive change sits on top of the merged scene-heat rework unchanged. +106. **When the browser has no speech engine at all.** "The podcasts don't even work on my Linux + setup on Firefox": Firefox on Linux speaks through speech-dispatcher, and without it + `speechSynthesis` is *present* (so `speechAvailable()` says yes) but empty and silent — + `getVoices()` returns nothing and `speak()` does nothing, with no events. The player used to + sit there looking broken. Now the failure is named and has a way out: pressing play with zero + installed voices (or a start that never arrives within 1.7 s) raises a banner that says what is + missing — *"On Firefox/Linux install speech-dispatcher (sudo apt install speech-dispatcher) and + reopen this page"* — with **Use the natural voice** right there when a provider key exists (it + switches the source, starts playing and remembers the choice), **Add a provider key** when not + (it opens Settings), and a link into the voice picker. Natural-voice errors (a refused + autoplay, a provider refusal) are surfaced in the same place instead of hiding inside the + picker. The suite fakes a present-but-empty `speechSynthesis` and pins the banner, the Linux + line and the one-tap switch (48 checks). + 105. **The play button that only worked once.** "It only plays once, and when you pause it you can't unpause." WebKit's quirk, faithfully: `speechSynthesis.cancel()` leaves the synthesis **paused**, and every later `speak()` is queued into that paused state — silently. Three fixes in the diff --git a/components/podcast/PodcastPlayer.tsx b/components/podcast/PodcastPlayer.tsx index f165b71..e2fe922 100644 --- a/components/podcast/PodcastPlayer.tsx +++ b/components/podcast/PodcastPlayer.tsx @@ -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(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" >
- {(!supported || unsupported) && !naturalActive ? ( -

- {t("podcast.noVoice")} - {isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""} + {naturalError ? ( +

+ {naturalError}

) : null} + {(!naturalActive && (!supported || unsupported || voiceSilent)) || (naturalActive && !natural) ? ( +
+

+ {naturalActive && !natural + ? t("podcast.voiceNeedsKey") + : `${ + voiceSilent || unsupported + ? t("podcast.voiceSilent") + : t("podcast.noVoice") + }${isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}`} +

+
+ {ttsProviderIds.length > 0 ? ( + + ) : ( + + )} + +
+
+ ) : null} +

{episode.title} diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 2d66e96..85a4e73 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -314,6 +314,9 @@ const en: Record = { "podcast.voiceNaturalNote": "Generated by your own provider key — the words are timed to the audio, so the highlight still follows the voice.", "podcast.voiceNeedsKey": "Add an OpenAI or Groq key in Settings → Providers to use natural voices.", "podcast.voiceNoSystem": "This device reports no system voices. The natural voice works anywhere — or add a voice in your system settings.", + "podcast.voiceSilent": "The system voice did not start — this browser has no speech engine behind it. On Firefox/Linux install speech-dispatcher (sudo apt install speech-dispatcher) and reopen this page.", + "podcast.voiceUseNatural": "Use the natural voice", + "podcast.voiceOpenProviders": "Add a provider key", "podcast.voiceGenerating": "Generating the voice…", "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.", @@ -1401,6 +1404,9 @@ const cs: Record = { "podcast.voiceNaturalNote": "Generuje ho klíč tvého providera — slova jsou časovaná podle audia, takže zvýraznění drží s hlasem.", "podcast.voiceNeedsKey": "Pro přirozené hlasy si v Nastavení → Provideři přidej klíč OpenAI nebo Groq.", "podcast.voiceNoSystem": "Tohle zařízení nehlásí žádné systémové hlasy. Přirozený hlas funguje všude — nebo si hlas přidej v nastavení systému.", + "podcast.voiceSilent": "Systémový hlas se nespustil — prohlížeč za ním nemá žádný hlasový engine. Na Firefoxu/Linuxu doinstaluj speech-dispatcher (sudo apt install speech-dispatcher) a stránku otevři znovu.", + "podcast.voiceUseNatural": "Použít přirozený hlas", + "podcast.voiceOpenProviders": "Přidat klíč providera", "podcast.voiceGenerating": "Generuji hlas…", "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.", diff --git a/package.json b/package.json index aefc80b..bb2bde2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freezmo", - "version": "0.7.7", + "version": "0.7.8", "private": true, "license": "GPL-3.0-only", "scripts": { diff --git a/public/sw.js b/public/sw.js index cd0788b..f46b661 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v99"; +const CACHE_VERSION = "freezmo-v100"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/podcast-test.mjs b/tests/podcast-test.mjs index e32d248..69f79bf 100644 --- a/tests/podcast-test.mjs +++ b/tests/podcast-test.mjs @@ -73,7 +73,8 @@ 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, + cancel() {}, getVoices() { return [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; }, + addEventListener() {}, removeEventListener() {}, speaking: false, paused: false, }, }); `; @@ -160,7 +161,8 @@ Object.defineProperty(window, "speechSynthesis", { }, cancel() { window.__cancelCount += 1; this._speaking = false; this._paused = true; }, resume() { this._paused = false; }, - getVoices() { return []; }, + /* One installed voice: a working engine, which is what this stub is. */ + getVoices() { return [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; }, }, }); `; @@ -383,6 +385,57 @@ try { await context.close(); } +/* 4b2b. no speech engine behind the browser (Firefox on Linux) ------- */ +{ + const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true }); + /* speechSynthesis exists but is empty and silent — exactly what Firefox + does on a Linux box without speech-dispatcher. */ + await page.addInitScript(() => { + class FakeUtterance { + constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; } + } + Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance }); + Object.defineProperty(window, "speechSynthesis", { + configurable: true, + value: { + speak() { /* nothing at all: no engine, no events */ }, + cancel() {}, + getVoices: () => [], + addEventListener() {}, + removeEventListener() {}, + speaking: false, + paused: false, + }, + }); + localStorage.setItem( + "freezmo.providers.v1", + JSON.stringify({ keys: { openai: "sk-test" }, models: {}, customBaseUrl: "", generation: null }), + ); + }); + 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 banner = await page.getByText(/speech engine behind it/).count(); + check("no engine: the banner explains what is missing", banner === 1, String(banner)); + check( + "no engine: it names the Linux fix", + (await page.getByText(/speech-dispatcher/).count()) === 1, + ); + const toNatural = page.getByRole("button", { name: "Use the natural voice" }); + check("no engine: the one-tap way out is offered", (await toNatural.count()) === 1); + await toNatural.click(); + await page.waitForTimeout(400); + check( + "no engine: the switch sticks", + (await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1")))?.includes("natural"), + String(await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1"))), + ); + await context.close(); +} + /* 4b3. natural voices: the provider TTS behind the same player ------- */ { const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });