Read-aloud: voice picker + speed control, auto-avoids eSpeak
- ⚙ button opens a settings popover above the toolbar with:
- Voice dropdown: lists all voices the browser exposes, with ☁
suffix for remote/cloud voices vs local ones
- Speed slider: 0.5x–2.0x in 0.1 steps, labelled live
- On first load, auto-selects the first English non-eSpeak voice
(helps Linux/Firefox users who only have eSpeak installed by default
and haven't yet set a preference)
- Voice URI and rate persist in localStorage (sw-tts-voice, sw-tts-rate)
- Voice and rate applied per-utterance via voiceURIRef / ttsRateRef
so changes take effect on the next sentence without restarting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c9126f718d
commit
a8f93582bf
@ -1,10 +1,46 @@
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
|
||||
const fileRef = useRef()
|
||||
const [isReading, setIsReading] = useState(false)
|
||||
const isReadingRef = useRef(false) // ref so closure in next() always sees current value
|
||||
|
||||
// ── TTS voice & speed ─────────────────────────────────────────────────────
|
||||
const [voices, setVoices] = useState([])
|
||||
const [voiceURI, setVoiceURI] = useState(() => localStorage.getItem('sw-tts-voice') || '')
|
||||
const [ttsRate, setTtsRate] = useState(() => parseFloat(localStorage.getItem('sw-tts-rate') || '1'))
|
||||
const [showTtsMenu, setShowTtsMenu] = useState(false)
|
||||
|
||||
const loadVoices = useCallback(() => {
|
||||
const all = window.speechSynthesis?.getVoices() ?? []
|
||||
if (!all.length) return
|
||||
setVoices(all)
|
||||
// If no preference saved yet, auto-pick first English non-eSpeak voice
|
||||
setVoiceURI(prev => {
|
||||
if (prev && all.find(v => v.voiceURI === prev)) return prev
|
||||
const pick = all.find(v => !v.name.toLowerCase().includes('espeak') && v.lang.startsWith('en'))
|
||||
|| all.find(v => v.lang.startsWith('en'))
|
||||
|| all[0]
|
||||
return pick ? pick.voiceURI : ''
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadVoices()
|
||||
window.speechSynthesis.addEventListener('voiceschanged', loadVoices)
|
||||
return () => window.speechSynthesis.removeEventListener('voiceschanged', loadVoices)
|
||||
}, [loadVoices])
|
||||
|
||||
useEffect(() => { localStorage.setItem('sw-tts-voice', voiceURI) }, [voiceURI])
|
||||
useEffect(() => { localStorage.setItem('sw-tts-rate', ttsRate) }, [ttsRate])
|
||||
|
||||
// Keep latest voice/rate in refs so the reading closure always gets current values
|
||||
const voiceURIRef = useRef(voiceURI)
|
||||
const ttsRateRef = useRef(ttsRate)
|
||||
useEffect(() => { voiceURIRef.current = voiceURI }, [voiceURI])
|
||||
useEffect(() => { ttsRateRef.current = ttsRate }, [ttsRate])
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Typewriter mode ───────────────────────────────────────────────────────
|
||||
const [typewriterMode, setTypewriterMode] = useState(false)
|
||||
const typewriterRef = useRef(false)
|
||||
@ -79,6 +115,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
||||
isReadingRef.current = true
|
||||
setIsReading(true)
|
||||
|
||||
const chosenVoice = window.speechSynthesis.getVoices().find(v => v.voiceURI === voiceURIRef.current)
|
||||
|
||||
let idx = 0
|
||||
function next() {
|
||||
if (!isReadingRef.current || idx >= chunks.length) {
|
||||
@ -87,6 +125,8 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
||||
return
|
||||
}
|
||||
const u = new SpeechSynthesisUtterance(chunks[idx++])
|
||||
if (chosenVoice) u.voice = chosenVoice
|
||||
u.rate = ttsRateRef.current
|
||||
u.onend = next
|
||||
u.onerror = () => { isReadingRef.current = false; setIsReading(false) }
|
||||
speechSynthesis.speak(u)
|
||||
@ -207,6 +247,38 @@ export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeCha
|
||||
⌨
|
||||
</button>
|
||||
|
||||
<div className="tts-settings-wrap">
|
||||
<button
|
||||
className={`toolbar-btn${showTtsMenu ? ' active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); setShowTtsMenu(o => !o) }}
|
||||
title="Read-aloud settings (voice & speed)"
|
||||
>⚙</button>
|
||||
{showTtsMenu && (
|
||||
<div className="tts-menu">
|
||||
<label className="tts-label">Voice</label>
|
||||
<select
|
||||
className="tts-select"
|
||||
value={voiceURI}
|
||||
onChange={e => setVoiceURI(e.target.value)}
|
||||
>
|
||||
{voices.length === 0 && <option value="">Loading…</option>}
|
||||
{voices.map(v => (
|
||||
<option key={v.voiceURI} value={v.voiceURI}>
|
||||
{v.name}{v.localService ? '' : ' ☁'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="tts-label">Speed — {ttsRate.toFixed(1)}×</label>
|
||||
<input
|
||||
type="range" min="0.5" max="2" step="0.1"
|
||||
value={ttsRate}
|
||||
onChange={e => setTtsRate(parseFloat(e.target.value))}
|
||||
className="tts-slider"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`toolbar-btn read-aloud-btn${isReading ? ' reading-active' : ''}`}
|
||||
onMouseDown={e => { e.preventDefault(); isReading ? stopReading() : startReading() }}
|
||||
|
||||
@ -1486,6 +1486,54 @@ button { cursor: pointer; font-family: inherit; }
|
||||
padding-bottom: 50vh;
|
||||
}
|
||||
|
||||
/* ── TTS Settings ─────────────────────────────────────── */
|
||||
|
||||
.tts-settings-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tts-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 240px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tts-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-head);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.tts-select {
|
||||
width: 100%;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 1px);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tts-select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.tts-slider {
|
||||
width: 100%;
|
||||
accent-color: var(--accent-hi);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Writing Prompt ───────────────────────────────────── */
|
||||
|
||||
.prompt-wrap { position: relative; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user