1import { useIsFocused } from "@react-navigation/native";
2import { videoMimeType } from "@t3tools/shared/video";
3import { useEffect, useRef, useState } from "react";
4import { Keyboard, Modal, Pressable, View } from "react-native";
5import { useSafeAreaInsets } from "react-native-safe-area-context";
6
7import { AppActivityIndicator } from "./AppActivityIndicator";
8import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
9import { useMediaActions } from "../state/mediaActions";
10import { type MediaActionsSource } from "../lib/mediaActionsSource";
11import {
12 mediaVideoPreviewUri,
13 mediaVideoThumbnailKey,
14 type LocalVideoPreviewSource,
15 type MediaVideoPreviewSource,
16 type VideoPreviewSource,
17} from "../lib/videoPreviewSource";
18import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
19import { usePreparedConnection } from "../state/session";
20import { AppText } from "./AppText";
21import { SymbolView } from "./AppSymbol";
22import { MediaActionsMenu } from "./MediaActionsMenu";
23import { MediaSourceCaption } from "./MediaSourceCaption";
24import { MediaVideoPlayer } from "./MediaVideoPlayer";
25
26export type { VideoPreviewSource } from "../lib/videoPreviewSource";
27
28interface PlaybackState {
29 readonly uri: string | null;
30 readonly resolvePlaybackUri?: () => Promise<string | null>;
31 readonly unavailable: boolean;
32 readonly error: string | null;
33 readonly actionsSource: MediaActionsSource | undefined;
34}
35
36function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState {
37 const environmentId = "environmentId" in source ? source.environmentId : null;
38 const resource = "resource" in source ? source.resource : null;
39 const connection = usePreparedConnection(environmentId);
40 const asset = useAssetUrlState(environmentId, resource);
41 const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource);
42 const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null);
43 return {
44 uri,
45 ...(resource !== null
46 ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) }
47 : {}),
48 unavailable:
49 uri === null &&
50 environmentId !== null &&
51 (connection._tag === "None" || asset._tag === "Failure"),
52 error: null,
53 actionsSource: source.actionsSource,
54 };
55}
56
57function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState {
58 const { attachment } = source;
59 const [uri, setUri] = useState<string | null>(null);
60 const [error, setError] = useState<string | null>(null);
61 // Only a different file needs a new lease; a metadata update on the same
62 // draft must not dispose the file Android is still playing.
63 const attachmentRef = useRef(attachment);
64 attachmentRef.current = attachment;
65 const { id: attachmentId, fileUri } = attachment;
66 useEffect(() => {
67 setUri(null);
68 setError(null);
69 const controller = new AbortController();
70 const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal);
71 void loading.then(
72 (file) => {
73 if (file === null) return;
74 if (controller.signal.aborted) file.dispose();
75 else setUri(file.uri);
76 },
77 (cause: unknown) => {
78 if (!controller.signal.aborted) {
79 setError(cause instanceof Error ? cause.message : "Could not load this video.");
80 }
81 },
82 );
83 return () => {
84 controller.abort();
85 void loading.then(
86 (file) => file?.dispose(),
87 () => undefined,
88 );
89 };
90 }, [attachmentId, fileUri]);
91 const mimeType = videoMimeType(attachment) ?? attachment.mimeType;
92 return {
93 uri,
94 unavailable: error !== null,
95 error,
96 actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri },
97 };
98}
99
100function MediaPreviewModal(props: {
101 readonly source: MediaVideoPreviewSource;
102 readonly onRequestClose: () => void;
103}) {
104 return (
105 <OpenVideoPreviewModal
106 name={props.source.name}
107 thumbnailKey={mediaVideoThumbnailKey(props.source)}
108 playback={useMediaPlayback(props.source)}
109 onRequestClose={props.onRequestClose}
110 />
111 );
112}
113
114function LocalPreviewModal(props: {
115 readonly source: LocalVideoPreviewSource;
116 readonly onRequestClose: () => void;
117}) {
118 return (
119 <OpenVideoPreviewModal
120 name={props.source.attachment.name}
121 thumbnailKey={`local:${props.source.attachment.id}`}
122 playback={useLocalPlayback(props.source)}
123 onRequestClose={props.onRequestClose}
124 />
125 );
126}
127
128function OpenVideoPreviewModal(props: {
129 readonly name: string;
130 readonly thumbnailKey: string;
131 readonly playback: PlaybackState;
132 readonly onRequestClose: () => void;
133}) {
134 const { playback } = props;
135 const insets = useSafeAreaInsets();
136 const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose);
137
138 useEffect(() => Keyboard.dismiss(), []);
139 return (
140 <Modal
141 visible
142 animationType="fade"
143 presentationStyle="fullScreen"
144 onRequestClose={props.onRequestClose}
145 statusBarTranslucent
146 navigationBarTranslucent
147 >
148 <View
149 className="flex-1 bg-black"
150 style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
151 >
152 <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2">
153 <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}>
154 {props.name}
155 </AppText>
156 <MediaActionsMenu media={mediaActions} inModal />
157 <Pressable
158 accessibilityRole="button"
159 accessibilityLabel="Close video"
160 onPress={props.onRequestClose}
161 className="size-12 items-center justify-center"
162 >
163 <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" />
164 </Pressable>
165 </View>
166 <MediaSourceCaption source={mediaActions.title} />
167 {playback.uri === null && !playback.unavailable ? (
168 <View className="flex-1 items-center justify-center gap-3 px-6">
169 <AppActivityIndicator color="#ffffff" />
170 <AppText className="text-sm text-white/80">Loading video...</AppText>
171 </View>
172 ) : (
173 <MediaVideoPlayer
174 uri={playback.uri}
175 resolvePlaybackUri={playback.resolvePlaybackUri}
176 name={props.name}
177 thumbnailKey={props.thumbnailKey}
178 unavailable={playback.unavailable}
179 paused={mediaActions.sharing}
180 expanded
181 autoPlay
182 />
183 )}
184 {playback.error ? (
185 <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80">
186 {playback.error}
187 </AppText>
188 ) : null}
189 <Pressable
190 accessibilityRole="button"
191 accessibilityLabel="Save or share video"
192 disabled={playback.uri === null || mediaActions.sharing}
193 onPress={mediaActions.share}
194 className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4"
195 >
196 <AppText className="font-t3-medium text-base text-white">
197 {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"}
198 </AppText>
199 </Pressable>
200 </View>
201 </Modal>
202 );
203}
204
205export function VideoPreviewModal(props: {
206 readonly source: VideoPreviewSource | null;
207 readonly onRequestClose: () => void;
208}) {
209 const isFocused = useIsFocused();
210 const hasSource = props.source !== null;
211 useEffect(() => {
212 if (!isFocused && hasSource) props.onRequestClose();
213 }, [isFocused, hasSource, props.onRequestClose]);
214 const { source } = props;
215 if (source === null || !isFocused) return null;
216 return source.type === "local" ? (
217 <LocalPreviewModal
218 key={`local:${source.attachment.id}:${source.attachment.fileUri}`}
219 source={source}
220 onRequestClose={props.onRequestClose}
221 />
222 ) : (
223 <MediaPreviewModal
224 key={mediaVideoThumbnailKey(source)}
225 source={source}
226 onRequestClose={props.onRequestClose}
227 />
228 );
229}