1// @effect-diagnostics globalTimers:off - The stream owns this browser control queue and its timeout.
2import type { DeviceScreenSize } from "./stream.ts";
3
4/** Native hinge presets. Each one also sets the device's physical orientation. */
5export type DuoPose = "closed" | "book" | "open" | "laptop" | "tent";
6export type DuoOrientation =
7 | "portrait"
8 | "landscape_left"
9 | "portrait_upside_down"
10 | "landscape_right";
11export type DuoCommand =
12 | { control: "angle"; value: number }
13 | { control: "pose"; value: DuoPose }
14 | { control: "table"; value: boolean }
15 | { control: "physical"; value: "faceup" | "facedown" }
16 | { control: "orientation"; value: DuoOrientation };
17export type DuoControlState = {
18 pending: boolean;
19 requested: DuoCommand | null;
20 error: string | null;
21};
22
23/**
24 * Whether the reporting display is the one the fold shows: the cover when closed, the inner
25 * display when open. A fold across the closed position hands off in steps, and the outgoing
26 * display first reports the incoming one's orientation, so an unsettled screen's orientation
27 * does not say how the phone is held. Stands such as Tent can also rest unsettled on the cover.
28 */
29export function duoScreenSettled(screen: Pick<DeviceScreenSize, "screenId" | "hingeAngle">) {
30 const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180);
31 return (screen.screenId === 1) === (angle === 0);
32}
33
34/**
35 * The fold the device is in and the way it is held. Missing hinge fields fall back the same way
36 * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a
37 * quarter turn from the cover, so its landscape orientation means a vertical phone.
38 */
39export function duoFoldState(
40 screen: Pick<DeviceScreenSize, "orientation" | "screenId" | "hingeAngle" | "hingePose">,
41) {
42 const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180);
43 const landscape = screen.orientation.startsWith("landscape");
44 return {
45 fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half",
46 stand: screen.hingePose === "laptop" || screen.hingePose === "tent",
47 phoneVertical: screen.screenId === 1 ? !landscape : landscape,
48 settled: duoScreenSettled(screen),
49 } as const;
50}
51
52/**
53 * The rotation that holds the phone vertical or horizontal. Native rotation is
54 * read in the frame of the display active when it arrives, and the inner panel
55 * is mounted a quarter turn from the cover.
56 */
57export function duoHoldOrientation(
58 vertical: boolean,
59 screenId: number | undefined,
60): DuoOrientation {
61 if (screenId === 1) return vertical ? "portrait" : "landscape_left";
62 return vertical ? "landscape_left" : "portrait_upside_down";
63}
64
65/** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */
66export function createDuoControl(options: {
67 send: (request: { requestId: number; command: DuoCommand }) => boolean;
68 onChange: (state: DuoControlState) => void;
69 timeoutMs?: number;
70}) {
71 let nextId = 1;
72 let active: { requestId: number; command: DuoCommand } | null = null;
73 let queued: DuoCommand | null = null;
74 let timer: ReturnType<typeof setTimeout> | null = null;
75 const publish = (error: string | null = null) =>
76 options.onChange({ pending: !!active, requested: queued ?? active?.command ?? null, error });
77 const clear = (error: string | null = null) => {
78 if (timer) clearTimeout(timer);
79 timer = null;
80 active = null;
81 queued = null;
82 publish(error);
83 };
84 const drain = () => {
85 if (active || !queued) return;
86 active = { requestId: nextId++, command: queued };
87 queued = null;
88 const id = active.requestId;
89 timer = setTimeout(() => {
90 if (active?.requestId === id) clear("Device control timed out. Its position is unknown.");
91 }, options.timeoutMs ?? 5_000);
92 publish();
93 if (!options.send(active)) clear("Device is disconnected.");
94 };
95 return {
96 enqueue(command: DuoCommand) {
97 if (
98 command.control === "angle" &&
99 (!Number.isFinite(command.value) || command.value < 0 || command.value > 180)
100 )
101 return;
102 queued = command;
103 if (active) publish();
104 else drain();
105 },
106 receive(reply: { requestId: number; ok: boolean; error?: string }) {
107 if (!active || reply.requestId !== active.requestId) return;
108 if (timer) clearTimeout(timer);
109 timer = null;
110 active = null;
111 if (!reply.ok) {
112 clear(reply.error ?? "Device control failed. Its position is unknown.");
113 return;
114 }
115 if (queued) drain();
116 else publish();
117 },
118 clear,
119 };
120}
121
122/** A pinch keeps its own accumulator across asynchronous native acknowledgements. */
123export function createDuoPinch(options: {
124 angle: () => number;
125 contains: (x: number, y: number) => boolean;
126 change: (angle: number | null) => void;
127}) {
128 let angle: number | null = null;
129 return {
130 begin(x: number, y: number) {
131 if (!options.contains(x, y)) return false;
132 angle = Math.max(0, Math.min(180, options.angle()));
133 return true;
134 },
135 move(logScale: number) {
136 if (angle === null || !Number.isFinite(logScale)) return;
137 const next = Math.max(0, Math.min(180, angle + logScale * 120));
138 if (next === angle) return;
139 angle = next;
140 options.change(next);
141 },
142 end() {
143 if (angle === null) return;
144 angle = null;
145 options.change(null);
146 },
147 get active() {
148 return angle !== null;
149 },
150 };
151}