1import { useEffect, useRef } from "react";
2import type { DuoViewer } from "@t3tools/client-runtime/device/duo-viewer";
3import type { DeviceModelSource } from "@t3tools/client-runtime/device/model";
4import type { 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 { mountDeviceViewer } from "./mountDeviceViewer";
9
10const loadDuoViewer = () => import("@t3tools/client-runtime/device/duo-viewer");
11
12export function DeviceDuoViewport(props: {
13 readonly model: DeviceModelSource;
14 readonly source: HTMLCanvasElement;
15 readonly onFrameListener: (listener: (() => void) | null) => void;
16 readonly onResetReady: (reset: (() => void) | null) => void;
17 readonly onInputCancel: (cancel: (() => void) | null) => void;
18 readonly client: DeviceStreamClient;
19 readonly screen: DeviceScreenSize | null;
20 readonly hingePreview: number | null;
21 readonly onControlRejectedListener: (listener: (() => void) | null) => void;
22 readonly onUnavailable: () => void;
23}) {
24 const viewerRef = useRef<
25 (DuoViewer & { readonly sources: Record<1 | 3, HTMLCanvasElement> }) | null
26 >(null);
27 const interactionRef = useRef<ReturnType<typeof createPhoneInteraction> | null>(null);
28 const pinchRef = useRef<ReturnType<typeof createDuoPinch> | null>(null);
29 const screenRef = useRef(props.screen);
30 const modelRef = useRef(props.model);
31 const previewRef = useRef(props.hingePreview);
32 const {
33 source,
34 client,
35 onInputCancel,
36 onControlRejectedListener,
37 onResetReady,
38 onUnavailable,
39 onFrameListener,
40 } = props;
41
42 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- stores screen and model in refs, ends any active interaction, and pushes the screen into the 3D Duo viewer
43 useEffect(() => {
44 screenRef.current = props.screen;
45 modelRef.current = props.model;
46 interactionRef.current?.end();
47 viewerRef.current?.setScreen(props.screen);
48 }, [props.screen, props.model]);
49
50 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes the hinge preview into the imperative 3D Duo viewer unless a pinch is active
51 useEffect(() => {
52 previewRef.current = props.hingePreview;
53 if (!pinchRef.current?.active) viewerRef.current?.setHingePreview(props.hingePreview);
54 }, [props.hingePreview]);
55
56 // The 3D viewer lives as long as its canvas; later prop changes are pushed into it above.
57 const mountViewer = (canvas: HTMLCanvasElement | null) => {
58 const host = canvas?.parentElement;
59 if (!canvas || !host) return;
60 return mountDeviceViewer(canvas, host, {
61 load: () =>
62 loadDuoViewer().then(({ createDuoViewer }) => () => {
63 const sources = {
64 1: document.createElement("canvas"),
65 3: document.createElement("canvas"),
66 };
67 const viewer = createDuoViewer({
68 canvas,
69 sources,
70 onUnavailable,
71 onPanelRequested: (panel) =>
72 client.controlDuo({
73 control: "physical",
74 value: panel === 1 ? "facedown" : "faceup",
75 }),
76 onOrientationRequested: (value) => client.controlDuo({ control: "orientation", value }),
77 onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
78 model: modelRef.current,
79 });
80 return { ...viewer, sources };
81 }),
82 touch: (phase, point) => client.sendRawTouch(phase, point.x, point.y),
83 viewerRef,
84 interactionRef,
85 onInputCancel,
86 onResetReady,
87 onFrameListener,
88 onUnavailable,
89 cancelInput: (viewer) => viewer.cancelInput(),
90 ready: ({ viewer, cancelTrackpad }) => {
91 onControlRejectedListener(() => {
92 cancelTrackpad();
93 viewer.rejectOrientation();
94 });
95 viewer.setScreen(screenRef.current);
96 viewer.setHingePreview(previewRef.current);
97 client.setDuoPanels({
98 onScreen(next) {
99 screenRef.current = next;
100 interactionRef.current?.end();
101 viewer.setScreen(next);
102 },
103 cover: createCanvasFrameSink(viewer.sources[1], () => viewer.frameUpdated(1)),
104 inner: createCanvasFrameSink(viewer.sources[3], () => viewer.frameUpdated(3)),
105 });
106 const pinch = createDuoPinch({
107 angle: () =>
108 previewRef.current ??
109 screenRef.current?.hingeAngle ??
110 (screenRef.current?.screenId === 1 ? 0 : 180),
111 contains: (x, y) => !!screenRef.current && viewer.beginHinge(x, y),
112 change: (angle) => {
113 viewer.setHingePreview(angle ?? previewRef.current, true);
114 if (angle !== null) client.controlDuo({ control: "angle", value: angle });
115 },
116 });
117 pinchRef.current = pinch;
118 return {
119 frame: () => {
120 const panel = screenRef.current?.screenId;
121 if (panel === 1 || panel === 3) viewer.frameUpdated(panel, source);
122 },
123 pinch: {
124 begin(x, y) {
125 interactionRef.current?.end();
126 return pinch.begin(x, y);
127 },
128 move: pinch.move,
129 end: pinch.end,
130 },
131 dispose: () => {
132 pinchRef.current = null;
133 onControlRejectedListener(null);
134 client.setDuoPanels(null);
135 },
136 };
137 },
138 });
139 };
140
141 const point = (event: React.PointerEvent<HTMLCanvasElement>) => {
142 const rect = event.currentTarget.getBoundingClientRect();
143 return {
144 x: (event.clientX - rect.left) / rect.width,
145 y: (event.clientY - rect.top) / rect.height,
146 };
147 };
148 return (
149 <div className="absolute inset-0">
150 <div className="absolute inset-0">
151 <div
152 aria-hidden
153 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"
154 />
155 <canvas
156 ref={mountViewer}
157 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."
158 className="size-full touch-none"
159 onPointerDown={(event) => {
160 if (event.button !== 0) return;
161 if (!interactionRef.current?.begin(event.pointerId, point(event), event.altKey)) return;
162 event.preventDefault();
163 event.currentTarget.setPointerCapture(event.pointerId);
164 (event.currentTarget.closest('[role="application"]') as HTMLElement | null)?.focus();
165 }}
166 onPointerMove={(event) => interactionRef.current?.move(event.pointerId, point(event))}
167 onPointerUp={(event) => {
168 interactionRef.current?.move(event.pointerId, point(event));
169 interactionRef.current?.end(event.pointerId);
170 }}
171 onPointerCancel={(event) => interactionRef.current?.end(event.pointerId)}
172 onLostPointerCapture={(event) => interactionRef.current?.end(event.pointerId)}
173 />
174 </div>
175 </div>
176 );
177}