feat(reader): add TTS speak button to dictionary popup (#4876) (#4957)

Add a speaker button to the dictionary popup/sheet header that pronounces
the current headword. Tapping it speaks via Edge TTS, falling back to the
platform speech engine (Web Speech on desktop/web, native on the mobile
app) when Edge is unavailable.

To speak as soon as possible, a dedicated wordPronouncer bypasses the
reader's TTSController entirely: it never runs EdgeTTSClient.init() (which
wastes a round trip synthesizing "test"), calls EdgeSpeechTTS directly
(whose static MP3 cache makes repeat words instant), and schedules one
chunk on a dedicated Web Audio context isolated from any active read-aloud
session. The context is warmed synchronously inside the click gesture so
playback is not blocked by autoplay policy after the network await.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-06 13:56:51 +09:00
committed by GitHub
parent 6f3b401c24
commit 4527aa277a
6 changed files with 463 additions and 4 deletions
@@ -55,6 +55,8 @@ const DictionaryPopup: React.FC<DictionaryPopupProps> = ({
canGoBack={state.canGoBack}
goBack={state.goBack}
onManage={onManage}
onSpeak={state.speakWord}
speaking={state.isSpeaking}
/>
<div className='min-h-0 flex-1'>
<DictionaryResultsBody {...state} />
@@ -1,7 +1,7 @@
'use client';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MdArrowBack, MdChevronRight, MdSettings } from 'react-icons/md';
import { MdArrowBack, MdChevronRight, MdSettings, MdVolumeUp } from 'react-icons/md';
import clsx from 'clsx';
import { openUrl } from '@tauri-apps/plugin-opener';
@@ -12,6 +12,7 @@ import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { getEnabledProviders } from '@/services/dictionaries/registry';
import { buildLookupCandidates } from '@/services/dictionaries/lookupCandidates';
import { isTauriAppPlatform } from '@/services/environment';
import { cancelWordPronounce, pronounceWord, warmWordAudio } from '@/services/tts/wordPronouncer';
import {
getBuiltinWebSearch,
substituteUrlTemplate,
@@ -51,6 +52,10 @@ export interface DictionaryResultsState {
noProvidersAtAll: boolean;
/** Dictionary popup font-size multiplier (#4443); `1` = default sizes. */
fontScale: number;
/** Whether the current word is being fetched/spoken by the TTS engine (#4876). */
isSpeaking: boolean;
/** Pronounce the current word via Edge TTS (falling back to platform speech). */
speakWord: () => void;
}
/**
@@ -124,6 +129,30 @@ export function useDictionaryResults({
setHistoryStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
}, []);
// Pronounce the current headword (#4876). `isSpeaking` covers both the
// Edge fetch and playback so the header button can show one active state.
const [isSpeaking, setIsSpeaking] = useState(false);
const langCode = typeof lang === 'string' ? lang : Array.isArray(lang) ? lang[0] : undefined;
const speakWord = useCallback(() => {
// Warm the audio context synchronously inside the click gesture; the
// synth/play happens after a network await, outside the gesture window.
warmWordAudio();
setIsSpeaking(true);
void pronounceWord(currentWord, langCode, { appService }, (status) => {
if (status !== 'playing') setIsSpeaking(false);
});
}, [currentWord, langCode, appService]);
// Stop any in-flight pronunciation when the word changes (in-content
// navigation / reopen) or the popup unmounts.
useEffect(() => {
return () => cancelWordPronounce();
}, []);
useEffect(() => {
cancelWordPronounce();
setIsSpeaking(false);
}, [currentWord]);
const toggleExpanded = useCallback((id: string) => {
setCards((prev) => {
const old = prev[id];
@@ -315,6 +344,8 @@ export function useDictionaryResults({
onWebSearchClickTauri,
noProvidersAtAll,
fontScale: settings.fontScale ?? 1,
isSpeaking,
speakWord,
};
}
@@ -324,6 +355,10 @@ interface DictionaryResultsHeaderProps {
canGoBack: boolean;
goBack: () => void;
onManage?: () => void;
/** Pronounce the current word (#4876); omit to hide the speaker button. */
onSpeak?: () => void;
/** Whether pronunciation is in progress, for the active button state. */
speaking?: boolean;
}
export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = ({
@@ -332,6 +367,8 @@ export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = (
canGoBack,
goBack,
onManage,
onSpeak,
speaking,
}) => {
const _ = useTranslation();
return (
@@ -348,9 +385,28 @@ export const DictionaryResultsHeader: React.FC<DictionaryResultsHeaderProps> = (
</button>
) : null}
</div>
<span data-testid='dict-title' className='line-clamp-1 flex-1 text-center font-bold'>
{currentWord}
</span>
<div className='flex min-w-0 flex-1 items-center justify-center gap-1'>
{onSpeak ? (
<button
type='button'
aria-label={_('Speak')}
title={_('Speak')}
aria-pressed={!!speaking}
onClick={onSpeak}
className={clsx(
'btn btn-ghost btn-square btn-xs shrink-0',
speaking
? 'text-base-content not-eink:animate-pulse'
: 'text-base-content/60 hover:text-base-content not-eink:hover:bg-base-200/60',
)}
>
<MdVolumeUp size={18} />
</button>
) : null}
<span data-testid='dict-title' className='line-clamp-1 min-w-0 font-bold'>
{currentWord}
</span>
</div>
<div className='flex h-8 w-8 items-center justify-center'>
{onManage ? (
<button
@@ -33,6 +33,8 @@ const DictionarySheet: React.FC<DictionarySheetProps> = ({ word, lang, onDismiss
canGoBack={state.canGoBack}
goBack={state.goBack}
onManage={onManage}
onSpeak={state.speakWord}
speaking={state.isSpeaking}
/>
}
contentClassName='!px-0 !mt-0'