1import deviceStreamScript from "@t3tools/mobile-device-stream";
2import {
3 useEffect,
4 useEffectEvent,
5 useImperativeHandle,
6 useLayoutEffect,
7 useMemo,
8 useRef,
9 useState,
10 type Ref,
11} from "react";
12import { Platform, Pressable, View } from "react-native";
13import { WebView } from "react-native-webview";
14import type { DeviceStreamStatus } from "@t3tools/client-runtime/device/stream";
15
16import { AppActivityIndicator } from "../../components/AppActivityIndicator";
17import { AppText } from "../../components/AppText";
18
19import {
20 deviceStreamDocument,
21 deviceStreamMessage,
22 type DeviceStreamConfiguration,
23} from "./device-stream-document";
24
25export interface DeviceStreamRef {
26 home: () => void;
27 back: () => void;
28 appSwitcher: () => void;
29 rotate: () => void;
30}
31
32type NativeStreamBridge = {
33 readonly ref?: Ref<DeviceStreamRef>;
34 readonly onUnauthorized: () => Promise<void>;
35 readonly onInputConnected: (connected: boolean) => Promise<void>;
36};
37
38export function DeviceStreamWebView({
39 ref,
40 ...props
41}: DeviceStreamConfiguration & NativeStreamBridge) {
42 const [attempt, setAttempt] = useState(0);
43 const processRetried = useRef(false);
44 const configuration = JSON.stringify({
45 access: props.access,
46 platform: props.platform,
47 deviceId: props.deviceId,
48 colors: props.colors,
49 });
50 return (
51 <DeviceStreamDocumentView
52 key={`${attempt}:${configuration}`}
53 ref={ref}
54 configuration={configuration}
55 background={props.colors.background}
56 onUnauthorized={props.onUnauthorized}
57 onInputConnected={props.onInputConnected}
58 onRetry={() => {
59 processRetried.current = false;
60 setAttempt((attempt) => attempt + 1);
61 void props.onUnauthorized();
62 }}
63 onStreaming={() => {
64 processRetried.current = false;
65 }}
66 onRecoverProcess={() => {
67 if (processRetried.current) return false;
68 processRetried.current = true;
69 setAttempt((attempt) => attempt + 1);
70 void props.onUnauthorized();
71 return true;
72 }}
73 />
74 );
75}
76
77function DeviceStreamDocumentView({
78 ref,
79 configuration,
80 background,
81 onUnauthorized,
82 onInputConnected,
83 onRetry,
84 onStreaming,
85 onRecoverProcess,
86}: NativeStreamBridge & {
87 readonly configuration: string;
88 readonly background: string;
89 readonly onRetry: () => void;
90 readonly onStreaming: () => void;
91 readonly onRecoverProcess: () => boolean;
92}) {
93 const webView = useRef<WebView<object>>(null);
94 const active = useRef(true);
95 const failed = useRef(false);
96 const [status, setStatus] = useState<DeviceStreamStatus>("connecting");
97 const [error, setError] = useState<string | null>(null);
98 const [started, setStarted] = useState(false);
99 const fail = (message: string) => {
100 if (!active.current || failed.current) return;
101 failed.current = true;
102 void onInputConnected(false);
103 webView.current?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
104 setError(message);
105 setStatus("error");
106 };
107 // The shared transport owns video timeouts once the document acknowledges startup.
108 const bootstrapTimedOut = useEffectEvent(() =>
109 fail("Device viewer could not start. Reconnect to try again."),
110 );
111 useEffect(() => {
112 if (started) return;
113 const timer = setTimeout(bootstrapTimedOut, 15_000);
114 return () => clearTimeout(timer);
115 }, [started]);
116 const source = useMemo(
117 () => ({
118 html: deviceStreamDocument(configuration, deviceStreamScript),
119 // Android WebCodecs needs a secure document; streams still use the environment's URLs.
120 baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///",
121 }),
122 [configuration],
123 );
124 const command = (button: keyof DeviceStreamRef) => {
125 webView.current?.injectJavaScript(
126 `window.T3DeviceStream?.command(${JSON.stringify(button)}); true;`,
127 );
128 };
129 useImperativeHandle(ref, () => ({
130 home: () => command("home"),
131 back: () => command("back"),
132 appSwitcher: () => command("appSwitcher"),
133 rotate: () => command("rotate"),
134 }));
135 const resetInput = useEffectEvent(() => void onInputConnected(false));
136 useLayoutEffect(() => {
137 active.current = true;
138 resetInput();
139 const view = webView.current;
140 return () => {
141 active.current = false;
142 view?.injectJavaScript("window.T3DeviceStream?.stop(); true;");
143 };
144 }, []);
145 const processTerminated = () => {
146 if (!active.current || failed.current) return;
147 void onInputConnected(false);
148 if (!onRecoverProcess()) fail("Device viewer stopped. Reconnect to try again.");
149 };
150 return (
151 <View className="flex-1" style={{ backgroundColor: background }}>
152 <WebView<object>
153 ref={webView}
154 source={source}
155 originWhitelist={["*"]}
156 scrollEnabled={false}
157 bounces={false}
158 mixedContentMode="always"
159 allowUniversalAccessFromFileURLs
160 contentInsetAdjustmentBehavior="never"
161 setSupportMultipleWindows={false}
162 style={{ flex: 1, backgroundColor: background }}
163 onError={() => fail("Device viewer could not load. Reconnect to try again.")}
164 onHttpError={() => fail("Device viewer could not load. Reconnect to try again.")}
165 onContentProcessDidTerminate={processTerminated}
166 onRenderProcessGone={processTerminated}
167 onShouldStartLoadWithRequest={(request) =>
168 request.url === "about:blank" || request.url === source.baseUrl
169 }
170 onMessage={(event) => {
171 if (!active.current || failed.current) return;
172 const message = deviceStreamMessage(event.nativeEvent.data);
173 if (message?.type === "unauthorized") void onUnauthorized();
174 else if (message?.type === "input") void onInputConnected(message.connected);
175 else if (message?.type === "retry") onRetry();
176 else if (message?.type === "status") {
177 setStarted(true);
178 if (message.status === "error") fail(message.detail ?? "Device stream failed.");
179 else {
180 setStatus(message.status);
181 if (message.status === "streaming") onStreaming();
182 }
183 }
184 }}
185 />
186 {status !== "streaming" ? (
187 <View
188 className="absolute inset-0 items-center justify-center gap-4 px-6"
189 style={{ backgroundColor: background }}
190 >
191 {status === "connecting" ? <AppActivityIndicator colorClassName="accent-icon" /> : null}
192 <AppText
193 accessibilityLiveRegion="polite"
194 className="text-center text-sm text-foreground-muted"
195 >
196 {status === "error" ? error : "Connecting to device..."}
197 </AppText>
198 {status === "error" ? (
199 <Pressable
200 accessibilityRole="button"
201 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
202 onPress={onRetry}
203 >
204 <AppText className="text-secondary-foreground">Reconnect</AppText>
205 </Pressable>
206 ) : null}
207 </View>
208 ) : null}
209 </View>
210 );
211}