1import { Button } from "../../components/ui/Button";
2import { useAudioPlayerStatus } from "expo-audio";
3import { useEffect, useState } from "react";
4import { AppState, View } from "react-native";
5import { AppText as Text } from "../../components/ui/AppText";
6import { useFullscreenVideoOpen, useMediaAudioSessionHold } from "../../lib/videoAudioSession";
7import { createAudioPlaybackSession } from "../../lib/audioSession";
8import { usePlaybackAudioPlayer } from "../voice-input/usePlaybackAudioPlayer";
9
10function timestamp(seconds: number) {
11 const value = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
12 return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, "0")}`;
13}
14
15export function AudioFilePreview(props: { uri: string; onRetry: () => void }) {
16 const player = usePlaybackAudioPlayer(props.uri);
17 const status = useAudioPlayerStatus(player);
18 const [playbackRequested, setPlaybackRequested] = useState(false);
19 const playback = createAudioPlaybackSession(player, false, setPlaybackRequested);
20 const [seekError, setSeekError] = useState(false);
21 // Video teardown deactivates the shared iOS session, which would stop this player.
22 useMediaAudioSessionHold(status.playing);
23 const fullscreenVideoOpen = useFullscreenVideoOpen();
24 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the shared audio session in step with the native player status and fullscreen video
25 useEffect(() => {
26 if (fullscreenVideoOpen || status.didJustFinish || status.error) playback.pause();
27 else playback.playingChanged(status.playing);
28 }, [fullscreenVideoOpen, status.didJustFinish, status.error, status.playing, playback]);
29 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to AppState to pause playback in the background, and pauses on cleanup
30 useEffect(() => {
31 const subscription = AppState.addEventListener("change", (state) => {
32 if (state !== "active") playback.pause();
33 });
34 return () => {
35 subscription.remove();
36 playback.pause();
37 };
38 }, [playback]);
39 const seek = (seconds: number) => {
40 setSeekError(false);
41 void player.seekTo(seconds).catch(() => setSeekError(true));
42 };
43 return (
44 <View className="flex-1 items-center justify-center gap-5 p-6">
45 <Text className="text-foreground-muted">
46 {timestamp(status.currentTime)} / {timestamp(status.duration)}
47 </Text>
48 <View className="flex-row items-center gap-4">
49 <Button
50 label="−15s"
51 tone="text"
52 accessibilityLabel="Back 15 seconds"
53 disabled={!status.isLoaded}
54 onPress={() => seek(Math.max(0, status.currentTime - 15))}
55 />
56 <Button
57 label={
58 playbackRequested || status.playing ? "Pause" : !status.isLoaded ? "Loading…" : "Play"
59 }
60 tone="secondary"
61 disabled={!status.isLoaded && !playbackRequested}
62 onPress={() => {
63 if (status.playing || playback.requested) playback.pause();
64 else {
65 setSeekError(false);
66 void playback
67 .play(
68 status.didJustFinish || status.currentTime >= status.duration
69 ? () => player.seekTo(0)
70 : undefined,
71 )
72 .catch(() => setSeekError(true));
73 }
74 }}
75 />
76 <Button
77 label="+15s"
78 tone="text"
79 accessibilityLabel="Forward 15 seconds"
80 disabled={!status.isLoaded}
81 onPress={() => seek(Math.min(status.duration, status.currentTime + 15))}
82 />
83 </View>
84 {status.error || seekError ? (
85 <View className="items-center gap-3">
86 <Text className="text-center text-foreground">
87 This audio could not be played. Try again or save it to open in another app.
88 </Text>
89 <View className="items-center">
90 <Button label="Try again" tone="secondary" onPress={props.onRetry} />
91 </View>
92 </View>
93 ) : null}
94 </View>
95 );
96}