1import { useEffect, useRef, type RefObject } from "react";
2import type { DuoViewer } from "@t3tools/client-runtime/device/duo-viewer";
3import type { DeviceModelSource } from "@t3tools/client-runtime/device/model";
4import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
5import { createCanvasFrameSink } from "@t3tools/client-runtime/device/frame";
6import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
7import { createDuoPinch } from "@t3tools/client-runtime/device/duo-control";
8import { bindPhoneTrackpad } from "./phoneTrackpad";
9import { observeResize } from "../../lib/observeResize";
10
11const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer");
12
13/** Web shell for the framework-independent viewer. The decoded screen and input connection remain owned by DeviceStreamView. */
14export function DeviceDuoViewport(props: {
15 readonly model: DeviceModelSource;
16 readonly source: RefObject<HTMLCanvasElement | null>;
17 readonly onFrameListener: (listener: (() => void) | null) => void;
18 readonly onResetReady: (reset: (() => void) | null) => void;
19 readonly onInputCancel: (cancel: (() => void) | null) => void;
20 readonly client: RefObject<DeviceStreamClient | null>;
21 readonly screen: DeviceScreenSize | null;
22 readonly hingePreview: number | null;
23 readonly controlError: string | null;
24 readonly onUnavailable: () => void;
25}) {
26 const hostRef = useRef<HTMLDivElement | null>(null);
27 const canvasRef = useRef<HTMLCanvasElement | null>(null);
28 const viewerRef = useRef<DuoViewer | null>(null);
29 const interactionRef = useRef<ReturnType<typeof createPhoneInteraction> | null>(null);
30 const pinchRef = useRef<ReturnType<typeof createDuoPinch> | null>(null);
31 const trackpadRef = useRef<ReturnType<typeof bindPhoneTrackpad> | null>(null);
32 const screenRef = useRef(props.screen);
33 const modelRef = useRef(props.model);
34 const previewRef = useRef(props.hingePreview);
35 const { source, client, onInputCancel, onResetReady, onUnavailable, onFrameListener } = props;
36
37 useEffect(() => {
38 screenRef.current = props.screen;
39 modelRef.current = props.model;
40 interactionRef.current?.end();
41 viewerRef.current?.setScreen(props.screen);
42 }, [props.screen, props.model]);
43
44 useEffect(() => {
45 if (props.controlError || !props.screen) {
46 trackpadRef.current?.cancel();
47 viewerRef.current?.rejectOrientation();
48 }
49 }, [props.controlError, props.screen]);
50
51 useEffect(() => {
52 previewRef.current = props.hingePreview;
53 if (!pinchRef.current?.active) viewerRef.current?.setHingePreview(props.hingePreview);
54 }, [props.hingePreview]);
55
56 useEffect(() => {
57 const host = hostRef.current;
58 const canvas = canvasRef.current;
59 const decoded = source.current;
60 if (!host || !canvas || !decoded) return;
61 let disposed = false;
62 let trackpad: ReturnType<typeof bindPhoneTrackpad> | null = null;
63 let stopTrackpadEnd: (() => void) | undefined;
64 const resize = () => {
65 const { width, height } = host.getBoundingClientRect();
66 viewerRef.current?.resize(width, height, window.devicePixelRatio);
67 };
68 const stopObserving = observeResize(host, resize);
69 const blur = () => {
70 interactionRef.current?.end();
71 trackpad?.cancel();
72 viewerRef.current?.cancelInput();
73 };
74 onInputCancel(blur);
75 window.addEventListener("blur", blur);
76 void loadDuoViewer()
77 .then(({ createDuoViewer }) => {
78 if (disposed) return;
79 const sources = {
80 1: document.createElement("canvas"),
81 3: document.createElement("canvas"),
82 };
83 const viewer = createDuoViewer({
84 canvas,
85 sources,
86 onUnavailable,
87 onPanelRequested: (panel) =>
88 client.current?.controlDuo({
89 control: "physical",
90 value: panel === 1 ? "facedown" : "faceup",
91 }),
92 onOrientationRequested: (value) =>
93 client.current?.controlDuo({ control: "orientation", value }),
94 onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
95 model: modelRef.current,
96 });
97 viewerRef.current = viewer;
98 onResetReady(viewer.resetPose);
99 viewer.setScreen(screenRef.current);
100 const primaryFrame = () => {
101 const panel = screenRef.current?.screenId;
102 if (panel === 1 || panel === 3) viewer.frameUpdated(panel, decoded);
103 };
104 onFrameListener(primaryFrame);
105 primaryFrame();
106 viewer.setHingePreview(previewRef.current);
107 client.current?.setDuoPanels({
108 onScreen(next) {
109 screenRef.current = next;
110 interactionRef.current?.end();
111 viewer.setScreen(next);
112 },
113 cover: createCanvasFrameSink(sources[1], () => viewer.frameUpdated(1)),
114 inner: createCanvasFrameSink(sources[3], () => viewer.frameUpdated(3)),
115 });
116 interactionRef.current = createPhoneInteraction({
117 screenPoint: (point, captured) => viewer.screenPoint(point.x, point.y, captured),
118 touch: (phase, point) => client.current?.sendRawTouch(phase, point.x, point.y),
119 orbit: viewer.orbit,
120 zoomBy: () => {},
121 onInteractionActive: viewer.setInteractionActive,
122 });
123 const pinch = createDuoPinch({
124 angle: () =>
125 previewRef.current ??
126 screenRef.current?.hingeAngle ??
127 (screenRef.current?.screenId === 1 ? 0 : 180),
128 contains: (x, y) => !!screenRef.current && viewer.beginHinge(x, y),
129 change: (angle) => {
130 viewer.setHingePreview(angle ?? previewRef.current, true);
131 if (angle !== null) client.current?.controlDuo({ control: "angle", value: angle });
132 },
133 });
134 pinchRef.current = pinch;
135 trackpad = bindPhoneTrackpad(canvas, interactionRef.current, {
136 begin(x, y) {
137 interactionRef.current?.end();
138 return pinch.begin(x, y);
139 },
140 move: pinch.move,
141 end: pinch.end,
142 });
143 trackpadRef.current = trackpad;
144 stopTrackpadEnd = window.desktopBridge?.onTrackpadScrollEnd?.(() => trackpad?.endOrbit());
145 resize();
146 })
147 .catch(() => {
148 if (!disposed) onUnavailable();
149 });
150 return () => {
151 disposed = true;
152 stopTrackpadEnd?.();
153 trackpad?.dispose();
154 trackpadRef.current = null;
155 pinchRef.current = null;
156 interactionRef.current?.end();
157 interactionRef.current = null;
158 onInputCancel(null);
159 onResetReady(null);
160 onFrameListener(null);
161 client.current?.setDuoPanels(null);
162 stopObserving();
163 window.removeEventListener("blur", blur);
164 viewerRef.current?.dispose();
165 viewerRef.current = null;
166 };
167 }, [onInputCancel, onResetReady, client, onUnavailable, source, onFrameListener]);
168
169 const point = (event: React.PointerEvent<HTMLCanvasElement>) => {
170 const rect = event.currentTarget.getBoundingClientRect();
171 return {
172 x: (event.clientX - rect.left) / rect.width,
173 y: (event.clientY - rect.top) / rect.height,
174 };
175 };
176 return (
177 <div className="absolute inset-0">
178 <div ref={hostRef} className="absolute inset-0">
179 <div
180 aria-hidden
181 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"
182 />
183 <canvas
184 ref={canvasRef}
185 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."
186 className="size-full touch-none"
187 onPointerDown={(event) => {
188 if (event.button !== 0) return;
189 if (!interactionRef.current?.begin(event.pointerId, point(event), event.altKey)) return;
190 event.preventDefault();
191 event.currentTarget.setPointerCapture(event.pointerId);
192 (event.currentTarget.closest('[role="application"]') as HTMLElement | null)?.focus();
193 }}
194 onPointerMove={(event) => interactionRef.current?.move(event.pointerId, point(event))}
195 onPointerUp={(event) => {
196 interactionRef.current?.move(event.pointerId, point(event));
197 interactionRef.current?.end(event.pointerId);
198 }}
199 onPointerCancel={(event) => interactionRef.current?.end(event.pointerId)}
200 onLostPointerCapture={(event) => interactionRef.current?.end(event.pointerId)}
201 />
202 </div>
203 </div>
204 );
205}