| 1 | import { |
| 2 | deviceToolVersionLabels, |
| 3 | deviceToolUpdateOwnership, |
| 4 | deviceToolUpdatePolicy, |
| 5 | } from "@t3tools/client-runtime/state/device"; |
| 6 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 10 | import * as Haptics from "expo-haptics"; |
| 11 | import { Accelerometer } from "expo-sensors"; |
| 12 | import { Alert, AppState, BackHandler, Pressable, StatusBar, View } from "react-native"; |
| 13 | import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; |
| 14 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 15 | |
| 16 | import { SymbolView } from "../../components/AppSymbol"; |
| 17 | import { AppActivityIndicator } from "../../components/AppActivityIndicator"; |
| 18 | import { AppText } from "../../components/AppText"; |
| 19 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 20 | import { GlassSurface } from "../../components/GlassSurface"; |
| 21 | import { |
| 22 | androidHeaderMenuActions, |
| 23 | findHeaderMenuAction, |
| 24 | } from "../../components/headerMenu.android"; |
| 25 | import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types"; |
| 26 | import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; |
| 27 | import { useEnvironmentQuery } from "../../state/query"; |
| 28 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 29 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 30 | import { DeviceStreamWebView, type DeviceStreamRef } from "./DeviceStreamWebView"; |
| 31 | import { |
| 32 | selectedThreadDevicePreview, |
| 33 | threadDevicePreviews, |
| 34 | type ThreadDevicePreview, |
| 35 | } from "./threadDevicePreviews"; |
| 36 | import { createShakeDetector } from "./shakeDetector"; |
| 37 | |
| 38 | const OVERLAY_ENTERING = FadeIn.duration(160).reduceMotion(ReduceMotion.System); |
| 39 | const OVERLAY_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); |
| 40 | |
| 41 | type 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. */ |
| 47 | export 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 | |
| 66 | function 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 | |
| 346 | function 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 | |
| 368 | function 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 | } |