1import { useIsFocused } from "@react-navigation/native";
2import { requireNativeModule } from "expo";
3import { useEffect, useEffectEvent, useId, useState } from "react";
4import { Alert, Keyboard } from "react-native";
5
6import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview";
7import { mediaVideoPreviewUri, type VideoPreviewSource } from "../lib/videoPreviewSource";
8import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets";
9import { usePreparedConnection } from "../state/session";
10
11export type { VideoPreviewSource } from "../lib/videoPreviewSource";
12
13const NativeControls = requireNativeModule<{
14 presentVideo(
15 uri: string,
16 title: string,
17 sourceIdentifier: string,
18 identifier: string,
19 ): Promise<void>;
20 dismissVideo(identifier: string): Promise<void>;
21}>("T3NativeControls");
22
23function NativeVideoPreview(props: {
24 readonly source: VideoPreviewSource;
25 readonly onRequestClose: () => void;
26}) {
27 const { source } = props;
28 const localAttachment = source.type === "local" ? source.attachment : null;
29 const identifier = useId();
30 const onRequestClose = useEffectEvent(props.onRequestClose);
31 const environmentId =
32 source.type === "media" && "environmentId" in source ? source.environmentId : null;
33 const resource = source.type === "media" && "resource" in source ? source.resource : null;
34 const preparedConnection = usePreparedConnection(environmentId);
35 const assetUrl = useAssetUrlState(environmentId, resource);
36 const refreshAssetUrl = useEffectEvent(useRefreshAssetUrl(environmentId, resource));
37 const name = source.type === "media" ? source.name : source.attachment.name;
38 // The first minted URL is kept so a background refresh does not restart playback.
39 const resolvedUrl =
40 source.type === "media"
41 ? mediaVideoPreviewUri(source, assetUrl._tag === "Success" ? assetUrl.url : null)
42 : null;
43 const [playbackUrl, setPlaybackUrl] = useState<string | null>(resolvedUrl);
44 const loadError =
45 resource !== null && playbackUrl === null
46 ? preparedConnection._tag === "None"
47 ? "Reconnect to this environment and open the video again."
48 : assetUrl._tag === "Failure"
49 ? "Could not load this video. Check the connection and try again."
50 : null
51 : null;
52
53 useEffect(() => Keyboard.dismiss(), []);
54 useEffect(() => {
55 if (playbackUrl === null && resolvedUrl !== null) setPlaybackUrl(resolvedUrl);
56 }, [playbackUrl, resolvedUrl]);
57 useEffect(() => {
58 if (!loadError) return;
59 Alert.alert("Could not open video", loadError);
60 onRequestClose();
61 }, [loadError]);
62
63 useEffect(() => {
64 if (localAttachment === null && playbackUrl === null) return;
65 const controller = new AbortController();
66 let ready = false;
67 void (async () => {
68 const file =
69 localAttachment !== null
70 ? await loadLocalAttachmentPreview(localAttachment, controller.signal)
71 : null;
72 if (localAttachment !== null && !file) return;
73 try {
74 if (controller.signal.aborted) return;
75 ready = true;
76 await NativeControls.presentVideo(
77 file?.uri ?? playbackUrl!,
78 name,
79 source.sourceIdentifier ?? "",
80 identifier,
81 );
82 if (!controller.signal.aborted) onRequestClose();
83 } finally {
84 // Native completion follows dismissal, so local playback keeps its file lease.
85 file?.dispose();
86 }
87 })().catch((error: unknown) => {
88 if (controller.signal.aborted) return;
89 // AVKit gives no retry, so re-mint now; the cached URL may simply have expired.
90 if (ready) void refreshAssetUrl();
91 Alert.alert(
92 "Could not open video",
93 ready
94 ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original."
95 : error instanceof Error
96 ? error.message
97 : "Could not load this video.",
98 );
99 onRequestClose();
100 });
101 return () => {
102 controller.abort();
103 void NativeControls.dismissVideo(identifier).catch(() => undefined);
104 };
105 }, [localAttachment, name, source.sourceIdentifier, playbackUrl, identifier]);
106
107 return null;
108}
109
110export function VideoPreviewModal(props: {
111 readonly source: VideoPreviewSource | null;
112 readonly onRequestClose: () => void;
113}) {
114 const isFocused = useIsFocused();
115 const hasSource = props.source !== null;
116 const onRequestClose = useEffectEvent(props.onRequestClose);
117 useEffect(() => {
118 if (!isFocused && hasSource) onRequestClose();
119 }, [isFocused, hasSource]);
120
121 if (!props.source || !isFocused) return null;
122 return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />;
123}