1import {
2 deviceToolVersionLabels,
3 deviceToolUpdateOwnership,
4 deviceToolUpdatePolicy,
5} from "@t3tools/client-runtime/state/device";
6import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native";
7import { EnvironmentId, ThreadId } from "@t3tools/contracts";
8import * as Cause from "effect/Cause";
9import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
10import * as Haptics from "expo-haptics";
11import { Accelerometer } from "expo-sensors";
12import { Alert, AppState, BackHandler, Pressable, StatusBar, View } from "react-native";
13import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated";
14import { useSafeAreaInsets } from "react-native-safe-area-context";
15
16import { SymbolView } from "../../components/AppSymbol";
17import { AppActivityIndicator } from "../../components/AppActivityIndicator";
18import { AppText } from "../../components/AppText";
19import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
20import { GlassSurface } from "../../components/GlassSurface";
21import {
22 androidHeaderMenuActions,
23 findHeaderMenuAction,
24} from "../../components/headerMenu.android";
25import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
26import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device";
27import { useEnvironmentQuery } from "../../state/query";
28import { useAtomCommand } from "../../state/use-atom-command";
29import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
30import { DeviceStreamWebView, type DeviceStreamRef } from "./DeviceStreamWebView";
31import {
32 selectedThreadDevicePreview,
33 threadDevicePreviews,
34 type ThreadDevicePreview,
35} from "./threadDevicePreviews";
36import { createShakeDetector } from "./shakeDetector";
37
38const OVERLAY_ENTERING = FadeIn.duration(160).reduceMotion(ReduceMotion.System);
39const OVERLAY_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System);
40
41type DevicePreviewRouteScreenProps = StaticScreenProps<{
42 readonly environmentId: string;
43 readonly threadId: string;
44}>;
45
46/** Full-screen viewer; controls live in an overlay toggled by the handle or a shake. */
47export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) {
48 const navigation = useNavigation();
49 const onClose = useCallback(() => navigation.goBack(), [navigation]);
50 const { environmentId, threadId } = route.params;
51 // A hand-typed deep link can carry a blank ID, which the branded IDs reject.
52 const isBlankLink = environmentId.trim().length === 0 || threadId.trim().length === 0;
53 useEffect(() => {
54 if (isBlankLink) onClose();
55 }, [isBlankLink, onClose]);
56 if (isBlankLink) return null;
57 return (
58 <DevicePreviewScreen
59 environmentId={EnvironmentId.make(environmentId)}
60 threadId={ThreadId.make(threadId)}
61 onClose={onClose}
62 />
63 );
64}
65
66function DevicePreviewScreen({
67 environmentId,
68 threadId,
69 onClose,
70}: {
71 readonly environmentId: EnvironmentId;
72 readonly threadId: ThreadId;
73 readonly onClose: () => void;
74}) {
75 const insets = useSafeAreaInsets();
76 const { themeVariables } = useAppearancePreferences();
77 const focused = useIsFocused();
78 const [foreground, setForeground] = useState(AppState.currentState !== "background");
79 const [selectedKey, setSelectedKey] = useState<string | null>(null);
80 const [inputConnected, setInputConnected] = useState(false);
81 const [streamAttempt, setStreamAttempt] = useState(0);
82 const [shuttingDown, setShuttingDown] = useState(false);
83 const [controlsVisible, setControlsVisible] = useState(true);
84 const retryHost = useAtomCommand(deviceEnvironment.list);
85 const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false });
86 const streamRef = useRef<DeviceStreamRef>(null);
87 const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} }));
88 const previews = useMemo(
89 () => threadDevicePreviews(state.data, threadId),
90 [state.data, threadId],
91 );
92 const preview = selectedThreadDevicePreview(previews, selectedKey);
93 const onInputConnected = useCallback(async (connected: boolean) => {
94 setInputConnected(connected);
95 // Controls greet the user while connecting, then get out of the stream's way.
96 if (connected) setControlsVisible(false);
97 }, []);
98 useEffect(() => {
99 const subscription = AppState.addEventListener("change", (state) =>
100 setForeground(state !== "background"),
101 );
102 return () => subscription.remove();
103 }, []);
104 useEffect(() => {
105 if (focused && state.data !== null && previews.length === 0) onClose();
106 }, [focused, state.data, previews.length, onClose]);
107 useEffect(() => {
108 if (!focused || !foreground) return;
109 const shaken = createShakeDetector();
110 Accelerometer.setUpdateInterval(50);
111 const subscription = Accelerometer.addListener((sample) => {
112 if (!shaken({ ...sample, timestamp: sample.timestamp * 1000 })) return;
113 void Haptics.selectionAsync();
114 setControlsVisible((visible) => !visible);
115 });
116 return () => subscription.remove();
117 }, [focused, foreground]);
118 // Android back reveals the controls first, so leaving takes a deliberate second press.
119 useEffect(() => {
120 if (controlsVisible) return;
121 const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
122 setControlsVisible(true);
123 return true;
124 });
125 return () => subscription.remove();
126 }, [controlsVisible]);
127
128 const shutDownDevice = async () => {
129 if (!preview || shuttingDown) return;
130 setShuttingDown(true);
131 try {
132 const result = await shutdown({
133 environmentId,
134 input: {
135 hostId: preview.session.hostId,
136 deviceId: preview.session.deviceId,
137 platform: preview.session.platform,
138 },
139 });
140 if (result._tag === "Failure") {
141 Alert.alert("Could not shut down device", String(Cause.squash(result.cause)));
142 }
143 } finally {
144 setShuttingDown(false);
145 }
146 };
147
148 const controls: ScreenHeaderMenuItem[] = [
149 ...(state.data?.hosts
150 .filter(
151 (host) =>
152 state.data?.supportsHostRetry && state.data.hostStatuses[host.id]?.status === "failed",
153 )
154 .map((host) => ({
155 id: `retry-${host.id}`,
156 title: `Retry ${host.label}`,
157 icon: "arrow.clockwise" as const,
158 onPress: () => {
159 void retryHost({ environmentId, input: { retryHostId: host.id } });
160 },
161 })) ?? []),
162 ...(state.data?.supportsToolInspection
163 ? [
164 {
165 id: "check-device-tools",
166 title: "Check device tool versions",
167 icon: "arrow.clockwise" as const,
168 onPress: () => {
169 void retryHost({ environmentId, input: { inspectOnly: true } });
170 },
171 },
172 ]
173 : []),
174 {
175 id: "device-tools",
176 title: "Device tool versions",
177 icon: "info.circle",
178 onPress: () =>
179 Alert.alert(
180 "Device tool versions",
181 deviceToolUpdateOwnership +
182 "\n\n" +
183 deviceToolUpdatePolicy(
184 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
185 ) +
186 "\n\n" +
187 deviceToolVersionLabels(
188 state.data?.hosts.find((host) => host.id === preview?.session.hostId)?.tools,
189 ).join("\n") +
190 "\n" +
191 (state.data?.hosts.find((host) => host.id === preview?.session.hostId)
192 ?.toolInspectionError ??
193 state.data?.hostStatuses[preview?.session.hostId ?? ""]?.detail ??
194 ""),
195 ),
196 },
197 {
198 id: "reload",
199 title: "Reload stream",
200 icon: "arrow.clockwise" as const,
201 disabled: !preview || shuttingDown,
202 onPress: () => {
203 setInputConnected(false);
204 setStreamAttempt((attempt) => attempt + 1);
205 },
206 },
207 ...(preview?.session.platform === "android"
208 ? [
209 {
210 id: "back",
211 title: "Back",
212 icon: "arrow.left",
213 disabled: !inputConnected,
214 onPress: () => streamRef.current?.back(),
215 },
216 ]
217 : []),
218 {
219 id: "app-switcher",
220 title: "App switcher",
221 icon: "square.on.square",
222 disabled: !inputConnected,
223 onPress: () => streamRef.current?.appSwitcher(),
224 },
225 ...(preview?.session.platform === "ios"
226 ? [
227 {
228 id: "rotate",
229 title: "Rotate device",
230 icon: "arrow.clockwise" as const,
231 disabled: !inputConnected,
232 onPress: () => streamRef.current?.rotate(),
233 },
234 ]
235 : []),
236 {
237 id: "shutdown",
238 title: shuttingDown ? "Shutting down…" : "Shut down device",
239 icon: "power",
240 disabled: !preview || shuttingDown,
241 onPress: () => void shutDownDevice(),
242 },
243 ];
244 const pressHome = () => streamRef.current?.home();
245 const menuItems: ScreenHeaderMenuItem[] = [
246 ...(previews.length > 1
247 ? [
248 {
249 id: "devices",
250 title: "Devices",
251 inline: true,
252 items: previews.map((device) => ({
253 id: device.key,
254 title: device.name,
255 subtitle: device.description,
256 selected: device.key === preview?.key,
257 onPress: () => setSelectedKey(device.key),
258 })),
259 },
260 ]
261 : []),
262 ...controls,
263 ];
264 return (
265 <View className="flex-1" style={{ backgroundColor: themeVariables["--color-sheet-solid"] }}>
266 <StatusBar hidden animated />
267 {preview && focused && foreground ? (
268 <OpenDevicePreview
269 key={`${preview.key}:${streamAttempt}`}
270 environmentId={environmentId}
271 preview={preview}
272 streamRef={streamRef}
273 onInputConnected={onInputConnected}
274 />
275 ) : (
276 <View className="flex-1 items-center justify-center gap-4 px-6">
277 {state.error ? (
278 <>
279 <AppText selectable className="text-center text-sm text-foreground-muted">
280 {state.error}
281 </AppText>
282 <Pressable
283 accessibilityRole="button"
284 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
285 onPress={state.refresh}
286 >
287 <AppText className="text-secondary-foreground">Retry</AppText>
288 </Pressable>
289 </>
290 ) : focused && foreground ? (
291 <AppActivityIndicator color={themeVariables["--color-icon"]} />
292 ) : null}
293 </View>
294 )}
295 {controlsVisible ? (
296 <Animated.View
297 entering={OVERLAY_ENTERING}
298 exiting={OVERLAY_EXITING}
299 pointerEvents="box-none"
300 className="absolute inset-0"
301 >
302 {inputConnected ? (
303 <Pressable
304 accessibilityLabel="Hide device controls"
305 className="absolute inset-0 bg-black/30"
306 onPress={() => setControlsVisible(false)}
307 />
308 ) : null}
309 <View className="px-3" style={{ paddingTop: Math.max(insets.top, 12) }}>
310 <GlassSurface className="flex-row items-center gap-1 p-1">
311 <ControlPill
312 accessibilityLabel="Close device preview"
313 icon="xmark"
314 onPress={onClose}
315 />
316 <AppText numberOfLines={1} className="flex-1 text-center font-t3-medium text-base">
317 {preview?.name ?? "Devices"}
318 </AppText>
319 <ControlPill
320 accessibilityLabel="Home"
321 icon="house"
322 disabled={!inputConnected}
323 onPress={pressHome}
324 />
325 <DeviceOptionsMenu items={menuItems} />
326 </GlassSurface>
327 </View>
328 </Animated.View>
329 ) : (
330 <Pressable
331 accessibilityRole="button"
332 accessibilityLabel="Show device controls"
333 accessibilityHint="Shaking the phone also shows them"
334 hitSlop={12}
335 className="absolute h-6 w-16 items-center justify-center self-center"
336 style={{ top: Math.max(insets.top - 18, 4) }}
337 onPress={() => setControlsVisible(true)}
338 >
339 <View className="h-1.5 w-10 rounded-full bg-white/40" />
340 </Pressable>
341 )}
342 </View>
343 );
344}
345
346function DeviceOptionsMenu({ items }: { readonly items: ReadonlyArray<ScreenHeaderMenuItem> }) {
347 return (
348 <ControlPillMenu
349 actions={androidHeaderMenuActions(items)}
350 isAnchoredToRight
351 title="Device options"
352 onPressAction={({ nativeEvent }) => {
353 const action = findHeaderMenuAction(items, nativeEvent.event);
354 if (action && !action.disabled) action.onPress();
355 }}
356 >
357 <Pressable
358 accessibilityLabel="Device options"
359 accessibilityRole="button"
360 className="size-11 items-center justify-center rounded-full bg-subtle"
361 >
362 <SymbolView name="ellipsis" size={18} tintColorClassName="accent-icon" />
363 </Pressable>
364 </ControlPillMenu>
365 );
366}
367
368function OpenDevicePreview({
369 environmentId,
370 preview,
371 streamRef,
372 onInputConnected,
373}: {
374 readonly environmentId: EnvironmentId;
375 readonly preview: ThreadDevicePreview;
376 readonly streamRef: RefObject<DeviceStreamRef | null>;
377 readonly onInputConnected: (connected: boolean) => Promise<void>;
378}) {
379 const { session } = preview;
380 const { themeVariables } = useAppearancePreferences();
381 const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId);
382 const onUnauthorized = useCallback(
383 async () => refreshDeviceHubAccess(environmentId),
384 [environmentId],
385 );
386 useEffect(() => {
387 refreshDeviceHubAccess(environmentId);
388 return () => void onInputConnected(false);
389 }, [environmentId, onInputConnected]);
390 return access ? (
391 <DeviceStreamWebView
392 ref={streamRef}
393 access={access}
394 platform={session.platform}
395 deviceId={session.deviceId}
396 colors={{
397 background: themeVariables["--color-sheet-solid"],
398 foreground: themeVariables["--color-foreground"],
399 muted: themeVariables["--color-foreground-muted"],
400 buttonBackground: themeVariables["--color-secondary"],
401 buttonForeground: themeVariables["--color-secondary-foreground"],
402 buttonBorder: themeVariables["--color-secondary-border"],
403 }}
404 onUnauthorized={onUnauthorized}
405 onInputConnected={onInputConnected}
406 />
407 ) : (
408 <View className="flex-1 items-center justify-center gap-4 px-6">
409 {error ? (
410 <>
411 <AppText selectable className="text-center text-sm text-foreground-muted">
412 {error}
413 </AppText>
414 <Pressable
415 accessibilityRole="button"
416 className="rounded-full border border-secondary-border bg-secondary px-6 py-3"
417 onPress={refresh}
418 >
419 <AppText className="text-secondary-foreground">Retry</AppText>
420 </Pressable>
421 </>
422 ) : (
423 <>
424 <AppActivityIndicator color={themeVariables["--color-icon"]} />
425 <AppText className="text-sm text-foreground-muted">Connecting to device...</AppText>
426 </>
427 )}
428 </View>
429 );
430}