1import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2import { useEffect, useRef, useState } from "react";
3import { Button } from "~/components/ui/button";
4import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
5import {
6 readAndroidFold,
7 setAndroidFold,
8 type AndroidFoldPosture,
9 type AndroidFoldState,
10} from "./deviceFold";
11import { DeviceDuoGlyph } from "./DeviceDuoGlyph";
12
13/** Capability comes from the emulator, not its AVD name or screen dimensions. */
14export 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}