| ... | ... | @@ -4,16 +4,32 @@ import { |
| 4 | 4 | deviceToolUpdatePolicy, |
| 5 | 5 | } from "@t3tools/client-runtime/state/device"; |
| 6 | 6 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | | import { createNativeStackNavigator } from "@react-navigation/native-stack"; |
| 8 | 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | 8 | import * as Cause from "effect/Cause"; |
| 10 | 9 | import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; |
| 11 | | import { ActivityIndicator, Alert, AppState, Platform, Pressable, View } from "react-native"; |
| 10 | import * as Haptics from "expo-haptics"; |
| 11 | import { Accelerometer } from "expo-sensors"; |
| 12 | import { |
| 13 | ActivityIndicator, |
| 14 | Alert, |
| 15 | AppState, |
| 16 | BackHandler, |
| 17 | Pressable, |
| 18 | StatusBar, |
| 19 | View, |
| 20 | } from "react-native"; |
| 21 | import Animated, { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"; |
| 12 | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | 23 | |
| 24 | import { SymbolView } from "../../components/AppSymbol"; |
| 14 | 25 | import { AppText } from "../../components/AppText"; |
| 15 | | import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader"; |
| 16 | | import { NativeHeaderToolbar } from "../../native/StackHeader"; |
| 26 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 27 | import { GlassSurface } from "../../components/GlassSurface"; |
| 28 | import { |
| 29 | androidHeaderMenuActions, |
| 30 | findHeaderMenuAction, |
| 31 | } from "../../components/headerMenu.android"; |
| 32 | import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types"; |
| 17 | 33 | import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; |
| 18 | 34 | import { useEnvironmentQuery } from "../../state/query"; |
| 19 | 35 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | ... | @@ -24,40 +40,26 @@ import { |
| 24 | 40 | threadDevicePreviews, |
| 25 | 41 | type ThreadDevicePreview, |
| 26 | 42 | } from "./threadDevicePreviews"; |
| 43 | import { createShakeDetector } from "./shakeDetector"; |
| 27 | 44 | |
| 28 | | const DevicePreviewStack = createNativeStackNavigator<{ DevicePreview: undefined }>(); |
| 45 | const OVERLAY_ENTERING = FadeIn.duration(160).reduceMotion(ReduceMotion.System); |
| 46 | const OVERLAY_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); |
| 29 | 47 | |
| 30 | 48 | type DevicePreviewRouteScreenProps = StaticScreenProps<{ |
| 31 | 49 | readonly environmentId: string; |
| 32 | 50 | readonly threadId: string; |
| 33 | 51 | }>; |
| 34 | 52 | |
| 35 | | /** The nested native stack supplies the navigation bar inside the modal. */ |
| 53 | /** Full-screen viewer; controls live in an overlay toggled by the handle or a shake. */ |
| 36 | 54 | export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { |
| 37 | 55 | const navigation = useNavigation(); |
| 38 | 56 | const onClose = useCallback(() => navigation.goBack(), [navigation]); |
| 39 | 57 | return ( |
| 40 | | <View collapsable={false} className="flex-1 bg-sheet"> |
| 41 | | <DevicePreviewStack.Navigator |
| 42 | | screenOptions={{ |
| 43 | | headerShown: Platform.OS === "ios", |
| 44 | | headerBackVisible: false, |
| 45 | | headerShadowVisible: false, |
| 46 | | headerTransparent: false, |
| 47 | | headerTitleStyle: { fontSize: 17, fontWeight: "600" }, |
| 48 | | }} |
| 49 | | > |
| 50 | | <DevicePreviewStack.Screen name="DevicePreview"> |
| 51 | | {() => ( |
| 52 | | <DevicePreviewScreen |
| 53 | | environmentId={EnvironmentId.make(route.params.environmentId)} |
| 54 | | threadId={ThreadId.make(route.params.threadId)} |
| 55 | | onClose={onClose} |
| 56 | | /> |
| 57 | | )} |
| 58 | | </DevicePreviewStack.Screen> |
| 59 | | </DevicePreviewStack.Navigator> |
| 60 | | </View> |
| 58 | <DevicePreviewScreen |
| 59 | environmentId={EnvironmentId.make(route.params.environmentId)} |
| 60 | threadId={ThreadId.make(route.params.threadId)} |
| 61 | onClose={onClose} |
| 62 | /> |
| 61 | 63 | ); |
| 62 | 64 | } |
| 63 | 65 | |
| ... | ... | @@ -78,6 +80,7 @@ function DevicePreviewScreen({ |
| 78 | 80 | const [inputConnected, setInputConnected] = useState(false); |
| 79 | 81 | const [streamAttempt, setStreamAttempt] = useState(0); |
| 80 | 82 | const [shuttingDown, setShuttingDown] = useState(false); |
| 83 | const [controlsVisible, setControlsVisible] = useState(true); |
| 81 | 84 | const retryHost = useAtomCommand(deviceEnvironment.list); |
| 82 | 85 | const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); |
| 83 | 86 | const streamRef = useRef<DeviceStreamRef>(null); |
| ... | ... | @@ -87,10 +90,11 @@ function DevicePreviewScreen({ |
| 87 | 90 | [state.data, threadId], |
| 88 | 91 | ); |
| 89 | 92 | const preview = selectedThreadDevicePreview(previews, selectedKey); |
| 90 | | const onInputConnected = useCallback( |
| 91 | | async (connected: boolean) => setInputConnected(connected), |
| 92 | | [], |
| 93 | | ); |
| 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 | }, []); |
| 94 | 98 | useEffect(() => { |
| 95 | 99 | const subscription = AppState.addEventListener("change", (state) => |
| 96 | 100 | setForeground(state !== "background"), |
| ... | ... | @@ -100,6 +104,26 @@ function DevicePreviewScreen({ |
| 100 | 104 | useEffect(() => { |
| 101 | 105 | if (focused && state.data !== null && previews.length === 0) onClose(); |
| 102 | 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]); |
| 103 | 127 | |
| 104 | 128 | const shutDownDevice = async () => { |
| 105 | 129 | if (!preview || shuttingDown) return; |
| ... | ... | @@ -217,57 +241,29 @@ function DevicePreviewScreen({ |
| 217 | 241 | onPress: () => void shutDownDevice(), |
| 218 | 242 | }, |
| 219 | 243 | ]; |
| 220 | | return ( |
| 221 | | <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}> |
| 222 | | <ScreenHeader |
| 223 | | title={preview?.name ?? "Devices"} |
| 224 | | sidebar={false} |
| 225 | | onBack={onClose} |
| 226 | | options={{ headerBackVisible: false }} |
| 227 | | actions={[ |
| 228 | | { |
| 229 | | accessibilityLabel: "Home", |
| 230 | | icon: "house", |
| 231 | | disabled: !inputConnected, |
| 232 | | onPress: () => streamRef.current?.home(), |
| 233 | | }, |
| 234 | | ]} |
| 235 | | menus={[ |
| 244 | const pressHome = () => streamRef.current?.home(); |
| 245 | const menuItems: ScreenHeaderMenuItem[] = [ |
| 246 | ...(previews.length > 1 |
| 247 | ? [ |
| 236 | 248 | { |
| 237 | | title: "Device options", |
| 238 | | icon: "ellipsis", |
| 239 | | items: [ |
| 240 | | ...(previews.length > 1 |
| 241 | | ? [ |
| 242 | | { |
| 243 | | id: "devices", |
| 244 | | title: "Devices", |
| 245 | | inline: true, |
| 246 | | items: previews.map((device) => ({ |
| 247 | | id: device.key, |
| 248 | | title: device.name, |
| 249 | | subtitle: device.description, |
| 250 | | selected: device.key === preview?.key, |
| 251 | | onPress: () => setSelectedKey(device.key), |
| 252 | | })), |
| 253 | | }, |
| 254 | | ] |
| 255 | | : []), |
| 256 | | ...controls, |
| 257 | | ], |
| 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 | })), |
| 258 | 259 | }, |
| 259 | | ]} |
| 260 | | /> |
| 261 | | {Platform.OS === "ios" ? ( |
| 262 | | <NativeHeaderToolbar placement="left"> |
| 263 | | <NativeHeaderToolbar.Button |
| 264 | | icon="xmark" |
| 265 | | accessibilityLabel="Close device preview" |
| 266 | | onPress={onClose} |
| 267 | | separateBackground |
| 268 | | /> |
| 269 | | </NativeHeaderToolbar> |
| 270 | | ) : null} |
| 260 | ] |
| 261 | : []), |
| 262 | ...controls, |
| 263 | ]; |
| 264 | return ( |
| 265 | <View className="flex-1" style={{ backgroundColor: themeVariables["--color-sheet-solid"] }}> |
| 266 | <StatusBar hidden animated /> |
| 271 | 267 | {preview && focused && foreground ? ( |
| 272 | 268 | <OpenDevicePreview |
| 273 | 269 | key={`${preview.key}:${streamAttempt}`} |
| ... | ... | @@ -296,10 +292,79 @@ function DevicePreviewScreen({ |
| 296 | 292 | ) : null} |
| 297 | 293 | </View> |
| 298 | 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 | )} |
| 299 | 342 | </View> |
| 300 | 343 | ); |
| 301 | 344 | } |
| 302 | 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 | |
| 303 | 368 | function OpenDevicePreview({ |
| 304 | 369 | environmentId, |
| 305 | 370 | preview, |