1import { useIsFocused } from "@react-navigation/native";
2import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
3import { useAudioPlayerStatus } from "expo-audio";
4import { useContext, useEffect, useRef, useState } from "react";
5import { AppState, Pressable, View } from "react-native";
6import type { FilePreviewSource } from "../../components/ui/FilePreviewModal";
7import { filePreviewKey } from "../../lib/filePreviewGallery";
8import { PierreEntryIcon } from "../../components/PierreEntryIcon";
9import { AppText as Text } from "../../components/ui/AppText";
10import { SymbolView } from "../../components/ui/AppSymbol";
11import { useFullscreenVideoOpen, useMediaAudioSessionHold } from "../../lib/videoAudioSession";
12import { useRefreshAssetUrl } from "../../state/assets";
13import { ThreadMediaVisibleContext } from "./ThreadMarkdownVideo";
14import {
15 acquireAudioSession,
16 activateAudioSession,
17 isAudioSessionOwner,
18 pauseAudioPlayer,
19 releaseAudioSession,
20} from "../../lib/audioSession";
21import { usePlaybackAudioPlayer } from "../voice-input/usePlaybackAudioPlayer";
22
23type PreviewProps = {
24 readonly source: FilePreviewSource;
25 readonly sizeBytes?: number;
26 readonly onOpen: () => void;
27 readonly onPlaybackStart?: () => void;
28 readonly onLongPress?: () => void;
29};
30
31function FileCardHeader(props: PreviewProps) {
32 return (
33 <Pressable
34 accessibilityRole="button"
35 accessibilityLabel={`Open ${props.source.name}`}
36 onPress={props.onOpen}
37 onLongPress={props.onLongPress}
38 className="h-[72px] flex-row items-center gap-3 p-3 active:bg-subtle"
39 >
40 <View className="h-12 w-10 shrink-0 items-center justify-center rounded-lg bg-subtle">
41 <PierreEntryIcon path={props.source.name ?? "file"} kind="file" size={26} />
42 </View>
43 <View className="min-w-0 flex-1 gap-1">
44 <Text className="text-sm font-t3-medium text-foreground" numberOfLines={1}>
45 {props.source.name}
46 </Text>
47 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
48 Audio
49 {props.sizeBytes !== undefined ? ` · ${formatAttachmentSize(props.sizeBytes)}` : ""}
50 </Text>
51 </View>
52 <SymbolView name="chevron.right" size={12} tintColorClassName="accent-foreground-muted" />
53 </Pressable>
54 );
55}
56
57function timestamp(seconds: number) {
58 const value = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
59 return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`;
60}
61
62function InlineAudio(props: PreviewProps & { readonly resolveUri: () => Promise<string | null> }) {
63 // An empty source keeps native buffering off until the user explicitly plays.
64 const player = usePlaybackAudioPlayer(null);
65 const status = useAudioPlayerStatus(player);
66 const [loading, setLoading] = useState(false);
67 const [error, setError] = useState(false);
68 const active = useRef(true);
69 const requestGeneration = useRef(0);
70 const playRequested = useRef(false);
71 const audioToken = useRef<ReturnType<typeof acquireAudioSession> | null>(null);
72 const [playbackRequested, setPlaybackRequested] = useState(false);
73 const requestPlayback = (requested: boolean) => {
74 playRequested.current = requested;
75 setPlaybackRequested(requested);
76 if (!requested) {
77 requestGeneration.current += 1;
78 setLoading(false);
79 if (audioToken.current) {
80 void releaseAudioSession(audioToken.current).catch(() => {});
81 audioToken.current = null;
82 }
83 }
84 };
85 useMediaAudioSessionHold(status.playing);
86 const fullscreenVideoOpen = useFullscreenVideoOpen();
87 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops the play request on fullscreen video, finish or error, and pauses the player for fullscreen
88 useEffect(() => {
89 if (fullscreenVideoOpen || status.didJustFinish || status.error) {
90 // oxlint-disable-next-line react/set-state-in-effect -- Native completion and fullscreen takeover invalidate the pending playback request.
91 requestPlayback(false);
92 }
93 if (fullscreenVideoOpen) pauseAudioPlayer(player);
94 }, [fullscreenVideoOpen, status.didJustFinish, status.error, player, requestPlayback]);
95 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to AppState to pause audio in the background and releases the audio session on cleanup
96 useEffect(() => {
97 active.current = true;
98 const subscription = AppState.addEventListener("change", (state) => {
99 if (state !== "active") {
100 requestPlayback(false);
101 pauseAudioPlayer(player);
102 }
103 });
104 return () => {
105 active.current = false;
106 playRequested.current = false;
107 requestGeneration.current += 1;
108 if (audioToken.current) {
109 void releaseAudioSession(audioToken.current).catch(() => {});
110 audioToken.current = null;
111 }
112 subscription.remove();
113 };
114 }, [player, requestPlayback]);
115 const toggle = () => {
116 if (status.playing || playRequested.current) {
117 requestPlayback(false);
118 return pauseAudioPlayer(player);
119 }
120 const token = acquireAudioSession(
121 "playback",
122 () => {
123 requestPlayback(false);
124 pauseAudioPlayer(player);
125 },
126 () => {
127 try {
128 pauseAudioPlayer(player);
129 } catch {}
130 },
131 );
132 if (!isAudioSessionOwner(token)) return;
133 props.onPlaybackStart?.();
134 audioToken.current = token;
135 requestPlayback(true);
136 const generation = ++requestGeneration.current;
137 const isCurrentRequest = () =>
138 active.current &&
139 playRequested.current &&
140 requestGeneration.current === generation &&
141 isAudioSessionOwner(token);
142 setLoading(true);
143 setError(false);
144 void Promise.resolve()
145 .then(async () => {
146 if (!isCurrentRequest()) return;
147 if (!status.isLoaded || status.error) {
148 const uri = await props.resolveUri();
149 if (!isCurrentRequest() || AppState.currentState !== "active") return;
150 if (!uri) {
151 requestPlayback(false);
152 setError(true);
153 return;
154 }
155 player.replace({ uri });
156 } else if (status.didJustFinish || status.currentTime >= status.duration) {
157 await player.seekTo(0);
158 }
159 if (!isCurrentRequest() || AppState.currentState !== "active") return;
160 await activateAudioSession(token);
161 if (isCurrentRequest() && AppState.currentState === "active") player.play();
162 })
163 .catch(() => {
164 if (isCurrentRequest()) {
165 requestPlayback(false);
166 setError(true);
167 }
168 })
169 .finally(() => {
170 if (isCurrentRequest()) setLoading(false);
171 });
172 };
173 const progress =
174 status.duration > 0 ? Math.min(1, Math.max(0, status.currentTime / status.duration)) : 0;
175 const playing = playbackRequested || status.playing;
176 const busy = loading || (playbackRequested && status.isBuffering);
177 return (
178 <>
179 <FileCardHeader
180 {...props}
181 onOpen={() => {
182 requestPlayback(false);
183 pauseAudioPlayer(player);
184 props.onOpen();
185 }}
186 />
187 <View className="h-14 flex-row items-center gap-3 px-3 pb-2">
188 <Pressable
189 accessibilityRole="button"
190 accessibilityLabel={
191 playing ? "Pause audio" : error || status.error ? "Retry audio" : "Play audio"
192 }
193 accessibilityState={{ busy }}
194 onPress={toggle}
195 className="h-11 w-11 items-center justify-center rounded-lg bg-subtle"
196 >
197 <SymbolView
198 name={playing ? "pause" : "play"}
199 size={18}
200 tintColorClassName="accent-foreground"
201 />
202 </Pressable>
203 <View className="min-w-0 flex-1 gap-2">
204 <View
205 accessibilityRole="progressbar"
206 accessibilityValue={{ min: 0, max: status.duration || 0, now: status.currentTime }}
207 className="h-1 overflow-hidden rounded-full bg-subtle"
208 >
209 <View className="h-1 bg-foreground-muted" style={{ width: `${progress * 100}%` }} />
210 </View>
211 <Text className="text-xs text-foreground-muted" numberOfLines={1}>
212 {error || status.error
213 ? "Unavailable · tap play to retry"
214 : busy
215 ? "Loading…"
216 : `${timestamp(status.currentTime)} / ${status.isLoaded ? timestamp(status.duration) : "--:--"}`}
217 </Text>
218 </View>
219 </View>
220 </>
221 );
222}
223
224function VisibleFilePreview(props: PreviewProps) {
225 // Each mounted preview owns its source; feed streaming must not restart a fetch or player.
226 const [source] = useState(props.source);
227 const refresh = useRefreshAssetUrl(
228 "environmentId" in source ? source.environmentId : null,
229 "resource" in source ? source.resource : null,
230 );
231 const resolveUri = () => ("uri" in source ? Promise.resolve(source.uri) : refresh());
232 return <InlineAudio {...props} resolveUri={resolveUri} />;
233}
234
235export function ThreadInlineFilePreview(props: PreviewProps) {
236 const visible = useContext(ThreadMediaVisibleContext);
237 const focused = useIsFocused();
238 return (
239 <View
240 className="my-1 overflow-hidden rounded-xl border border-border bg-card"
241 style={{ width: 280, maxWidth: "100%" }}
242 >
243 {visible && focused ? (
244 <VisibleFilePreview key={filePreviewKey(props.source)} {...props} />
245 ) : (
246 <>
247 <FileCardHeader {...props} />
248 <View className="h-14" />
249 </>
250 )}
251 </View>
252 );
253}