| 1 | import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; |
| 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | import { Button } from "~/components/ui/button"; |
| 4 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 5 | import { |
| 6 | readAndroidFold, |
| 7 | setAndroidFold, |
| 8 | type AndroidFoldPosture, |
| 9 | type AndroidFoldState, |
| 10 | } from "./deviceFold"; |
| 11 | import { DeviceDuoGlyph } from "./DeviceDuoGlyph"; |
| 12 | |
| 13 | /** Capability comes from the emulator, not its AVD name or screen dimensions. */ |
| 14 | export function DeviceAndroidFoldControls(props: { |
| 15 | readonly access: DeviceHubAccess; |
| 16 | readonly deviceId: string; |
| 17 | readonly visible: boolean; |
| 18 | readonly enabled: boolean; |
| 19 | readonly screenWidth: number | undefined; |
| 20 | readonly screenHeight: number | undefined; |
| 21 | readonly onFoldAngle: (angle: number | null) => void; |
| 22 | }) { |
| 23 | const [fold, setFold] = useState<AndroidFoldState | null>(null); |
| 24 | const [pending, setPending] = useState(false); |
| 25 | const [error, setError] = useState<string | null>(null); |
| 26 | const busy = useRef(false); |
| 27 | const mounted = useRef(true); |
| 28 | const { access, deviceId, visible, enabled, screenWidth, screenHeight, onFoldAngle } = props; |
| 29 | |
| 30 | useEffect(() => { |
| 31 | mounted.current = true; |
| 32 | return () => { |
| 33 | mounted.current = false; |
| 34 | }; |
| 35 | }, []); |
| 36 | |
| 37 | useEffect(() => { |
| 38 | if (!visible || !enabled || pending || !screenWidth || !screenHeight) return; |
| 39 | const controller = new AbortController(); |
| 40 | let retry: number | undefined; |
| 41 | const read = () => { |
| 42 | void readAndroidFold(access, deviceId, controller.signal) |
| 43 | .then((next) => { |
| 44 | if (!controller.signal.aborted) { |
| 45 | setFold(next); |
| 46 | onFoldAngle( |
| 47 | next.supported ? (next.hingeAngle ?? (next.posture === "closed" ? 0 : 180)) : null, |
| 48 | ); |
| 49 | } |
| 50 | }) |
| 51 | .catch(() => { |
| 52 | if (!controller.signal.aborted) retry = window.setTimeout(read, 3_000); |
| 53 | }); |
| 54 | }; |
| 55 | read(); |
| 56 | return () => { |
| 57 | controller.abort(); |
| 58 | window.clearTimeout(retry); |
| 59 | }; |
| 60 | }, [access, deviceId, visible, enabled, screenWidth, screenHeight, pending, onFoldAngle]); |
| 61 | |
| 62 | if (!fold?.supported || !props.visible) return null; |
| 63 | |
| 64 | const change = (posture: AndroidFoldPosture) => { |
| 65 | if (busy.current || !props.enabled) return; |
| 66 | busy.current = true; |
| 67 | setPending(true); |
| 68 | setError(null); |
| 69 | props.onFoldAngle(posture === "closed" ? 0 : 180); |
| 70 | const controller = new AbortController(); |
| 71 | const timeout = window.setTimeout(() => controller.abort(), 12_000); |
| 72 | void setAndroidFold(props.access, props.deviceId, posture, controller.signal) |
| 73 | .then((next) => { |
| 74 | if (mounted.current) { |
| 75 | setFold(next); |
| 76 | props.onFoldAngle(next.hingeAngle ?? (next.posture === "closed" ? 0 : 180)); |
| 77 | } |
| 78 | }) |
| 79 | .catch((cause: unknown) => { |
| 80 | if (mounted.current) { |
| 81 | props.onFoldAngle(fold.hingeAngle ?? (fold.posture === "closed" ? 0 : 180)); |
| 82 | setError( |
| 83 | controller.signal.aborted |
| 84 | ? "Fold command timed out." |
| 85 | : cause instanceof Error |
| 86 | ? cause.message |
| 87 | : "Could not change fold posture.", |
| 88 | ); |
| 89 | } |
| 90 | }) |
| 91 | .finally(() => { |
| 92 | window.clearTimeout(timeout); |
| 93 | busy.current = false; |
| 94 | if (mounted.current) setPending(false); |
| 95 | }); |
| 96 | }; |
| 97 | |
| 98 | return ( |
| 99 | <div aria-label="Android fold controls" className="flex flex-col items-center gap-2"> |
| 100 | <div className="pointer-events-auto flex shrink-0 flex-col items-center gap-1 rounded-full border border-border/50 bg-background/80 p-1 shadow-sm"> |
| 101 | <Tooltip> |
| 102 | <TooltipTrigger |
| 103 | render={ |
| 104 | <Button |
| 105 | size="icon" |
| 106 | variant={fold.posture === "closed" ? "secondary" : "ghost"} |
| 107 | aria-label="Fold device" |
| 108 | aria-pressed={fold.posture === "closed"} |
| 109 | disabled={pending || !props.enabled} |
| 110 | onClick={() => change("closed")} |
| 111 | /> |
| 112 | } |
| 113 | > |
| 114 | <DeviceDuoGlyph pose="closed" /> |
| 115 | </TooltipTrigger> |
| 116 | <TooltipPopup side="left">Fold device</TooltipPopup> |
| 117 | </Tooltip> |
| 118 | <Tooltip> |
| 119 | <TooltipTrigger |
| 120 | render={ |
| 121 | <Button |
| 122 | size="icon" |
| 123 | variant={fold.posture === "opened" ? "secondary" : "ghost"} |
| 124 | aria-label="Unfold device" |
| 125 | aria-pressed={fold.posture === "opened"} |
| 126 | disabled={pending || !props.enabled} |
| 127 | onClick={() => change("opened")} |
| 128 | /> |
| 129 | } |
| 130 | > |
| 131 | <DeviceDuoGlyph pose="open" /> |
| 132 | </TooltipTrigger> |
| 133 | <TooltipPopup side="left">Unfold device</TooltipPopup> |
| 134 | </Tooltip> |
| 135 | </div> |
| 136 | {error ? ( |
| 137 | <Tooltip> |
| 138 | <TooltipTrigger |
| 139 | render={ |
| 140 | <span |
| 141 | tabIndex={0} |
| 142 | role="alert" |
| 143 | aria-label={error} |
| 144 | className="pointer-events-auto text-xs text-destructive" |
| 145 | > |
| 146 | ! |
| 147 | </span> |
| 148 | } |
| 149 | /> |
| 150 | <TooltipPopup side="left">{error}</TooltipPopup> |
| 151 | </Tooltip> |
| 152 | ) : null} |
| 153 | </div> |
| 154 | ); |
| 155 | } |