| ... | @@ -4,16 +4,32 @@ import { | ... | @@ -4,16 +4,32 @@ import { |
| 4 | deviceToolUpdatePolicy, | 4 | deviceToolUpdatePolicy, |
| 5 | } from "@t3tools/client-runtime/state/device"; | 5 | } from "@t3tools/client-runtime/state/device"; |
| 6 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; | 6 | import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; |
| 7 | import { createNativeStackNavigator } from "@react-navigation/native-stack"; | | |
| 8 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 7 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 9 | import * as Cause from "effect/Cause"; | 8 | import * as Cause from "effect/Cause"; |
| 10 | import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; | 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 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 13 | | 23 | |
| | 24 | import { SymbolView } from "../../components/AppSymbol"; |
| 14 | import { AppText } from "../../components/AppText"; | 25 | import { AppText } from "../../components/AppText"; |
| 15 | import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader"; | 26 | import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; |
| 16 | import { NativeHeaderToolbar } from "../../native/StackHeader"; | 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 | import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; | 33 | import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; |
| 18 | import { useEnvironmentQuery } from "../../state/query"; | 34 | import { useEnvironmentQuery } from "../../state/query"; |
| 19 | import { useAtomCommand } from "../../state/use-atom-command"; | 35 | import { useAtomCommand } from "../../state/use-atom-command"; |
| ... | @@ -24,40 +40,26 @@ import { | ... | @@ -24,40 +40,26 @@ import { |
| 24 | threadDevicePreviews, | 40 | threadDevicePreviews, |
| 25 | type ThreadDevicePreview, | 41 | type ThreadDevicePreview, |
| 26 | } from "./threadDevicePreviews"; | 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 | type DevicePreviewRouteScreenProps = StaticScreenProps<{ | 48 | type DevicePreviewRouteScreenProps = StaticScreenProps<{ |
| 31 | readonly environmentId: string; | 49 | readonly environmentId: string; |
| 32 | readonly threadId: string; | 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 | export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { | 54 | export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { |
| 37 | const navigation = useNavigation(); | 55 | const navigation = useNavigation(); |
| 38 | const onClose = useCallback(() => navigation.goBack(), [navigation]); | 56 | const onClose = useCallback(() => navigation.goBack(), [navigation]); |
| 39 | return ( | 57 | return ( |
| 40 | <View collapsable={false} className="flex-1 bg-sheet"> | 58 | <DevicePreviewScreen |
| 41 | <DevicePreviewStack.Navigator | 59 | environmentId={EnvironmentId.make(route.params.environmentId)} |
| 42 | screenOptions={{ | 60 | threadId={ThreadId.make(route.params.threadId)} |
| 43 | headerShown: Platform.OS === "ios", | 61 | onClose={onClose} |
| 44 | headerBackVisible: false, | 62 | /> |
| 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> | | |
| 61 | ); | 63 | ); |
| 62 | } | 64 | } |
| 63 | | 65 | |
| ... | @@ -78,6 +80,7 @@ function DevicePreviewScreen({ | ... | @@ -78,6 +80,7 @@ function DevicePreviewScreen({ |
| 78 | const [inputConnected, setInputConnected] = useState(false); | 80 | const [inputConnected, setInputConnected] = useState(false); |
| 79 | const [streamAttempt, setStreamAttempt] = useState(0); | 81 | const [streamAttempt, setStreamAttempt] = useState(0); |
| 80 | const [shuttingDown, setShuttingDown] = useState(false); | 82 | const [shuttingDown, setShuttingDown] = useState(false); |
| | 83 | const [controlsVisible, setControlsVisible] = useState(true); |
| 81 | const retryHost = useAtomCommand(deviceEnvironment.list); | 84 | const retryHost = useAtomCommand(deviceEnvironment.list); |
| 82 | const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); | 85 | const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); |
| 83 | const streamRef = useRef<DeviceStreamRef>(null); | 86 | const streamRef = useRef<DeviceStreamRef>(null); |
| ... | @@ -87,10 +90,11 @@ function DevicePreviewScreen({ | ... | @@ -87,10 +90,11 @@ function DevicePreviewScreen({ |
| 87 | [state.data, threadId], | 90 | [state.data, threadId], |
| 88 | ); | 91 | ); |
| 89 | const preview = selectedThreadDevicePreview(previews, selectedKey); | 92 | const preview = selectedThreadDevicePreview(previews, selectedKey); |
| 90 | const onInputConnected = useCallback( | 93 | const onInputConnected = useCallback(async (connected: boolean) => { |
| 91 | async (connected: boolean) => setInputConnected(connected), | 94 | setInputConnected(connected); |
| 92 | [], | 95 | // Controls greet the user while connecting, then get out of the stream's way. |
| 93 | ); | 96 | if (connected) setControlsVisible(false); |
| | 97 | }, []); |
| 94 | useEffect(() => { | 98 | useEffect(() => { |
| 95 | const subscription = AppState.addEventListener("change", (state) => | 99 | const subscription = AppState.addEventListener("change", (state) => |
| 96 | setForeground(state !== "background"), | 100 | setForeground(state !== "background"), |
| ... | @@ -100,6 +104,26 @@ function DevicePreviewScreen({ | ... | @@ -100,6 +104,26 @@ function DevicePreviewScreen({ |
| 100 | useEffect(() => { | 104 | useEffect(() => { |
| 101 | if (focused && state.data !== null && previews.length === 0) onClose(); | 105 | if (focused && state.data !== null && previews.length === 0) onClose(); |
| 102 | }, [focused, state.data, previews.length, 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]); |
| 103 | | 127 | |
| 104 | const shutDownDevice = async () => { | 128 | const shutDownDevice = async () => { |
| 105 | if (!preview || shuttingDown) return; | 129 | if (!preview || shuttingDown) return; |
| ... | @@ -217,57 +241,29 @@ function DevicePreviewScreen({ | ... | @@ -217,57 +241,29 @@ function DevicePreviewScreen({ |
| 217 | onPress: () => void shutDownDevice(), | 241 | onPress: () => void shutDownDevice(), |
| 218 | }, | 242 | }, |
| 219 | ]; | 243 | ]; |
| 220 | return ( | 244 | const pressHome = () => streamRef.current?.home(); |
| 221 | <View className="flex-1 bg-sheet" style={{ paddingBottom: insets.bottom }}> | 245 | const menuItems: ScreenHeaderMenuItem[] = [ |
| 222 | <ScreenHeader | 246 | ...(previews.length > 1 |
| 223 | title={preview?.name ?? "Devices"} | 247 | ? [ |
| 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={[ | | |
| 236 | { | 248 | { |
| 237 | title: "Device options", | 249 | id: "devices", |
| 238 | icon: "ellipsis", | 250 | title: "Devices", |
| 239 | items: [ | 251 | inline: true, |
| 240 | ...(previews.length > 1 | 252 | items: previews.map((device) => ({ |
| 241 | ? [ | 253 | id: device.key, |
| 242 | { | 254 | title: device.name, |
| 243 | id: "devices", | 255 | subtitle: device.description, |
| 244 | title: "Devices", | 256 | selected: device.key === preview?.key, |
| 245 | inline: true, | 257 | onPress: () => setSelectedKey(device.key), |
| 246 | items: previews.map((device) => ({ | 258 | })), |
| 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 | ], | | |
| 258 | }, | 259 | }, |
| 259 | ]} | 260 | ] |
| 260 | /> | 261 | : []), |
| 261 | {Platform.OS === "ios" ? ( | 262 | ...controls, |
| 262 | <NativeHeaderToolbar placement="left"> | 263 | ]; |
| 263 | <NativeHeaderToolbar.Button | 264 | return ( |
| 264 | icon="xmark" | 265 | <View className="flex-1" style={{ backgroundColor: themeVariables["--color-sheet-solid"] }}> |
| 265 | accessibilityLabel="Close device preview" | 266 | <StatusBar hidden animated /> |
| 266 | onPress={onClose} | | |
| 267 | separateBackground | | |
| 268 | /> | | |
| 269 | </NativeHeaderToolbar> | | |
| 270 | ) : null} | | |
| 271 | {preview && focused && foreground ? ( | 267 | {preview && focused && foreground ? ( |
| 272 | <OpenDevicePreview | 268 | <OpenDevicePreview |
| 273 | key={`${preview.key}:${streamAttempt}`} | 269 | key={`${preview.key}:${streamAttempt}`} |
| ... | @@ -296,10 +292,79 @@ function DevicePreviewScreen({ | ... | @@ -296,10 +292,79 @@ function DevicePreviewScreen({ |
| 296 | ) : null} | 292 | ) : null} |
| 297 | </View> | 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 | </View> | 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 | function OpenDevicePreview({ | 368 | function OpenDevicePreview({ |
| 304 | environmentId, | 369 | environmentId, |
| 305 | preview, | 370 | preview, |