Skip to content

Commit fe69bdc

Browse files
refactor(web): device panel reads hub state from atoms
The device panel, stream view, 3D viewports, fold controls, tools panel, setup and EAS host settings synced device hub state into React with effects: probe on open, detail reads guarded by a generation counter, fold polling, event log and foreground subscriptions, and the stream client's lifecycle. These now live in atoms that run while mounted (deviceHubAtoms, deviceStreamSession, deviceDetailReadAtom, deviceHostProbeAtom), and the 3D viewers mount from their canvas ref. Effects in the touched files: 23 -> 7 (-16). Behavior changes: - A screenshot capture is never cancelled; it finishes and downloads even if the panel hides or closes meanwhile. Its object URL is revoked after a minute instead of on unmount. - The "Waiting for device video" notice is delayed by a CSS transition instead of a timer, so it is in the DOM (at opacity 0) from the start. - The detail re-read on reopen relies on the existing per-environment serial lane for ordering instead of a generation counter. - The mini player's screen size resets to null when its own stream session ends, instead of keeping the last reported size. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent f88b33a commit fe69bdc

19 files changed

Lines changed: 985 additions & 653 deletions

‎apps/web/src/components/device/DeviceAndroidFoldControls.tsx‎

Lines changed: 27 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,45 @@
11
import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess";
2-
import { useEffect, useRef, useState } from "react";
2+
import { useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react";
3+
import { useRef, useState } from "react";
34
import { Button } from "~/components/ui/button";
45
import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
5-
import {
6-
AndroidFoldError,
7-
readAndroidFold,
8-
setAndroidFold,
9-
type AndroidFoldPosture,
10-
type AndroidFoldState,
11-
} from "./deviceFold";
6+
import { AndroidFoldError, setAndroidFold, type AndroidFoldPosture } from "./deviceFold";
7+
import { androidFoldReadAtom, androidFoldViewAtom, foldAngle, NOOP_ATOM } from "./deviceHubAtoms";
128
import { DeviceDuoGlyph } from "./DeviceDuoGlyph";
139

1410
export function DeviceAndroidFoldControls(props: {
1511
readonly access: DeviceHubAccess;
12+
readonly deviceKey: string;
1613
readonly deviceId: string;
1714
readonly visible: boolean;
1815
readonly enabled: boolean;
1916
readonly screenWidth: number | undefined;
2017
readonly screenHeight: number | undefined;
21-
readonly onFoldAngle: (angle: number | null) => void;
2218
readonly renewAccess: () => Promise<DeviceHubAccess | null>;
2319
}) {
24-
const [fold, setFold] = useState<AndroidFoldState | null>(null);
2520
const [pending, setPending] = useState(false);
2621
const [error, setError] = useState<string | null>(null);
2722
const busy = useRef(false);
28-
const mounted = useRef(true);
29-
const { access, deviceId, visible, enabled, screenWidth, screenHeight, onFoldAngle } = props;
30-
31-
useEffect(() => {
32-
mounted.current = true;
33-
return () => {
34-
mounted.current = false;
35-
};
36-
}, []);
37-
38-
useEffect(() => {
39-
if (!visible || !enabled || pending || !screenWidth || !screenHeight) return;
40-
const controller = new AbortController();
41-
let retry: number | undefined;
42-
const read = () => {
43-
void readAndroidFold(access, deviceId, controller.signal)
44-
.then((next) => {
45-
if (!controller.signal.aborted) {
46-
setFold(next);
47-
onFoldAngle(
48-
next.supported ? (next.hingeAngle ?? (next.posture === "closed" ? 0 : 180)) : null,
49-
);
50-
}
51-
})
52-
.catch((cause: unknown) => {
53-
// Retrying an expired ticket cannot succeed. New access re-runs this effect.
54-
const expired = cause instanceof AndroidFoldError && cause.status === 401;
55-
if (!controller.signal.aborted && !expired) retry = window.setTimeout(read, 3_000);
56-
});
57-
};
58-
read();
59-
return () => {
60-
controller.abort();
61-
window.clearTimeout(retry);
62-
};
63-
}, [access, deviceId, visible, enabled, screenWidth, screenHeight, pending, onFoldAngle]);
23+
const { access, deviceKey, deviceId, visible, enabled, screenWidth, screenHeight } = props;
24+
const viewAtom = androidFoldViewAtom(deviceKey);
25+
const { fold } = useAtomValue(viewAtom);
26+
const setView = useAtomSet(viewAtom);
27+
// A change in flight owns the posture; reading meanwhile could show the old one.
28+
useAtomMount(
29+
visible && enabled && !pending && screenWidth && screenHeight
30+
? androidFoldReadAtom({ deviceKey, access, deviceId, screenWidth, screenHeight })
31+
: NOOP_ATOM,
32+
);
6433

6534
if (!fold?.supported || !props.visible) return null;
6635

36+
const setAngle = (angle: number) => setView((current) => ({ ...current, angle }));
6737
const change = (posture: AndroidFoldPosture) => {
6838
if (busy.current || !props.enabled) return;
6939
busy.current = true;
7040
setPending(true);
7141
setError(null);
72-
props.onFoldAngle(posture === "closed" ? 0 : 180);
42+
setAngle(posture === "closed" ? 0 : 180);
7343
const controller = new AbortController();
7444
const timeout = window.setTimeout(() => controller.abort(), 12_000);
7545
const send = (access: DeviceHubAccess) =>
@@ -82,28 +52,21 @@ export function DeviceAndroidFoldControls(props: {
8252
if (!renewed || controller.signal.aborted) throw cause;
8353
return send(renewed);
8454
})
85-
.then((next) => {
86-
if (mounted.current) {
87-
setFold(next);
88-
props.onFoldAngle(next.hingeAngle ?? (next.posture === "closed" ? 0 : 180));
89-
}
90-
})
55+
.then((next) => setView({ fold: next, angle: foldAngle(next) }))
9156
.catch((cause: unknown) => {
92-
if (mounted.current) {
93-
props.onFoldAngle(fold.hingeAngle ?? (fold.posture === "closed" ? 0 : 180));
94-
setError(
95-
controller.signal.aborted
96-
? "Fold command timed out."
97-
: cause instanceof Error
98-
? cause.message
99-
: "Could not change fold posture.",
100-
);
101-
}
57+
setAngle(foldAngle(fold));
58+
setError(
59+
controller.signal.aborted
60+
? "Fold command timed out."
61+
: cause instanceof Error
62+
? cause.message
63+
: "Could not change fold posture.",
64+
);
10265
})
10366
.finally(() => {
10467
window.clearTimeout(timeout);
10568
busy.current = false;
106-
if (mounted.current) setPending(false);
69+
setPending(false);
10770
});
10871
};
10972

‎apps/web/src/components/device/DeviceDuoViewport.tsx‎

Lines changed: 83 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,43 @@
1-
import { useEffect, useRef, type RefObject } from "react";
1+
import { useEffect, useRef } from "react";
22
import type { DuoViewer } from "@t3tools/client-runtime/device/duo-viewer";
33
import type { DeviceModelSource } from "@t3tools/client-runtime/device/model";
4-
import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
4+
import type { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
55
import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame";
66
import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
77
import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control";
8-
import { bindPhoneTrackpad } from "./phoneTrackpad";
9-
import { observeResize } from "../../lib/observeResize";
8+
import { mountDeviceViewer } from "./mountDeviceViewer";
109

1110
const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer");
1211

1312
export function DeviceDuoViewport(props: {
1413
readonly model: DeviceModelSource;
15-
readonly source: RefObject<HTMLCanvasElement | null>;
14+
readonly source: HTMLCanvasElement;
1615
readonly onFrameListener: (listener: (() => void) | null) => void;
1716
readonly onResetReady: (reset: (() => void) | null) => void;
1817
readonly onInputCancel: (cancel: (() => void) | null) => void;
19-
readonly client: RefObject<DeviceStreamClient | null>;
18+
readonly client: DeviceStreamClient;
2019
readonly screen: DeviceScreenSize | null;
2120
readonly hingePreview: number | null;
22-
readonly controlError: string | null;
21+
readonly onControlRejectedListener: (listener: (() => void) | null) => void;
2322
readonly onUnavailable: () => void;
2423
}) {
25-
const hostRef = useRef<HTMLDivElement | null>(null);
26-
const canvasRef = useRef<HTMLCanvasElement | null>(null);
27-
const viewerRef = useRef<DuoViewer | null>(null);
24+
const viewerRef = useRef<
25+
(DuoViewer & { readonly sources: Record<1 | 3, HTMLCanvasElement> }) | null
26+
>(null);
2827
const interactionRef = useRef<ReturnType<typeof createPhoneInteraction> | null>(null);
2928
const pinchRef = useRef<ReturnType<typeof createDuoPinch> | null>(null);
30-
const trackpadRef = useRef<ReturnType<typeof bindPhoneTrackpad> | null>(null);
3129
const screenRef = useRef(props.screen);
3230
const modelRef = useRef(props.model);
3331
const previewRef = useRef(props.hingePreview);
34-
const { source, client, onInputCancel, onResetReady, onUnavailable, onFrameListener } = props;
32+
const {
33+
source,
34+
client,
35+
onInputCancel,
36+
onControlRejectedListener,
37+
onResetReady,
38+
onUnavailable,
39+
onFrameListener,
40+
} = props;
3541

3642
useEffect(() => {
3743
screenRef.current = props.screen;
@@ -40,84 +46,60 @@ export function DeviceDuoViewport(props: {
4046
viewerRef.current?.setScreen(props.screen);
4147
}, [props.screen, props.model]);
4248

43-
useEffect(() => {
44-
if (props.controlError || !props.screen) {
45-
trackpadRef.current?.cancel();
46-
viewerRef.current?.rejectOrientation();
47-
}
48-
}, [props.controlError, props.screen]);
49-
5049
useEffect(() => {
5150
previewRef.current = props.hingePreview;
5251
if (!pinchRef.current?.active) viewerRef.current?.setHingePreview(props.hingePreview);
5352
}, [props.hingePreview]);
5453

55-
useEffect(() => {
56-
const host = hostRef.current;
57-
const canvas = canvasRef.current;
58-
const decoded = source.current;
59-
if (!host || !canvas || !decoded) return;
60-
let disposed = false;
61-
let trackpad: ReturnType<typeof bindPhoneTrackpad> | null = null;
62-
let stopTrackpadEnd: (() => void) | undefined;
63-
const resize = () => {
64-
const { width, height } = host.getBoundingClientRect();
65-
viewerRef.current?.resize(width, height, window.devicePixelRatio);
66-
};
67-
const stopObserving = observeResize(host, resize);
68-
const blur = () => {
69-
interactionRef.current?.end();
70-
trackpad?.cancel();
71-
viewerRef.current?.cancelInput();
72-
};
73-
onInputCancel(blur);
74-
window.addEventListener("blur", blur);
75-
void loadDuoViewer()
76-
.then(({ createDuoViewer }) => {
77-
if (disposed) return;
78-
const sources = {
79-
1: document.createElement("canvas"),
80-
3: document.createElement("canvas"),
81-
};
82-
const viewer = createDuoViewer({
83-
canvas,
84-
sources,
85-
onUnavailable,
86-
onPanelRequested: (panel) =>
87-
client.current?.controlDuo({
88-
control: "physical",
89-
value: panel === 1 ? "facedown" : "faceup",
90-
}),
91-
onOrientationRequested: (value) =>
92-
client.current?.controlDuo({ control: "orientation", value }),
93-
onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
94-
model: modelRef.current,
54+
// The 3D viewer lives as long as its canvas; later prop changes are pushed into it above.
55+
const mountViewer = (canvas: HTMLCanvasElement | null) => {
56+
const host = canvas?.parentElement;
57+
if (!canvas || !host) return;
58+
return mountDeviceViewer(canvas, host, {
59+
load: () =>
60+
loadDuoViewer().then(({ createDuoViewer }) => () => {
61+
const sources = {
62+
1: document.createElement("canvas"),
63+
3: document.createElement("canvas"),
64+
};
65+
const viewer = createDuoViewer({
66+
canvas,
67+
sources,
68+
onUnavailable,
69+
onPanelRequested: (panel) =>
70+
client.controlDuo({
71+
control: "physical",
72+
value: panel === 1 ? "facedown" : "faceup",
73+
}),
74+
onOrientationRequested: (value) => client.controlDuo({ control: "orientation", value }),
75+
onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
76+
model: modelRef.current,
77+
});
78+
return { ...viewer, sources };
79+
}),
80+
touch: (phase, point) => client.sendRawTouch(phase, point.x, point.y),
81+
viewerRef,
82+
interactionRef,
83+
onInputCancel,
84+
onResetReady,
85+
onFrameListener,
86+
onUnavailable,
87+
cancelInput: (viewer) => viewer.cancelInput(),
88+
ready: ({ viewer, cancelTrackpad }) => {
89+
onControlRejectedListener(() => {
90+
cancelTrackpad();
91+
viewer.rejectOrientation();
9592
});
96-
viewerRef.current = viewer;
97-
onResetReady(viewer.resetPose);
9893
viewer.setScreen(screenRef.current);
99-
const primaryFrame = () => {
100-
const panel = screenRef.current?.screenId;
101-
if (panel === 1 || panel === 3) viewer.frameUpdated(panel, decoded);
102-
};
103-
onFrameListener(primaryFrame);
104-
primaryFrame();
10594
viewer.setHingePreview(previewRef.current);
106-
client.current?.setDuoPanels({
95+
client.setDuoPanels({
10796
onScreen(next) {
10897
screenRef.current = next;
10998
interactionRef.current?.end();
11099
viewer.setScreen(next);
111100
},
112-
cover: createCanvasFrameSink(sources[1], () => viewer.frameUpdated(1)),
113-
inner: createCanvasFrameSink(sources[3], () => viewer.frameUpdated(3)),
114-
});
115-
interactionRef.current = createPhoneInteraction({
116-
screenPoint: (point, captured) => viewer.screenPoint(point.x, point.y, captured),
117-
touch: (phase, point) => client.current?.sendRawTouch(phase, point.x, point.y),
118-
orbit: viewer.orbit,
119-
zoomBy: () => {},
120-
onInteractionActive: viewer.setInteractionActive,
101+
cover: createCanvasFrameSink(viewer.sources[1], () => viewer.frameUpdated(1)),
102+
inner: createCanvasFrameSink(viewer.sources[3], () => viewer.frameUpdated(3)),
121103
});
122104
const pinch = createDuoPinch({
123105
angle: () =>
@@ -127,43 +109,32 @@ export function DeviceDuoViewport(props: {
127109
contains: (x, y) => !!screenRef.current && viewer.beginHinge(x, y),
128110
change: (angle) => {
129111
viewer.setHingePreview(angle ?? previewRef.current, true);
130-
if (angle !== null) client.current?.controlDuo({ control: "angle", value: angle });
112+
if (angle !== null) client.controlDuo({ control: "angle", value: angle });
131113
},
132114
});
133115
pinchRef.current = pinch;
134-
trackpad = bindPhoneTrackpad(canvas, interactionRef.current, {
135-
begin(x, y) {
136-
interactionRef.current?.end();
137-
return pinch.begin(x, y);
116+
return {
117+
frame: () => {
118+
const panel = screenRef.current?.screenId;
119+
if (panel === 1 || panel === 3) viewer.frameUpdated(panel, source);
138120
},
139-
move: pinch.move,
140-
end: pinch.end,
141-
});
142-
trackpadRef.current = trackpad;
143-
stopTrackpadEnd = window.desktopBridge?.onTrackpadScrollEnd?.(() => trackpad?.endOrbit());
144-
resize();
145-
})
146-
.catch(() => {
147-
if (!disposed) onUnavailable();
148-
});
149-
return () => {
150-
disposed = true;
151-
stopTrackpadEnd?.();
152-
trackpad?.dispose();
153-
trackpadRef.current = null;
154-
pinchRef.current = null;
155-
interactionRef.current?.end();
156-
interactionRef.current = null;
157-
onInputCancel(null);
158-
onResetReady(null);
159-
onFrameListener(null);
160-
client.current?.setDuoPanels(null);
161-
stopObserving();
162-
window.removeEventListener("blur", blur);
163-
viewerRef.current?.dispose();
164-
viewerRef.current = null;
165-
};
166-
}, [onInputCancel, onResetReady, client, onUnavailable, source, onFrameListener]);
121+
pinch: {
122+
begin(x, y) {
123+
interactionRef.current?.end();
124+
return pinch.begin(x, y);
125+
},
126+
move: pinch.move,
127+
end: pinch.end,
128+
},
129+
dispose: () => {
130+
pinchRef.current = null;
131+
onControlRejectedListener(null);
132+
client.setDuoPanels(null);
133+
},
134+
};
135+
},
136+
});
137+
};
167138

168139
const point = (event: React.PointerEvent<HTMLCanvasElement>) => {
169140
const rect = event.currentTarget.getBoundingClientRect();
@@ -174,13 +145,13 @@ export function DeviceDuoViewport(props: {
174145
};
175146
return (
176147
<div className="absolute inset-0">
177-
<div ref={hostRef} className="absolute inset-0">
148+
<div className="absolute inset-0">
178149
<div
179150
aria-hidden
180151
className="pointer-events-none absolute bottom-[6%] left-1/2 h-5 w-2/5 -translate-x-1/2 rounded-full bg-foreground/10 blur-xl"
181152
/>
182153
<canvas
183-
ref={canvasRef}
154+
ref={mountViewer}
184155
aria-label="Interactive 3D iPhone Duo. Drag the screen to interact. Drag outside it or swipe with two fingers to turn. Pinch over the device to open or close its hinge."
185156
className="size-full touch-none"
186157
onPointerDown={(event) => {

0 commit comments

Comments
 (0)