From ba02eb1c91d54c437d3e7b489fbeb41ca8dda6e7 Mon Sep 17 00:00:00 2001 From: codedogQBY <1369175442@qq.com> Date: Sat, 13 Jun 2026 06:57:16 +0800 Subject: [PATCH] fix(mobile): resync TTS after foreground restore --- .../platform/track-player-dashscope-player.ts | 43 +++++++++++--- .../lib/platform/track-player-edge-player.ts | 57 +++++++++++++++---- .../src/screens/reader/useReaderTTS.ts | 29 +++++++--- .../app-expo/src/services/PlaybackService.ts | 16 +++++- 4 files changed, 117 insertions(+), 28 deletions(-) diff --git a/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts b/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts index 4920153a..764fdf42 100644 --- a/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts +++ b/packages/app-expo/src/lib/platform/track-player-dashscope-player.ts @@ -5,11 +5,8 @@ import { File, Paths } from "expo-file-system"; import { AppState, type AppStateStatus, Image, Platform } from "react-native"; import TrackPlayer, { Event, State } from "react-native-track-player"; +import { chunkIndexFromTrackId, trackIdForChunkIndex } from "./track-player-chunk-id"; import { ensureSilenceFile } from "./tts-silence-keeper"; -import { - chunkIndexFromTrackId, - trackIdForChunkIndex, -} from "./track-player-chunk-id"; const CHUNK_MAX_CHARS = 500; const DEFAULT_ARTWORK = (() => { @@ -209,6 +206,7 @@ export class TrackPlayerDashScopeTTSPlayer implements ITTSPlayer { if (state !== "active") return; if (gen !== this._speakGen || this._stopped) return; this._ensureProducerRunning(gen); + this._syncActiveTrackFromNative(gen); if (this._queueStarved) { this._recoverFromStarvation(gen); } @@ -371,8 +369,7 @@ export class TrackPlayerDashScopeTTSPlayer implements ITTSPlayer { await TrackPlayer.skip(targetQueuePos).catch(() => {}); await TrackPlayer.play(); const resolvedTrack = await TrackPlayer.getActiveTrack().catch(() => null); - const resolvedChunkIndex = - chunkIndexFromTrackId(resolvedTrack?.id) ?? targetChunkIndex; + const resolvedChunkIndex = chunkIndexFromTrackId(resolvedTrack?.id) ?? targetChunkIndex; this._notifyChunkChange(resolvedChunkIndex); this._startProgressPolling(gen); this.onStateChange?.("playing"); @@ -396,7 +393,10 @@ export class TrackPlayerDashScopeTTSPlayer implements ITTSPlayer { .map((track, index) => (ids.has(String(track.id)) ? index : -1)) .filter((index) => index >= 0) .sort((a, b) => b - a); - if (indexes.length > 0) await TrackPlayer.remove(indexes).catch((err) => console.warn("[TTS] TrackPlayer remove failed:", err)); + if (indexes.length > 0) + await TrackPlayer.remove(indexes).catch((err) => + console.warn("[TTS] TrackPlayer remove failed:", err), + ); } catch (err) { console.warn("[TTS] Failed to clear silence tracks:", err); } @@ -438,6 +438,35 @@ export class TrackPlayerDashScopeTTSPlayer implements ITTSPlayer { this.onChunkChange?.(index, this._chunks.length); } + private _syncActiveTrackFromNative(gen: number): void { + void (async () => { + try { + const [activeTrack, playbackState] = await Promise.all([ + TrackPlayer.getActiveTrack().catch(() => null), + TrackPlayer.getPlaybackState().catch(() => null), + ]); + if (gen !== this._speakGen || this._stopped) return; + + const chunkIndex = chunkIndexFromTrackId(activeTrack?.id); + if (chunkIndex != null) { + this._notifyChunkChange(chunkIndex); + } + + if (playbackState?.state === State.Playing) { + this._playStarted = true; + this._startProgressPolling(gen); + this.onStateChange?.("playing"); + } else if (this._paused && playbackState?.state === State.Paused) { + this.onStateChange?.("paused"); + } else if (playbackState?.state === State.Ended || playbackState?.state === State.Stopped) { + this._handlePlaybackEnded(gen, chunkIndex ?? undefined); + } + } catch (error) { + console.warn("[TrackPlayerDashScopeTTSPlayer] failed to sync active track", error); + } + })(); + } + private _startProgressPolling(gen: number): void { this._stopProgressPolling(); this._progressPollTimer = setInterval(() => { diff --git a/packages/app-expo/src/lib/platform/track-player-edge-player.ts b/packages/app-expo/src/lib/platform/track-player-edge-player.ts index f30880f9..8b6fb818 100644 --- a/packages/app-expo/src/lib/platform/track-player-edge-player.ts +++ b/packages/app-expo/src/lib/platform/track-player-edge-player.ts @@ -4,11 +4,8 @@ import { File, Paths } from "expo-file-system"; import { AppState, type AppStateStatus, Image, Platform } from "react-native"; import TrackPlayer, { Event, State } from "react-native-track-player"; +import { chunkIndexFromTrackId, trackIdForChunkIndex } from "./track-player-chunk-id"; import { ensureSilenceFile } from "./tts-silence-keeper"; -import { - chunkIndexFromTrackId, - trackIdForChunkIndex, -} from "./track-player-chunk-id"; const CHUNK_MAX_CHARS = 500; const DEFAULT_ARTWORK = (() => { @@ -210,6 +207,7 @@ export class TrackPlayerEdgeTTSPlayer implements ITTSPlayer { if (state !== "active") return; if (gen !== this._speakGen || this._stopped) return; this._ensureProducerRunning(gen); + this._syncActiveTrackFromNative(gen); if (this._queueStarved) { this._recoverFromStarvation(gen); } @@ -308,7 +306,10 @@ export class TrackPlayerEdgeTTSPlayer implements ITTSPlayer { // Resolve current playback position by track ID, not queue index — when // silence keep-alive tracks were inserted at the head of the queue // before playback started, the queue index would be off by N. - const activeTrack = await TrackPlayer.getActiveTrack().catch((err) => { console.warn("[TTS] TrackPlayer getActiveTrack failed:", err); return null; }); + const activeTrack = await TrackPlayer.getActiveTrack().catch((err) => { + console.warn("[TTS] TrackPlayer getActiveTrack failed:", err); + return null; + }); const chunkIndex = chunkIndexFromTrackId(activeTrack?.id); if (chunkIndex != null) { this._notifyChunkChange(chunkIndex); @@ -378,11 +379,15 @@ export class TrackPlayerEdgeTTSPlayer implements ITTSPlayer { } this._queueStarved = false; - await TrackPlayer.skip(targetQueuePos).catch((err) => console.warn("[TTS] TrackPlayer skip failed:", err)); + await TrackPlayer.skip(targetQueuePos).catch((err) => + console.warn("[TTS] TrackPlayer skip failed:", err), + ); await TrackPlayer.play(); - const resolvedTrack = await TrackPlayer.getActiveTrack().catch((err) => { console.warn("[TTS] TrackPlayer getActiveTrack failed:", err); return null; }); - const resolvedChunkIndex = - chunkIndexFromTrackId(resolvedTrack?.id) ?? targetChunkIndex; + const resolvedTrack = await TrackPlayer.getActiveTrack().catch((err) => { + console.warn("[TTS] TrackPlayer getActiveTrack failed:", err); + return null; + }); + const resolvedChunkIndex = chunkIndexFromTrackId(resolvedTrack?.id) ?? targetChunkIndex; this._notifyChunkChange(resolvedChunkIndex); this._startProgressPolling(gen); this.onStateChange?.("playing"); @@ -406,7 +411,10 @@ export class TrackPlayerEdgeTTSPlayer implements ITTSPlayer { .map((track, index) => (ids.has(String(track.id)) ? index : -1)) .filter((index) => index >= 0) .sort((a, b) => b - a); - if (indexes.length > 0) await TrackPlayer.remove(indexes).catch((err) => console.warn("[TTS] TrackPlayer remove failed:", err)); + if (indexes.length > 0) + await TrackPlayer.remove(indexes).catch((err) => + console.warn("[TTS] TrackPlayer remove failed:", err), + ); } catch (err) { console.warn("[TTS] Failed to clear silence tracks:", err); } @@ -448,6 +456,35 @@ export class TrackPlayerEdgeTTSPlayer implements ITTSPlayer { this.onChunkChange?.(index, this._chunks.length); } + private _syncActiveTrackFromNative(gen: number): void { + void (async () => { + try { + const [activeTrack, playbackState] = await Promise.all([ + TrackPlayer.getActiveTrack().catch(() => null), + TrackPlayer.getPlaybackState().catch(() => null), + ]); + if (gen !== this._speakGen || this._stopped) return; + + const chunkIndex = chunkIndexFromTrackId(activeTrack?.id); + if (chunkIndex != null) { + this._notifyChunkChange(chunkIndex); + } + + if (playbackState?.state === State.Playing) { + this._playStarted = true; + this._startProgressPolling(gen); + this.onStateChange?.("playing"); + } else if (this._paused && playbackState?.state === State.Paused) { + this.onStateChange?.("paused"); + } else if (playbackState?.state === State.Ended || playbackState?.state === State.Stopped) { + this._handlePlaybackEnded(gen, chunkIndex ?? undefined); + } + } catch (error) { + console.warn("[TrackPlayerEdgeTTSPlayer] failed to sync active track", error); + } + })(); + } + private _startProgressPolling(gen: number): void { this._stopProgressPolling(); this._progressPollTimer = setInterval(() => { diff --git a/packages/app-expo/src/screens/reader/useReaderTTS.ts b/packages/app-expo/src/screens/reader/useReaderTTS.ts index 372a7cc4..d67fce9a 100644 --- a/packages/app-expo/src/screens/reader/useReaderTTS.ts +++ b/packages/app-expo/src/screens/reader/useReaderTTS.ts @@ -421,6 +421,21 @@ export function useReaderTTS({ setTtsChunkOffset(nextOffset); }, []); + const resolveForegroundTTSCfi = useCallback(() => { + const state = useTTSStore.getState(); + if (state.currentBookId !== bookId) return null; + + const localIndex = state.currentChunkIndex - ttsChunkOffsetRef.current; + const indexSegment = + localIndex >= 0 && localIndex < ttsSegmentsRef.current.length + ? ttsSegmentsRef.current[localIndex] || null + : null; + + return ( + indexSegment?.cfi || state.currentLocationCfi || resolvedTTSSegmentCfi || currentCfi || null + ); + }, [bookId, currentCfi, resolvedTTSSegmentCfi]); + const dedupeTTSSegments = useCallback( (segments: TTSSegment[]) => { const seenIdentities = new Set(); @@ -1523,7 +1538,6 @@ export function useReaderTTS({ currentChapter, currentCfi, setShowControls, - setShowTTS, syncTTSChunkOffset, ttsCoverUri, ttsPlay, @@ -2155,7 +2169,7 @@ export function useReaderTTS({ ]); // ─── Foreground resync: force highlight update after returning from background ── - // When iOS suspends the app, React effects don't run but native audio continues. + // When the OS suspends the app, React effects don't run but native audio can continue. // On return to foreground, force-resync the WebView highlight to current TTS position. useEffect(() => { const handleAppStateChange = (nextState: AppStateStatus) => { @@ -2165,15 +2179,14 @@ export function useReaderTTS({ if (ttsPlayState !== "playing" && ttsPlayState !== "paused") return; if (ttsSourceKind !== "page") return; - const targetCfi = resolvedTTSSegmentCfi; - if (!targetCfi) return; - // Small delay to let WebView fully resume before injecting JS setTimeout(() => { + const targetCfi = resolveForegroundTTSCfi(); + if (!targetCfi) return; bridgeRef.current?.setTTSHighlight(targetCfi, ttsHighlightColor, true); // Also navigate to the current TTS position if user scrolled away bridgeRef.current?.goToCFI(targetCfi); - }, 300); + }, 450); }; const subscription = AppState.addEventListener("change", handleAppStateChange); @@ -2181,10 +2194,8 @@ export function useReaderTTS({ }, [ bookId, bridgeRef, - resolvedTTSSegmentCfi, - showTTS, + resolveForegroundTTSCfi, ttsCurrentBookId, - ttsHighlightColor, ttsPlayState, ttsSourceKind, webViewReady, diff --git a/packages/app-expo/src/services/PlaybackService.ts b/packages/app-expo/src/services/PlaybackService.ts index 3df5f3ef..7e49759e 100644 --- a/packages/app-expo/src/services/PlaybackService.ts +++ b/packages/app-expo/src/services/PlaybackService.ts @@ -11,26 +11,38 @@ * handler hasn't loaded yet. */ import TrackPlayer, { Event } from "react-native-track-player"; +import { useTTSStore } from "../stores/tts-store"; export async function PlaybackService() { TrackPlayer.addEventListener(Event.RemotePlay, () => { + useTTSStore.getState().resume(); TrackPlayer.play(); }); TrackPlayer.addEventListener(Event.RemotePause, () => { + useTTSStore.getState().pause(); TrackPlayer.pause(); }); TrackPlayer.addEventListener(Event.RemoteStop, () => { + useTTSStore.getState().stop(); TrackPlayer.stop(); }); TrackPlayer.addEventListener(Event.RemoteNext, () => { - // Handled by App.tsx TTS store bridge + const { currentChunkIndex, jumpToChunk, totalChunks } = useTTSStore.getState(); + const nextIndex = currentChunkIndex + 1; + if (nextIndex < totalChunks) { + jumpToChunk(nextIndex); + } }); TrackPlayer.addEventListener(Event.RemotePrevious, () => { - // Handled by App.tsx TTS store bridge + const { currentChunkIndex, jumpToChunk } = useTTSStore.getState(); + const prevIndex = currentChunkIndex - 1; + if (prevIndex >= 0) { + jumpToChunk(prevIndex); + } }); TrackPlayer.addEventListener(Event.RemoteSeek, (event) => {