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