1import { useState } from "react";
2import {
3 duoFoldState,
4 duoHoldOrientation,
5 type DuoCommand,
6 type DuoControlState,
7} from "@t3tools/client-runtime/device/duo-control";
8import type { DeviceScreenSize } from "@t3tools/client-runtime/device/stream";
9import { DeviceDuoGlyph } from "./DeviceDuoGlyph";
10import { Button } from "~/components/ui/button";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
12
13const FOLDS = [
14 { id: "closed", angle: 0 },
15 { id: "half", angle: 90 },
16 { id: "open", angle: 180 },
17] as const;
18const STANDS = [
19 { id: "laptop", label: "Laptop stand" },
20 { id: "tent", label: "Tent stand" },
21] as const;
22
23export function DeviceDuoControls(props: {
24 screen: DeviceScreenSize;
25 state: DuoControlState;
26 enabled: boolean;
27 onCommand: (command: DuoCommand) => void;
28}) {
29 const { screen } = props;
30 const { fold, stand, phoneVertical: reportedVertical, settled } = duoFoldState(screen);
31 const [settledVertical, setSettledVertical] = useState(reportedVertical);
32 if (settled && settledVertical !== reportedVertical) setSettledVertical(reportedVertical);
33 const [standVertical, setStandVertical] = useState(settledVertical);
34 const phoneVertical = stand ? standVertical : settledVertical;
35 // Only one command can wait; defer fold controls until stand rotation and queued commands settle in the
36 // correct display frame.
37 const foldWaits = props.state.pending && (stand || props.state.requested?.control === "pose");
38 const foldLabels = {
39 closed: "Closed",
40 half: phoneVertical ? "Book" : "Laptop",
41 open: "Open",
42 };
43 const button = (
44 key: string,
45 label: string,
46 pressed: boolean,
47 onClick: () => void,
48 glyph: React.ReactNode,
49 waits = false,
50 ) => (
51 <Tooltip key={key}>
52 <TooltipTrigger
53 render={
54 <Button
55 size="icon"
56 variant={pressed ? "secondary" : "ghost"}
57 disabled={!props.enabled || waits}
58 aria-label={label}
59 aria-pressed={pressed}
60 data-pressed={pressed ? "" : undefined}
61 onClick={onClick}
62 />
63 }
64 >
65 {glyph}
66 </TooltipTrigger>
67 <TooltipPopup side="left">{label}</TooltipPopup>
68 </Tooltip>
69 );
70 const group =
71 "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";
72 return (
73 <div aria-label="iPhone Duo stands" className="flex flex-col items-center gap-2">
74 <div role="group" aria-label="Fold shape" className={group}>
75 {FOLDS.map(({ id, angle: value }) =>
76 button(
77 id,
78 foldLabels[id],
79 !stand && fold === id,
80 () => {
81 if (stand)
82 props.onCommand({
83 control: "orientation",
84 value: duoHoldOrientation(standVertical, screen.screenId),
85 });
86 props.onCommand({ control: "angle", value });
87 },
88 <DeviceDuoGlyph pose={id === "half" ? "book" : id} rotated={!phoneVertical} />,
89 foldWaits,
90 ),
91 )}
92 </div>
93 <div role="group" aria-label="Device stance" className={group}>
94 {STANDS.map(({ id, label }) =>
95 button(
96 id,
97 label,
98 screen.hingePose === id,
99 () => {
100 if (!stand) setStandVertical(settledVertical);
101 props.onCommand({ control: "pose", value: id });
102 },
103 <DeviceDuoGlyph pose={id} />,
104 ),
105 )}
106 </div>
107 {props.state.error ? (
108 <Tooltip>
109 <TooltipTrigger
110 render={
111 <span
112 tabIndex={0}
113 role="alert"
114 aria-label={props.state.error}
115 className="pointer-events-auto text-xs text-destructive"
116 >
117 !
118 </span>
119 }
120 />
121 <TooltipPopup side="left">{props.state.error}</TooltipPopup>
122 </Tooltip>
123 ) : null}
124 </div>
125 );
126}