Skip to content
Source
Sign inSign up

VideoPreviewModal.tsx

160 lines6.2 KB

1import * as Dialog from "./Dialog";
2import { requireNativeModule } from "expo";
3import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import { AsyncResult } from "effect/reactivity";
5import { useEffect, useEffectEvent, useId } from "react";
6​
7import { loadLocalAttachmentPreview } from "../../lib/localAttachmentPreview";
8import { usePreviewPresentation, videoPreviewKey } from "../../lib/previewPresentation";
9import { fullscreenVideoClosed, fullscreenVideoOpened } from "../../lib/videoAudioSession";
10import { mediaVideoPreviewUri, type VideoPreviewSource } from "../../lib/videoPreviewSource";
11import { refreshAssetUrl } from "../../state/assets";
12import { usePreparedConnection } from "../../state/session";
13import {
14 acquireAudioSession,
15 activateAudioSession,
16 isAudioSessionOwner,
17 releaseAudioSession,
18} from "../../lib/audioSession";
19​
20export type { VideoPreviewSource } from "../../lib/videoPreviewSource";
21​
22const NativeControls = requireNativeModule<{
23 presentVideo(
24 uri: string,
25 title: string,
26 sourceIdentifier: string,
27 identifier: string,
28 ): Promise<void>;
29 dismissVideo(identifier: string): Promise<void>;
30 addListener(
31 event: "videoPlaybackChange",
32 listener: (event: { identifier: string; isPlaying: boolean }) => void,
33 ): { remove(): void };
34}>("T3NativeControls");
35​
36function NativeVideoPreview(props: {
37 readonly source: VideoPreviewSource;
38 readonly onRequestClose: () => void;
39}) {
40 const { source } = props;
41 const localAttachment = source.type === "local" ? source.attachment : null;
42 const identifier = useId();
43 const onRequestClose = useEffectEvent(props.onRequestClose);
44 const environmentId =
45 source.type === "media" && "environmentId" in source ? source.environmentId : null;
46 const preparedConnection = usePreparedConnection(environmentId);
47 const name = source.type === "media" ? source.name : source.attachment.name;
48 // Mints a fresh URL for each presentation: AVKit retains the URL and offers no retry,
49 // so a cached link that expired while the app was suspended would fail to play.
50 const resolvePlaybackUrl = useEffectEvent(async (): Promise<string> => {
51 if (source.type !== "media") throw new Error("Could not load this video.");
52 if (!("resource" in source)) return source.uri;
53 const url = await refreshAssetUrl(source.environmentId, source.resource).catch(() => null);
54 const playbackUrl = mediaVideoPreviewUri(source, url);
55 if (playbackUrl !== null) return playbackUrl;
56 throw new Error(
57 preparedConnection._tag === "None"
58 ? "Reconnect to this environment and open the video again."
59 : "Could not load this video. Check the connection and try again.",
60 );
61 });
62 const reauthorize = useEffectEvent(() => {
63 if (source.type === "media" && "resource" in source) {
64 void refreshAssetUrl(source.environmentId, source.resource).catch(() => {});
65 }
66 });
67​
68 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- presents the native fullscreen video and owns its audio session and file lease until cleanup
69 useEffect(() => {
70 const controller = new AbortController();
71 let token: ReturnType<typeof acquireAudioSession> | null = null;
72 const release = () => {
73 if (token) void releaseAudioSession(token).catch(() => {});
74 token = null;
75 };
76 const acquire = () => {
77 const next = acquireAudioSession("playback", () => {
78 release();
79 void NativeControls.dismissVideo(identifier).catch(() => {});
80 });
81 if (!isAudioSessionOwner(next)) {
82 void NativeControls.dismissVideo(identifier).catch(() => {});
83 return null;
84 }
85 token = next;
86 return next;
87 };
88 const subscription = NativeControls.addListener("videoPlaybackChange", (event) => {
89 if (event.identifier !== identifier || controller.signal.aborted) return;
90 if (event.isPlaying) {
91 if (!token) acquire();
92 } else release();
93 });
94 let ready = false;
95 fullscreenVideoOpened();
96 void (async () => {
97 const file =
98 localAttachment !== null
99 ? await loadLocalAttachmentPreview(localAttachment, controller.signal)
100 : null;
101 if (localAttachment !== null && !file) return;
102 const uri = file?.uri ?? (await resolvePlaybackUrl());
103 if (controller.signal.aborted) {
104 file?.dispose();
105 return;
106 }
107 const current = acquire();
108 if (!current || !(await activateAudioSession(current, true)) || controller.signal.aborted) {
109 release();
110 file?.dispose();
111 if (!controller.signal.aborted) onRequestClose();
112 return;
113 }
114 ready = true;
115 const presented = await settlePromise(() =>
116 NativeControls.presentVideo(uri, name, source.sourceIdentifier ?? "", identifier),
117 );
118 // Native completion follows dismissal, so local playback keeps its file lease.
119 file?.dispose();
120 release();
121 if (AsyncResult.isFailure(presented)) throw squashAtomCommandFailure(presented);
122 if (!controller.signal.aborted) onRequestClose();
123 })().catch((error: unknown) => {
124 release();
125 if (controller.signal.aborted) return;
126 // AVKit gives no retry, so re-mint now; the minted URL may already be unusable.
127 if (ready) reauthorize();
128 Dialog.alert(
129 "Could not open video",
130 ready
131 ? "This video couldn't be loaded or played. Check the connection, or touch and hold the video to save or share the original."
132 : error instanceof Error
133 ? error.message
134 : "Could not load this video.",
135 );
136 onRequestClose();
137 });
138 return () => {
139 controller.abort();
140 fullscreenVideoClosed();
141 subscription.remove();
142 release();
143 void NativeControls.dismissVideo(identifier).catch(() => undefined);
144 };
145 }, [localAttachment, name, source.sourceIdentifier, identifier]);
146​
147 return null;
149​
150export function VideoPreviewModal(props: {
151 readonly source: VideoPreviewSource | null;
152 readonly onRequestClose: () => void;
153}) {
154 usePreviewPresentation(
155 props.source === null ? null : videoPreviewKey(props.source),
156 props.onRequestClose,
157 );
158 if (!props.source) return null;
159 return <NativeVideoPreview source={props.source} onRequestClose={props.onRequestClose} />;