From a51fbd25d7872392df2d12e9fa8184564886bbb0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 21:41:07 +0200 Subject: [PATCH 01/37] feat(tts): add Web Speech API hook --- src/hooks/useTextToSpeech.ts | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/hooks/useTextToSpeech.ts diff --git a/src/hooks/useTextToSpeech.ts b/src/hooks/useTextToSpeech.ts new file mode 100644 index 00000000..eaddd87b --- /dev/null +++ b/src/hooks/useTextToSpeech.ts @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +export interface UseTTSOptions { + defaultLang?: string + defaultRate?: number + defaultPitch?: number + defaultVolume?: number +} + +export interface UseTTS { + supported: boolean + speaking: boolean + paused: boolean + voices: SpeechSynthesisVoice[] + voice: SpeechSynthesisVoice | null + rate: number + pitch: number + volume: number + setVoice: (v: SpeechSynthesisVoice | null) => void + setRate: (r: number) => void + setPitch: (p: number) => void + setVolume: (v: number) => void + speak: (text: string, langOverride?: string) => void + pause: () => void + resume: () => void + stop: () => void +} + +export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS { + const synth = typeof window !== 'undefined' ? window.speechSynthesis : undefined + const supported = !!synth + const [voices, setVoices] = useState([]) + const [voice, setVoice] = useState(null) + const [speaking, setSpeaking] = useState(false) + const [paused, setPaused] = useState(false) + const [rate, setRate] = useState(options.defaultRate ?? 1) + const [pitch, setPitch] = useState(options.defaultPitch ?? 1) + const [volume, setVolume] = useState(options.defaultVolume ?? 1) + const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en') + + const utteranceRef = useRef(null) + + // Load voices (async in many browsers) + useEffect(() => { + if (!supported) return + const load = () => { + const v = synth!.getVoices() + setVoices(v) + if (!voice && v.length) { + // pick best match by language first, then default + const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase())) + setVoice(byLang || v[0] || null) + } + } + load() + // Safari/Chrome fire 'voiceschanged' + synth!.addEventListener?.('voiceschanged', load as EventListener) + return () => { + synth!.removeEventListener?.('voiceschanged', load as EventListener) + } + }, [supported, defaultLang, voice, synth]) + + const stop = useCallback(() => { + if (!supported) return + synth!.cancel() + setSpeaking(false) + setPaused(false) + utteranceRef.current = null + }, [supported, synth]) + + const speak = useCallback((text: string, langOverride?: string) => { + if (!supported || !text?.trim()) return + // stopping any current speech first is safer for iOS + synth!.cancel() + const u = new SpeechSynthesisUtterance(text) + u.lang = langOverride || voice?.lang || defaultLang + if (voice) u.voice = voice + u.rate = rate + u.pitch = pitch + u.volume = volume + + u.onstart = () => { setSpeaking(true); setPaused(false) } + u.onpause = () => setPaused(true) + u.onresume = () => setPaused(false) + u.onend = () => { setSpeaking(false); setPaused(false); utteranceRef.current = null } + u.onerror = () => { setSpeaking(false); setPaused(false); utteranceRef.current = null } + + utteranceRef.current = u + synth!.speak(u) + }, [supported, synth, voice, rate, pitch, volume, defaultLang]) + + const pause = useCallback(() => { + if (!supported) return + if (synth!.speaking && !synth!.paused) { + synth!.pause() + setPaused(true) + } + }, [supported, synth]) + + const resume = useCallback(() => { + if (!supported) return + if (synth!.speaking && synth!.paused) { + synth!.resume() + setPaused(false) + } + }, [supported, synth]) + + // stop TTS when unmounting + useEffect(() => stop, [stop]) + + return useMemo(() => ({ + supported, + speaking, + paused, + voices, + voice, + rate, setRate, + pitch, setPitch, + volume, setVolume, + setVoice, + speak, pause, resume, stop + }), [supported, speaking, paused, voices, voice, rate, pitch, volume, setVoice, speak, pause, resume, stop]) +} + From 34f44c59b57fc733df38276ba7217464b01545aa Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 21:41:19 +0200 Subject: [PATCH 02/37] feat(tts): add TTSControls component with play/pause/stop and rate --- src/components/TTSControls.tsx | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/components/TTSControls.tsx diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx new file mode 100644 index 00000000..99c677b2 --- /dev/null +++ b/src/components/TTSControls.tsx @@ -0,0 +1,77 @@ +import React from 'react' +import { useTextToSpeech } from '../hooks/useTextToSpeech' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlay, faPause, faStop } from '@fortawesome/free-solid-svg-icons' + +interface Props { + text: string + defaultLang?: string + className?: string +} + +const TTSControls: React.FC = ({ text, defaultLang, className }) => { + const { + supported, speaking, paused, + speak, pause, resume, stop, + rate, setRate + } = useTextToSpeech({ defaultLang }) + + const canPlay = supported && text?.trim().length > 0 + + const handlePlayPause = () => { + if (!canPlay) return + if (!speaking) { + speak(text, defaultLang) + } else if (paused) { + resume() + } else { + pause() + } + } + + const playLabel = !speaking ? 'Listen' : (paused ? 'Resume' : 'Pause') + + if (!supported) return null + + return ( +
+ + + +
+ ) +} + +export default TTSControls + From b3206d5e79d49e7407046547931b4fd32dcc373f Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 21:41:31 +0200 Subject: [PATCH 03/37] feat(reader): integrate TTS controls in ContentPanel --- src/components/ContentPanel.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index c5e4592e..5fd0517f 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -46,6 +46,7 @@ import { loadReadingPosition, saveReadingPosition } from '../services/readingPositionService' +import TTSControls from './TTSControls' interface ContentPanelProps { loading: boolean @@ -321,6 +322,25 @@ const ContentPanel: React.FC = ({ const hasHighlights = relevantHighlights.length > 0 + // Extract plain text for TTS + const baseHtml = useMemo(() => { + if (markdown) return renderedMarkdownHtml && finalHtml ? finalHtml : '' + return finalHtml || html || '' + }, [markdown, renderedMarkdownHtml, finalHtml, html]) + + const articleText = useMemo(() => { + const parts: string[] = [] + if (title) parts.push(title) + if (summary) parts.push(summary) + if (baseHtml) { + const div = document.createElement('div') + div.innerHTML = baseHtml + const txt = (div.textContent || '').replace(/\s+/g, ' ').trim() + if (txt) parts.push(txt) + } + return parts.join('. ') + }, [title, summary, baseHtml]) + // Determine if we're on a nostr-native article (/a/) or external URL (/r/) const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:') const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type) @@ -759,6 +779,11 @@ const ContentPanel: React.FC = ({ highlights={relevantHighlights} highlightVisibility={highlightVisibility} /> + {isTextContent && articleText && ( +
+ +
+ )} {isExternalVideo ? ( <>
From 31987010b8eed5581bedfc1892f05bb5b42ef649 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 21:42:02 +0200 Subject: [PATCH 04/37] docs(tts): add TTS feature to FEATURES.md --- FEATURES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/FEATURES.md b/FEATURES.md index 4a2a1f2c..159de73c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -11,6 +11,7 @@ - **Distraction‑free view**: Clean typography, optional hero image, summary, and published date. - **Reading time**: Displays estimated reading time for text or duration for supported videos. - **Progress**: Reading progress indicator with completion state. +- **Text‑to‑Speech**: Listen to articles with browser‑native TTS; play/pause/stop controls with adjustable speed (0.8–1.6x). - **Menus**: Quick actions to open, share, or copy links (for both Nostr and web content). - **Performance**: Lightweight fetching and caching for speed; skeleton loaders to avoid empty flashes. From c623dc8d849d91eca4e24ae7429675370f3ba264 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 21:59:31 +0200 Subject: [PATCH 05/37] style(tts): reduce button and text sizes for compact layout --- src/components/TTSControls.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index 99c677b2..49cde1e7 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -41,9 +41,10 @@ const TTSControls: React.FC = ({ text, defaultLang, className }) => { onClick={handlePlayPause} title={playLabel} disabled={!canPlay} + style={{ padding: '0.25rem 0.5rem', fontSize: '0.85rem' }} > - {playLabel} + {playLabel} -
) } From cc1b9f042f6c72f5f12afe07d5b8400f1761c651 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:01:13 +0200 Subject: [PATCH 09/37] feat(tts): extend speed range to 3x with 2.1x default --- src/components/TTSControls.tsx | 2 +- src/hooks/useTextToSpeech.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index 2278eea7..821335a4 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -9,7 +9,7 @@ interface Props { className?: string } -const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6] +const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3] const TTSControls: React.FC = ({ text, defaultLang, className }) => { const { diff --git a/src/hooks/useTextToSpeech.ts b/src/hooks/useTextToSpeech.ts index eaddd87b..02434175 100644 --- a/src/hooks/useTextToSpeech.ts +++ b/src/hooks/useTextToSpeech.ts @@ -33,7 +33,7 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS { const [voice, setVoice] = useState(null) const [speaking, setSpeaking] = useState(false) const [paused, setPaused] = useState(false) - const [rate, setRate] = useState(options.defaultRate ?? 1) + const [rate, setRate] = useState(options.defaultRate ?? 2.1) const [pitch, setPitch] = useState(options.defaultPitch ?? 1) const [volume, setVolume] = useState(options.defaultVolume ?? 1) const defaultLang = options.defaultLang || (typeof navigator !== 'undefined' ? navigator.language : 'en') From c82fb657453a2a3cb0652a73ec8750842dfa4382 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:02:00 +0200 Subject: [PATCH 10/37] style(tts): remove Stop button, keep Play/Pause and Speed --- src/components/TTSControls.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index 821335a4..814ff6a2 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -1,7 +1,7 @@ import React from 'react' import { useTextToSpeech } from '../hooks/useTextToSpeech' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faPlay, faPause, faStop, faGauge } from '@fortawesome/free-solid-svg-icons' +import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons' interface Props { text: string @@ -53,16 +53,6 @@ const TTSControls: React.FC = ({ text, defaultLang, className }) => { {playLabel} - + ))} + + + + + ) +} + +export default TTSSettings From 177f8c1e70fc75682c076257bb30fd858e7fa7bb Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:05:01 +0200 Subject: [PATCH 14/37] feat(settings): integrate TTSSettings into settings page --- src/components/Settings.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index b6e3f594..ccbf8d65 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -12,6 +12,7 @@ import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings' import ZapSettings from './Settings/ZapSettings' import RelaySettings from './Settings/RelaySettings' import PWASettings from './Settings/PWASettings' +import TTSSettings from './Settings/TTSSettings' import { useRelayStatus } from '../hooks/useRelayStatus' import VersionFooter from './VersionFooter' @@ -45,6 +46,7 @@ const DEFAULT_SETTINGS: UserSettings = { syncReadingPosition: true, autoMarkAsReadOnCompletion: false, hideBookmarksWithoutCreationDate: true, + ttsDefaultSpeed: 2.1, } interface SettingsProps { @@ -177,6 +179,7 @@ const Settings: React.FC = ({ settings, onSave, onClose, relayPoo + From b92f5716dcc35e11a0bad8eca42eab022dda0713 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:05:04 +0200 Subject: [PATCH 15/37] feat(tts): use default speed from settings in TTSControls --- src/components/ContentPanel.tsx | 2 +- src/components/TTSControls.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 5fd0517f..26e33c7b 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -781,7 +781,7 @@ const ContentPanel: React.FC = ({ /> {isTextContent && articleText && (
- +
)} {isExternalVideo ? ( diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index 814ff6a2..d23e5f65 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -2,21 +2,23 @@ import React from 'react' import { useTextToSpeech } from '../hooks/useTextToSpeech' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons' +import { UserSettings } from '../services/settingsService' interface Props { text: string defaultLang?: string className?: string + settings?: UserSettings } const SPEED_OPTIONS = [0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.1, 2.4, 2.8, 3] -const TTSControls: React.FC = ({ text, defaultLang, className }) => { +const TTSControls: React.FC = ({ text, defaultLang, className, settings }) => { const { supported, speaking, paused, speak, pause, resume, stop, rate, setRate - } = useTextToSpeech({ defaultLang }) + } = useTextToSpeech({ defaultLang, defaultRate: settings?.ttsDefaultSpeed }) const canPlay = supported && text?.trim().length > 0 From b3f4b032298a37fdbfbd17a07ca4d3ad34637e94 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:05:21 +0200 Subject: [PATCH 16/37] style(tts): remove button labels, show icons only --- src/components/TTSControls.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index d23e5f65..44cc7969 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -53,7 +53,6 @@ const TTSControls: React.FC = ({ text, defaultLang, className, settings } disabled={!canPlay} > - {playLabel} - ))} - + From fdb52fe3b2b6727973bf3b6d76fda7a8bcc44e6b Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:07:31 +0200 Subject: [PATCH 19/37] style(tts-settings): use setting-buttons layout like Default Bookmark View --- src/components/Settings/TTSSettings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Settings/TTSSettings.tsx b/src/components/Settings/TTSSettings.tsx index b1d0f8dd..e9f80013 100644 --- a/src/components/Settings/TTSSettings.tsx +++ b/src/components/Settings/TTSSettings.tsx @@ -24,8 +24,8 @@ const TTSSettings: React.FC = ({ settings, onUpdate }) => {

Text-to-Speech

- -
+ +
+ +
+ +
+ +
+ +
) } From 94b9d89225f3601fc08e4948927aee9bbef9c460 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:53:14 +0200 Subject: [PATCH 32/37] feat(deps): add tinyld for client-side language detection --- package-lock.json | 21 +++++++++++++++++++-- package.json | 1 + 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 34f6e9e9..6a951921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "boris", - "version": "0.9.0", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "boris", - "version": "0.9.0", + "version": "0.9.1", "dependencies": { "@fortawesome/fontawesome-svg-core": "^7.1.0", "@fortawesome/free-regular-svg-icons": "^7.1.0", @@ -35,6 +35,7 @@ "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "tinyld": "^1.3.4", "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { @@ -11215,6 +11216,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyld": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/tinyld/-/tinyld-1.3.4.tgz", + "integrity": "sha512-u26CNoaInA4XpDU+8s/6Cq8xHc2T5M4fXB3ICfXPokUQoLzmPgSZU02TAkFwFMJCWTjk53gtkS8pETTreZwCqw==", + "license": "MIT", + "bin": { + "tinyld": "bin/tinyld.js", + "tinyld-heavy": "bin/tinyld-heavy.js", + "tinyld-light": "bin/tinyld-light.js" + }, + "engines": { + "node": ">= 12.10.0", + "npm": ">= 6.12.0", + "yarn": ">= 1.20.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index 4e3aab50..642ff4f6 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "rehype-prism-plus": "^2.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", + "tinyld": "^1.3.4", "use-pull-to-refresh": "^2.4.1" }, "devDependencies": { From 831f701c0477bfa47f72569b277f7554192c1fd1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:54:06 +0200 Subject: [PATCH 33/37] feat(tts): detect content language with tinyld and honor system lang toggle --- src/components/TTSControls.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index 9b638495..1b028820 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -1,8 +1,9 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useTextToSpeech } from '../hooks/useTextToSpeech' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faPlay, faPause, faGauge } from '@fortawesome/free-solid-svg-icons' import { UserSettings } from '../services/settingsService' +import { detect } from 'tinyld' interface Props { text: string @@ -22,10 +23,30 @@ const TTSControls: React.FC = ({ text, defaultLang, className, settings } const canPlay = supported && text?.trim().length > 0 + const resolvedSystemLang = useMemo(() => { + if (settings?.ttsUseSystemLanguage) { + return navigator?.language?.split('-')[0] + } + return undefined + }, [settings?.ttsUseSystemLanguage]) + + const detectContentLang = useMemo(() => settings?.ttsDetectContentLanguage !== false, [settings?.ttsDetectContentLanguage]) + const handlePlayPause = () => { if (!canPlay) return + if (!speaking) { - speak(text, defaultLang) + let langOverride: string | undefined + if (detectContentLang && text) { + try { + const lang = detect(text) + if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2) + } catch {} + } + if (!langOverride && resolvedSystemLang) { + langOverride = resolvedSystemLang + } + speak(text, langOverride) } else if (paused) { resume() } else { From fc138f3ceba917b86452da14699ab2d3a33ac077 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:55:15 +0200 Subject: [PATCH 34/37] feat(tts): select voice by detected/system language per utterance --- src/hooks/useTextToSpeech.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/hooks/useTextToSpeech.ts b/src/hooks/useTextToSpeech.ts index e903ea37..fb90e98f 100644 --- a/src/hooks/useTextToSpeech.ts +++ b/src/hooks/useTextToSpeech.ts @@ -152,11 +152,17 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS { charIndexRef.current = 0 const u = createUtterance(text) - if (langOverride) u.lang = langOverride + if (langOverride) { + u.lang = langOverride + // try to pick a voice that matches the override + const available = voices + const match = available.find(v => v.lang?.toLowerCase().startsWith(langOverride.toLowerCase())) + if (match) u.voice = match + } utteranceRef.current = u synth!.speak(u) - }, [supported, synth, createUtterance, rate]) + }, [supported, synth, createUtterance, rate, voices]) const pause = useCallback(() => { if (!supported) return From 6c42ee88ea9d516b5197a316ed69695a28837b4c Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:56:16 +0200 Subject: [PATCH 35/37] fix(lint): avoid empty catch in TTSControls detection --- src/components/TTSControls.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index 1b028820..b782589e 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -41,7 +41,9 @@ const TTSControls: React.FC = ({ text, defaultLang, className, settings } try { const lang = detect(text) if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2) - } catch {} + } catch (err) { + console.debug('[tts][detect] failed', err) + } } if (!langOverride && resolvedSystemLang) { langOverride = resolvedSystemLang From 227f0624567cb555a0125189f62a451103ae5c3c Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:58:36 +0200 Subject: [PATCH 36/37] feat(settings): consolidate TTS language into Speaker language dropdown (default: content) --- src/components/Settings.tsx | 1 + src/components/Settings/TTSSettings.tsx | 36 +++++++++---------------- src/services/settingsService.ts | 1 + 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 1926c3cb..f9e5dc0b 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -48,6 +48,7 @@ const DEFAULT_SETTINGS: UserSettings = { hideBookmarksWithoutCreationDate: true, ttsUseSystemLanguage: false, ttsDetectContentLanguage: true, + ttsLanguageMode: 'content', ttsDefaultSpeed: 2.1, } diff --git a/src/components/Settings/TTSSettings.tsx b/src/components/Settings/TTSSettings.tsx index d580977d..20bff912 100644 --- a/src/components/Settings/TTSSettings.tsx +++ b/src/components/Settings/TTSSettings.tsx @@ -38,30 +38,18 @@ const TTSSettings: React.FC = ({ settings, onUpdate }) => { -
- -
- -
- +
+ +
+ +
) diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 1f368df1..7a08ca9e 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -68,6 +68,7 @@ export interface UserSettings { // TTS language selection ttsUseSystemLanguage?: boolean // default: false ttsDetectContentLanguage?: boolean // default: true + ttsLanguageMode?: 'system' | 'content' // default: 'content' // Text-to-Speech settings ttsDefaultSpeed?: number // default: 2.1 } From a551234a29ee4f3915933fc3bd6c2a9d445a2300 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 22:59:26 +0200 Subject: [PATCH 37/37] feat(tts): use Speaker language mode (system|content) with fallback to legacy flags --- src/components/TTSControls.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/TTSControls.tsx b/src/components/TTSControls.tsx index b782589e..305d5753 100644 --- a/src/components/TTSControls.tsx +++ b/src/components/TTSControls.tsx @@ -24,13 +24,18 @@ const TTSControls: React.FC = ({ text, defaultLang, className, settings } const canPlay = supported && text?.trim().length > 0 const resolvedSystemLang = useMemo(() => { - if (settings?.ttsUseSystemLanguage) { + const mode = settings?.ttsLanguageMode + if ((mode ? mode === 'system' : settings?.ttsUseSystemLanguage) === true) { return navigator?.language?.split('-')[0] } return undefined - }, [settings?.ttsUseSystemLanguage]) + }, [settings?.ttsLanguageMode, settings?.ttsUseSystemLanguage]) - const detectContentLang = useMemo(() => settings?.ttsDetectContentLanguage !== false, [settings?.ttsDetectContentLanguage]) + const detectContentLang = useMemo(() => { + const mode = settings?.ttsLanguageMode + if (mode) return mode === 'content' + return settings?.ttsDetectContentLanguage !== false + }, [settings?.ttsLanguageMode, settings?.ttsDetectContentLanguage]) const handlePlayPause = () => { if (!canPlay) return