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
23/**
24 * Fold shapes move only the hinge, so the device opens around whichever edge it
25 * currently rests on: a vertical phone opens as a book into a landscape tablet,
26 * a horizontal one as a laptop into a portrait tablet. Stands are native presets
27 * that also place the device. Pinching supplies continuous hinge control.
28 */
29export function DeviceDuoControls(props: {
30 screen: DeviceScreenSize;
31 state: DuoControlState;
32 enabled: boolean;
33 onCommand: (command: DuoCommand) => void;
34}) {
35 const { screen } = props;
36 const { fold, stand, phoneVertical: reportedVertical, settled } = duoFoldState(screen);
37 // A fold never changes how the phone is held. Keep the last settled reading
38 // through display handoffs, whose interim orientation belongs to the other display.
39 const [settledVertical, setSettledVertical] = useState(reportedVertical);
40 if (settled && settledVertical !== reportedVertical) setSettledVertical(reportedVertical);
41 // Stands rotate the device. Folding out of one returns it to how it was held before.
42 const [standVertical, setStandVertical] = useState(settledVertical);
43 const phoneVertical = stand ? standVertical : settledVertical;
44 // Folding out of a stand sends the rotation back, then the fold, and only one command can
45 // wait. Hold the fold buttons until a stand, or anything queued on one, has landed; the
46 // rotation must also be read in the frame of the display the stand settles on.
47 const foldWaits = props.state.pending && (stand || props.state.requested?.control === "pose");
48 const foldLabels = {
49 closed: "Closed",
50 half: phoneVertical ? "Book" : "Laptop",
51 open: "Open",
52 };
53 const button = (
54 key: string,
55 label: string,
56 pressed: boolean,
57 onClick: () => void,
58 glyph: React.ReactNode,
59 waits = false,
60 ) => (
61 <Tooltip key={key}>
62 <TooltipTrigger
63 render={
64 <Button
65 size="icon"
66 variant={pressed ? "secondary" : "ghost"}
67 disabled={!props.enabled || waits}
68 aria-label={label}
69 aria-pressed={pressed}
70 data-pressed={pressed ? "" : undefined}
71 onClick={onClick}
72 />
73 }
74 >
75 {glyph}
76 </TooltipTrigger>
77 <TooltipPopup side="left">{label}</TooltipPopup>
78 </Tooltip>
79 );
80 const group =
81 "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";
82 return (
83 <div aria-label="iPhone Duo stands" className="flex flex-col items-center gap-2">
84 <div role="group" aria-label="Fold shape" className={group}>
85 {FOLDS.map(({ id, angle: value }) =>
86 button(
87 id,
88 foldLabels[id],
89 !stand && fold === id,
90 () => {
91 if (stand)
92 props.onCommand({
93 control: "orientation",
94 value: duoHoldOrientation(standVertical, screen.screenId),
95 });
96 props.onCommand({ control: "angle", value });
97 },
98 <DeviceDuoGlyph pose={id === "half" ? "book" : id} rotated={!phoneVertical} />,
99 foldWaits,
100 ),
101 )}
102 </div>
103 <div role="group" aria-label="Device stance" className={group}>
104 {STANDS.map(({ id, label }) =>
105 button(
106 id,
107 label,
108 screen.hingePose === id,
109 () => {
110 if (!stand) setStandVertical(settledVertical);
111 props.onCommand({ control: "pose", value: id });
112 },
113 <DeviceDuoGlyph pose={id} />,
114 ),
115 )}
116 </div>
117 {props.state.error ? (
118 <Tooltip>
119 <TooltipTrigger
120 render={
121 <span
122 tabIndex={0}
123 role="alert"
124 aria-label={props.state.error}
125 className="pointer-events-auto text-xs text-destructive"
126 >
127 !
128 </span>
129 }
130 />
131 <TooltipPopup side="left">{props.state.error}</TooltipPopup>
132 </Tooltip>
133 ) : null}
134 </div>
135 );
136}