forked from akai/readest
feat(tts): add native local iOS TTS (AVSpeechSynthesizer) (#4697)
Implement on-device iOS text-to-speech using AVSpeechSynthesizer, mirroring the Android native TextToSpeech plugin so the shared NativeTTSClient drives both platforms through the same command and tts_events contract. - Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and voice enumeration, with region-disambiguated duplicate voice names and a small preUtteranceDelay to avoid first-word clipping. - Enable the native TTS client on iOS in TTSController. - Make TTS teardown resilient: reset UI state up front and tear down the controller, media session, and background audio in parallel so a slow native shutdown can never leave the TTS icon or lock-screen session stuck on. - Keep iOS on navigator.mediaSession for the lock screen (Android uses the native foreground service), which restores the Edge TTS cover and current-sentence metadata. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -535,26 +535,42 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
|
||||
const handleStop = useCallback(
|
||||
async (bookKey: string) => {
|
||||
const ttsController = ttsControllerRef.current;
|
||||
if (ttsController) {
|
||||
await ttsController.shutdown();
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
getView(bookKey)?.deselect();
|
||||
setIsPlaying(false);
|
||||
emitPlaybackState('stopped');
|
||||
onRequestHidePanel?.();
|
||||
setShowIndicator(false);
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
}
|
||||
// Reset all UI/session state up front — including the TTS toggle
|
||||
// (ttsEnabled) and indicator that color the TTS icon — so disabling TTS
|
||||
// always takes effect immediately. The teardown below is best-effort and
|
||||
// must never block or skip these resets if it hangs or throws, which was
|
||||
// observed with iOS system TTS (Edge TTS was unaffected). See #4676.
|
||||
ttsControllerRef.current = null;
|
||||
setTtsController(null);
|
||||
setIsPlaying(false);
|
||||
emitPlaybackState('stopped');
|
||||
onRequestHidePanel?.();
|
||||
setShowIndicator(false);
|
||||
setShowBackToCurrentTTSLocation(false);
|
||||
previousSectionLabelRef.current = undefined;
|
||||
if (appService?.isIOSApp) {
|
||||
await invokeUseBackgroundAudio({ enabled: false });
|
||||
}
|
||||
setTTSEnabled(bookKey, false);
|
||||
getView(bookKey)?.deselect();
|
||||
if (appService?.isMobile) {
|
||||
releaseUnblockAudio();
|
||||
}
|
||||
await deinitMediaSession();
|
||||
setTTSEnabled(bookKey, false);
|
||||
|
||||
// Tear down the controller, the lock-screen media session, and the
|
||||
// background-audio session best-effort and IN PARALLEL. The controller's
|
||||
// own shutdown can stall on iOS system TTS, and it must NOT gate the media
|
||||
// session / background-audio teardown — otherwise the lock-screen Now
|
||||
// Playing keeps running after TTS is disabled (Edge TTS was unaffected
|
||||
// because it never hits the stalling native path). See #4676.
|
||||
await Promise.all([
|
||||
ttsController
|
||||
? Promise.resolve()
|
||||
.then(() => ttsController.shutdown())
|
||||
.catch((error) => console.warn('TTS shutdown failed:', error))
|
||||
: Promise.resolve(),
|
||||
appService?.isIOSApp
|
||||
? invokeUseBackgroundAudio({ enabled: false }).catch(() => {})
|
||||
: Promise.resolve(),
|
||||
deinitMediaSession().catch(() => {}),
|
||||
]);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[appService],
|
||||
|
||||
Reference in New Issue
Block a user