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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user