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:
Huang Xin
2026-06-21 15:53:23 +08:00
committed by GitHub
parent ab935f8510
commit 96d65d9960
9 changed files with 772 additions and 32 deletions
@@ -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],