Skip to content

Commit 9fdeb1f

Browse files
Add files via upload
1 parent 57f7dff commit 9fdeb1f

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import TrackPlayer, {
2+
AppKilledPlaybackBehavior,
3+
Capability,
4+
RepeatMode,
5+
State,
6+
} from "react-native-track-player";
7+
8+
// react-native-track-player owns a real native MediaSession (Android) /
9+
// Now Playing + Remote Command Center (iOS) — that's what actually puts
10+
// play/pause/next/prev on the lock screen and notification shade with
11+
// native styling, instead of the plain alert-style notification we had
12+
// before.
13+
//
14+
// The catch: RNTP expects an actual playable track, and our narration
15+
// comes from expo-speech, which has no accessible audio stream/URL to
16+
// hand it. So we give RNTP a silent, looping local track purely to hold
17+
// the media session and drive the transport UI, while expo-speech keeps
18+
// doing the real narration. Play/pause on the lock screen mirrors into
19+
// the app's TTS controls via setRemoteHandlers below — it doesn't
20+
// control audible playback itself.
21+
const SILENT_TRACK_ID = "tts_silent_anchor";
22+
23+
export interface TTSNotificationState {
24+
novelTitle: string;
25+
chapterNumber: number;
26+
chapterTitle: string;
27+
progressPercent: number;
28+
isPlaying: boolean;
29+
}
30+
31+
export interface TTSRemoteHandlers {
32+
onPlay?: () => void;
33+
onPause?: () => void;
34+
onNext?: () => void;
35+
onPrevious?: () => void;
36+
onStop?: () => void;
37+
}
38+
39+
let remoteHandlers: TTSRemoteHandlers = {};
40+
41+
// Read by service.js inside the headless playback service — kept as a
42+
// plain module-level object rather than React state since the service
43+
// can run outside the component tree.
44+
export const getRemoteHandlers = () => remoteHandlers;
45+
46+
export const setRemoteHandlers = (handlers: TTSRemoteHandlers) => {
47+
remoteHandlers = handlers;
48+
};
49+
50+
let playerReady: Promise<void> | null = null;
51+
52+
export const setupMediaSession = async () => {
53+
if (playerReady) return playerReady;
54+
55+
playerReady = (async () => {
56+
await TrackPlayer.setupPlayer({
57+
autoHandleInterruptions: true,
58+
});
59+
60+
await TrackPlayer.updateOptions({
61+
android: {
62+
appKilledPlaybackBehavior:
63+
AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification,
64+
},
65+
capabilities: [
66+
Capability.Play,
67+
Capability.Pause,
68+
Capability.SkipToNext,
69+
Capability.SkipToPrevious,
70+
Capability.Stop,
71+
],
72+
compactCapabilities: [
73+
Capability.Play,
74+
Capability.Pause,
75+
Capability.SkipToNext,
76+
Capability.SkipToPrevious,
77+
],
78+
notificationCapabilities: [
79+
Capability.Play,
80+
Capability.Pause,
81+
Capability.SkipToNext,
82+
Capability.SkipToPrevious,
83+
],
84+
});
85+
86+
await TrackPlayer.setRepeatMode(RepeatMode.Track);
87+
})();
88+
89+
return playerReady;
90+
};
91+
92+
const ensureAnchorTrackLoaded = async (state: TTSNotificationState) => {
93+
const activeTrack = await TrackPlayer.getActiveTrack();
94+
if (activeTrack?.id === SILENT_TRACK_ID) return;
95+
96+
await TrackPlayer.reset();
97+
await TrackPlayer.add({
98+
id: SILENT_TRACK_ID,
99+
// eslint-disable-next-line @typescript-eslint/no-require-imports
100+
url: require("@/assets/audio/silence.mp3"),
101+
title: state.chapterTitle,
102+
artist: state.novelTitle,
103+
artwork: require("@/assets/images/icon.png"),
104+
duration: 60,
105+
});
106+
};
107+
108+
// Reading progress has no real audio-duration analogue, so rather than
109+
// faking a scrubber position against the silent track (which would let
110+
// someone "seek" reading progress and looks broken when it snaps back),
111+
// progress is surfaced as text in the notification body instead.
112+
const describeState = (state: TTSNotificationState) =>
113+
`Chapter ${state.chapterNumber}${state.progressPercent}% ${
114+
state.isPlaying ? "" : "(Paused)"
115+
}`.trim();
116+
117+
export const updateMediaSession = async (state: TTSNotificationState) => {
118+
try {
119+
await setupMediaSession();
120+
await ensureAnchorTrackLoaded(state);
121+
122+
await TrackPlayer.updateNowPlayingMetadata({
123+
title: state.chapterTitle,
124+
artist: state.novelTitle,
125+
description: describeState(state),
126+
});
127+
128+
const playbackState = await TrackPlayer.getPlaybackState();
129+
const isCurrentlyPlaying = playbackState.state === State.Playing;
130+
if (state.isPlaying && !isCurrentlyPlaying) {
131+
await TrackPlayer.play();
132+
} else if (!state.isPlaying && isCurrentlyPlaying) {
133+
await TrackPlayer.pause();
134+
}
135+
} catch (error) {
136+
console.warn("[TTS Media Session] Failed to update:", error);
137+
}
138+
};
139+
140+
export const clearMediaSession = async () => {
141+
try {
142+
await TrackPlayer.reset();
143+
} catch (error) {
144+
console.warn("[TTS Media Session] Failed to clear:", error);
145+
}
146+
};

0 commit comments

Comments
 (0)