Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
Expand Down Expand Up @@ -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(() => {
Expand Down
57 changes: 47 additions & 10 deletions packages/app-expo/src/lib/platform/track-player-edge-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
Expand Down Expand Up @@ -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(() => {
Expand Down
29 changes: 20 additions & 9 deletions packages/app-expo/src/screens/reader/useReaderTTS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Expand Down Expand Up @@ -1523,7 +1538,6 @@ export function useReaderTTS({
currentChapter,
currentCfi,
setShowControls,
setShowTTS,
syncTTSChunkOffset,
ttsCoverUri,
ttsPlay,
Expand Down Expand Up @@ -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) => {
Expand All @@ -2165,26 +2179,23 @@ 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);
return () => subscription.remove();
}, [
bookId,
bridgeRef,
resolvedTTSSegmentCfi,
showTTS,
resolveForegroundTTSCfi,
ttsCurrentBookId,
ttsHighlightColor,
ttsPlayState,
ttsSourceKind,
webViewReady,
Expand Down
16 changes: 14 additions & 2 deletions packages/app-expo/src/services/PlaybackService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down