1import { useEffect, useRef, type RefObject } from "react";
2import type { PhoneViewer } from "@t3tools/client-runtime/device/phone-viewer";
3import type {
4 DeviceAccessorySource,
5 DeviceModelSource,
6} from "@t3tools/client-runtime/device/model";
7import type { DeviceShapeProfile } from "@t3tools/client-runtime/device/shape-profile";
8import { createPhoneInteraction } from "@t3tools/client-runtime/device/phone-interaction";
9import type { DeviceScreenSize, DeviceStreamClient } from "@t3tools/client-runtime/device/stream";
10import { bindPhoneTrackpad } from "./phoneTrackpad";
11import { observeResize } from "../../lib/observeResize";
12
13const loadPhoneViewer = () => import("@t3tools/client-runtime/device/phone-viewer");
14
15/** Web shell for the framework-independent viewer. The decoded screen and input connection remain owned by DeviceStreamView. */
16export function DevicePhoneViewport(props: {
17 readonly profile: DeviceShapeProfile;
18 readonly model: DeviceModelSource | null;
19 readonly accessory: DeviceAccessorySource | null;
20 readonly source: RefObject<HTMLCanvasElement | null>;
21 readonly onFrameListener: (listener: (() => void) | null) => void;
22 readonly onResetReady: (reset: (() => void) | null) => void;
23 readonly onInputCancel: (cancel: (() => void) | null) => void;
24 readonly client: RefObject<DeviceStreamClient | null>;
25 readonly screen: DeviceScreenSize | null;
26 readonly foldAngle: number | null;
27 readonly onUnavailable: () => void;
28}) {
29 const hostRef = useRef<HTMLDivElement | null>(null);
30 const canvasRef = useRef<HTMLCanvasElement | null>(null);
31 const viewerRef = useRef<PhoneViewer | null>(null);
32 const interactionRef = useRef<ReturnType<typeof createPhoneInteraction> | null>(null);
33 const screenRef = useRef(props.screen);
34 const foldAngleRef = useRef(props.foldAngle);
35 const profileRef = useRef(props.profile);
36 const modelRef = useRef(props.model);
37 const accessoryRef = useRef(props.accessory);
38 const { source, onFrameListener, client, onInputCancel, onResetReady, onUnavailable } = props;
39
40 useEffect(() => {
41 foldAngleRef.current = props.foldAngle;
42 viewerRef.current?.setFoldAngle(props.foldAngle);
43 }, [props.foldAngle]);
44
45 useEffect(() => {
46 screenRef.current = props.screen;
47 profileRef.current = props.profile;
48 modelRef.current = props.model;
49 accessoryRef.current = props.accessory;
50 interactionRef.current?.end();
51 viewerRef.current?.setModel(props.model);
52 viewerRef.current?.setAccessory(props.accessory);
53 viewerRef.current?.setScreen(props.screen, props.profile);
54 }, [props.screen, props.profile, props.model, props.accessory]);
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 };
73 onInputCancel(blur);
74 window.addEventListener("blur", blur);
75 void loadPhoneViewer()
76 .then(({ createPhoneViewer }) => {
77 if (disposed) return;
78 const viewer = createPhoneViewer({
79 canvas,
80 source: decoded,
81 onUnavailable,
82 onModelError: (cause) => console.warn("Device 3D asset could not load", cause),
83 profile: profileRef.current,
84 model: modelRef.current,
85 accessory: accessoryRef.current,
86 foldAngle: foldAngleRef.current,
87 });
88 viewerRef.current = viewer;
89 onResetReady(viewer.resetPose);
90 viewer.setScreen(screenRef.current);
91 onFrameListener(viewer.frameUpdated);
92 interactionRef.current = createPhoneInteraction({
93 screenPoint: (point, captured) => viewer.screenPoint(point.x, point.y, captured),
94 touch: (phase, point) => client.current?.sendTouch(phase, point.x, point.y),
95 orbit: viewer.orbit,
96 zoomBy: () => {},
97 onInteractionActive: viewer.setInteractionActive,
98 });
99 trackpad = bindPhoneTrackpad(canvas, interactionRef.current);
100 stopTrackpadEnd = window.desktopBridge?.onTrackpadScrollEnd?.(() => trackpad?.endOrbit());
101 resize();
102 viewer.frameUpdated();
103 })
104 .catch(() => {
105 if (!disposed) onUnavailable();
106 });
107 return () => {
108 disposed = true;
109 stopTrackpadEnd?.();
110 trackpad?.dispose();
111 interactionRef.current?.end();
112 interactionRef.current = null;
113 onInputCancel(null);
114 onResetReady(null);
115 onFrameListener(null);
116 stopObserving();
117 window.removeEventListener("blur", blur);
118 viewerRef.current?.dispose();
119 viewerRef.current = null;
120 };
121 }, [onInputCancel, onResetReady, client, onFrameListener, onUnavailable, source]);
122
123 const point = (event: React.PointerEvent<HTMLCanvasElement>) => {
124 const rect = event.currentTarget.getBoundingClientRect();
125 return {
126 x: (event.clientX - rect.left) / rect.width,
127 y: (event.clientY - rect.top) / rect.height,
128 };
129 };
130 return (
131 <div className="absolute inset-0">
132 <div ref={hostRef} className="absolute inset-0">
133 <div
134 aria-hidden
135 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"
136 />
137 <canvas
138 ref={canvasRef}
139 aria-label="Interactive 3D device. Drag the screen to interact. Drag outside it or swipe with two fingers to turn."
140 className="size-full touch-none"
141 onPointerDown={(event) => {
142 if (event.button !== 0) return;
143 if (!interactionRef.current?.begin(event.pointerId, point(event), event.altKey)) return;
144 event.preventDefault();
145 event.currentTarget.setPointerCapture(event.pointerId);
146 (event.currentTarget.closest('[role="application"]') as HTMLElement | null)?.focus();
147 }}
148 onPointerMove={(event) => interactionRef.current?.move(event.pointerId, point(event))}
149 onPointerUp={(event) => {
150 interactionRef.current?.move(event.pointerId, point(event));
151 interactionRef.current?.end(event.pointerId);
152 }}
153 onPointerCancel={(event) => interactionRef.current?.end(event.pointerId)}
154 onLostPointerCapture={(event) => interactionRef.current?.end(event.pointerId)}
155 />
156 </div>
157 </div>
158 );
159}