1import type { DevicePlatform } from "@t3tools/contracts";
2import {
3 Camera,
4 ChevronLeft,
5 Home,
6 Keyboard,
7 Box,
8 Maximize,
9 MoreHorizontal,
10 PictureInPicture2,
11 Power,
12 RotateCcw,
13 SlidersHorizontal,
14 Smartphone,
15 Square,
16 Type,
17 X,
18} from "lucide-react";
19import { Moon, Sun } from "lucide";
20import type { ReactNode } from "react";
21import { Button } from "~/components/ui/button";
22import { MorphIcon } from "~/components/MorphIcon";
23import {
24 Menu,
25 MenuItem,
26 MenuPopup,
27 MenuSeparator,
28 MenuTrigger,
29 MenuRadioGroup,
30 MenuRadioItem,
31 MenuRadioItemIndicator,
32} from "~/components/ui/menu";
33import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
34import type { DeviceStreamHandle, DeviceViewControls } from "./DeviceStreamView";
35import type { DeviceControls } from "./useDeviceControls";
36
37/** Stable floating controls for both presentations, regardless of panel width. */
38export function DeviceControlsRail(props: {
39 platform: DevicePlatform;
40 handle: DeviceStreamHandle | null;
41 view: DeviceViewControls;
42 controls: DeviceControls;
43 screenshotPending: boolean;
44 onScreenshot: () => void;
45 toolsOpen: boolean;
46 onTools: () => void;
47 onFloat: () => void;
48 onClose: () => void;
49 onPowerOff: () => void;
50}) {
51 const { view, handle, controls } = props;
52 const popupSide = "left";
53 const settings = controls.detail?.settings;
54 const inputDisabled = !handle?.inputConnected;
55 const nextAppearance = settings?.appearance === "dark" ? "light" : "dark";
56 return (
57 <aside
58 aria-label="Device controls"
59 data-layout="rail"
60 className="pointer-events-none absolute inset-y-0 right-0 z-10 flex w-14 flex-col items-center gap-2 overflow-y-auto [justify-content:safe_center] py-3 pr-2 [scrollbar-width:none]"
61 >
62 <div className="pointer-events-auto flex shrink-0 flex-col items-center gap-1 overflow-y-auto rounded-full border border-border/50 bg-background/80 p-2 shadow-sm [scrollbar-width:none]">
63 <RailButton
64 tooltipSide={popupSide}
65 label="Home"
66 disabled={inputDisabled}
67 onClick={() => handle?.pressButton("home")}
68 >
69 <Home />
70 </RailButton>
71 {props.platform === "android" ? (
72 <>
73 <RailButton
74 tooltipSide={popupSide}
75 label="Back"
76 disabled={inputDisabled}
77 onClick={() => handle?.pressButton("back")}
78 >
79 <ChevronLeft />
80 </RailButton>
81 <RailButton
82 tooltipSide={popupSide}
83 label="Recents"
84 disabled={inputDisabled}
85 onClick={() => handle?.pressButton("recents")}
86 >
87 <Square />
88 </RailButton>
89 <Menu>
90 <MenuTrigger
91 render={
92 <Button
93 size="icon-sm"
94 variant="ghost"
95 aria-label="Rotate device"
96 disabled={controls.disabled}
97 />
98 }
99 >
100 <RotateCcw />
101 </MenuTrigger>
102 <MenuPopup side={popupSide}>
103 <MenuItem
104 onClick={() => void controls.act({ type: "setOrientation", value: "portrait" })}
105 >
106 Portrait
107 </MenuItem>
108 <MenuItem
109 onClick={() =>
110 void controls.act({ type: "setOrientation", value: "landscape_left" })
111 }
112 >
113 Landscape
114 </MenuItem>
115 </MenuPopup>
116 </Menu>
117 </>
118 ) : (
119 <RailButton
120 tooltipSide={popupSide}
121 label="Rotate device"
122 disabled={inputDisabled || !!view.keyboard?.attached}
123 onClick={() => handle?.rotate()}
124 >
125 <RotateCcw />
126 </RailButton>
127 )}
128 <RailDivider />
129 <RailButton
130 tooltipSide={popupSide}
131 label={`Switch device to ${nextAppearance} mode`}
132 disabled={controls.disabled || !settings?.appearance}
133 onClick={() => void controls.act({ type: "setAppearance", value: nextAppearance })}
134 >
135 <MorphIcon icon={settings?.appearance === "dark" ? Sun : Moon} />
136 </RailButton>
137 <Menu>
138 <MenuTrigger
139 render={
140 <Button
141 size="icon-sm"
142 variant="ghost"
143 aria-label="Device text size"
144 title="Device text size"
145 disabled={controls.disabled || !settings?.textSize}
146 />
147 }
148 >
149 <Type />
150 </MenuTrigger>
151 <MenuPopup side={popupSide} className="min-w-40">
152 <MenuRadioGroup
153 value={settings?.textSize ?? ""}
154 onValueChange={(value) => {
155 if (
156 value === "small" ||
157 value === "default" ||
158 value === "large" ||
159 value === "extra-large"
160 )
161 void controls.act({ type: "setTextSize", value });
162 }}
163 >
164 {(
165 [
166 ["small", "Small"],
167 ["default", "Default"],
168 ["large", "Large"],
169 ["extra-large", "Extra large"],
170 ] as const
171 ).map(([value, label]) => (
172 <MenuRadioItem key={value} value={value}>
173 <span className="flex items-center gap-2">
174 <span className="flex-1">{label}</span>
175 <MenuRadioItemIndicator />
176 </span>
177 </MenuRadioItem>
178 ))}
179 </MenuRadioGroup>
180 </MenuPopup>
181 </Menu>
182 <RailButton
183 tooltipSide={popupSide}
184 label="Device tools"
185 pressed={props.toolsOpen}
186 onClick={props.onTools}
187 >
188 <SlidersHorizontal />
189 </RailButton>
190 <RailButton
191 tooltipSide={popupSide}
192 label={props.screenshotPending ? "Capturing screenshot" : "Save screenshot"}
193 disabled={!view.streaming || props.screenshotPending}
194 onClick={props.onScreenshot}
195 >
196 <Camera />
197 </RailButton>
198 <Menu>
199 <MenuTrigger
200 render={
201 <Button
202 size="icon-sm"
203 variant="ghost"
204 aria-label="More device actions"
205 title="More device actions"
206 />
207 }
208 >
209 <MoreHorizontal />
210 </MenuTrigger>
211 <MenuPopup side={popupSide} align="end">
212 <MenuItem onClick={props.onFloat}>
213 <PictureInPicture2 />
214 Float device over chat
215 </MenuItem>
216 <MenuItem onClick={props.onClose}>
217 <X />
218 Close device panel
219 </MenuItem>
220 <MenuSeparator />
221 <MenuItem variant="destructive" onClick={props.onPowerOff}>
222 <Power />
223 Power off device
224 </MenuItem>
225 </MenuPopup>
226 </Menu>
227 <RailDivider />
228 <RailButton
229 tooltipSide={popupSide}
230 label="3D view"
231 pressed={view.phone}
232 disabled={!view.streaming || !!view.phoneUnavailableReason}
233 description={view.phoneUnavailableReason ?? undefined}
234 onClick={view.showPhone}
235 >
236 <Box />
237 </RailButton>
238 <RailButton
239 tooltipSide={popupSide}
240 label="Flat view"
241 pressed={!view.phone}
242 disabled={!view.streaming}
243 onClick={view.showFlat}
244 >
245 <Smartphone />
246 </RailButton>
247 {view.keyboard ? (
248 <RailButton
249 tooltipSide={popupSide}
250 label={view.keyboard.attached ? "Detach Magic Keyboard" : "Attach Magic Keyboard"}
251 pressed={view.keyboard.attached}
252 onClick={view.keyboard.toggle}
253 >
254 <Keyboard />
255 </RailButton>
256 ) : null}
257 {view.phone ? (
258 <RailButton tooltipSide={popupSide} label="Restore 3D view" onClick={view.resetView}>
259 <Maximize />
260 </RailButton>
261 ) : null}
262 </div>
263 {view.foldingControls}
264 </aside>
265 );
266}
267
268function RailDivider() {
269 return <div aria-hidden className="my-1 h-px w-5 shrink-0 bg-border/70" />;
270}
271
272function RailButton(props: {
273 tooltipSide: "left" | "bottom";
274 label: string;
275 description?: string | undefined;
276 disabled?: boolean;
277 pressed?: boolean;
278 onClick: () => void;
279 children: ReactNode;
280}) {
281 const button = (
282 <Button
283 size="icon-sm"
284 variant={props.pressed ? "secondary" : "ghost"}
285 aria-label={props.label}
286 aria-pressed={props.pressed}
287 disabled={props.disabled}
288 onClick={props.onClick}
289 >
290 {props.children}
291 </Button>
292 );
293 return (
294 <Tooltip>
295 {props.disabled && props.description ? (
296 <TooltipTrigger
297 render={
298 <span className="inline-flex" tabIndex={0} role="group" aria-label={props.label} />
299 }
300 >
301 {button}
302 </TooltipTrigger>
303 ) : (
304 <TooltipTrigger render={button} />
305 )}
306 <TooltipPopup side={props.tooltipSide}>{props.description ?? props.label}</TooltipPopup>
307 </Tooltip>
308 );
309}