Skip to content
Source
Sign inSign up

device-stream.browser.ts

188 lines6.2 KB

1import {
2 createDeviceStreamClient,
3 type DeviceScreenSize,
4} from "@t3tools/client-runtime/device/stream";
5​
6import type { DeviceStreamConfiguration } from "./device-stream-document";
7​
8declare global {
9 interface Window {
10 ReactNativeWebView: { postMessage: (message: string) => void };
11 }
13​
14let activeClient: ReturnType<typeof createDeviceStreamClient> | null = null;
15​
16export function stop() {
17 activeClient?.stop();
18 activeClient = null;
20​
21export function command(button: "home" | "back" | "appSwitcher" | "rotate") {
22 if (button === "rotate") activeClient?.rotate();
23 else activeClient?.pressButton(button);
25​
26export function start(configuration: DeviceStreamConfiguration) {
27 stop();
28 // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string.
29 const post = (message: object) => window.ReactNativeWebView.postMessage(JSON.stringify(message));
30 const { colors, platform } = configuration;
31 Object.assign(document.documentElement.style, { height: "100%", overflow: "hidden" });
32 Object.assign(document.body.style, {
33 margin: "0",
34 height: "100%",
35 overflow: "hidden",
36 background: colors.background,
37 color: colors.foreground,
38 fontFamily: "system-ui",
39 });
40 const container = document.createElement("div");
41 Object.assign(container.style, {
42 position: "fixed",
43 inset: "0",
44 containerType: "size",
45 display: "flex",
46 alignItems: "center",
47 justifyContent: "center",
48 });
49 const frame = document.createElement("div");
50 frame.setAttribute("role", "application");
51 frame.setAttribute(
52 "aria-label",
53 platform === "ios" ? "iOS Simulator screen" : "Android Emulator screen",
54 );
55 frame.tabIndex = 0;
56 Object.assign(frame.style, {
57 position: "relative",
58 touchAction: "none",
59 userSelect: "none",
60 webkitUserSelect: "none",
61 webkitTouchCallout: "none",
62 outline: "none",
63 });
64 const canvas = document.createElement("canvas");
65 const image = document.createElement("img");
66 image.alt = "";
67 image.draggable = false;
68 image.style.display = "none";
69 const inputStatus = document.createElement("div");
70 inputStatus.setAttribute("role", "status");
71 inputStatus.textContent = "Reconnecting device controls...";
72 Object.assign(inputStatus.style, {
73 position: "fixed",
74 bottom: "12px",
75 left: "0",
76 right: "0",
77 textAlign: "center",
78 pointerEvents: "none",
79 fontSize: "13px",
80 color: colors.muted,
81 background: colors.background,
82 });
83 frame.append(canvas, image);
84 container.append(frame);
85 document.body.replaceChildren(container, inputStatus);
86​
87 let pointerId: number | null = null;
88 let inputConnected = false;
89 let streaming = false;
90 const reportStatus = (status: "connecting" | "streaming" | "error", detail?: string) => {
91 if (activeClient !== client) return;
92 streaming = status === "streaming";
93 inputStatus.style.display = streaming && !inputConnected ? "block" : "none";
94 post({ type: "status", status, detail });
95 };
96 const layout = (screen: DeviceScreenSize | null) => {
97 const landscape =
98 screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right";
99 const aspect = screen
100 ? landscape
101 ? Math.max(screen.width, screen.height) / Math.min(screen.width, screen.height)
102 : Math.min(screen.width, screen.height) / Math.max(screen.width, screen.height)
103 : 9 / 19.5;
104 const rotation =
105 platform === "ios" && screen && screen.width <= screen.height
106 ? screen.orientation === "landscape_left"
107 ? 90
108 : screen.orientation === "landscape_right"
109 ? -90
110 : screen.orientation === "portrait_upside_down"
111 ? 180
112 : 0
113 : 0;
114 const sideways = Math.abs(rotation) === 90;
115 frame.style.width = `min(100cqw, ${aspect * 100}cqh)`;
116 frame.style.height = `min(100cqh, ${100 / aspect}cqw)`;
117 for (const media of [canvas, image]) {
118 Object.assign(media.style, {
119 position: "absolute",
120 width: sideways ? `${100 / aspect}%` : "100%",
121 height: sideways ? `${100 * aspect}%` : "100%",
122 left: "50%",
123 top: "50%",
124 transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
125 pointerEvents: "none",
126 });
127 }
128 };
129 layout(null);
130 const unauthorized = () => {
131 if (activeClient === client) post({ type: "unauthorized" });
132 };
133 const client = createDeviceStreamClient(configuration, canvas, {
134 onStatus: reportStatus,
135 onScreen: layout,
136 onMjpegFallback: () => {
137 canvas.style.display = "none";
138 image.style.display = "block";
139 },
140 onCanvasVideo: () => {
141 image.style.display = "none";
142 canvas.style.display = "";
143 },
144 onUnauthorized: unauthorized,
145 onInputConnected: (connected) => {
146 inputConnected = connected;
147 inputStatus.style.display = streaming && !connected ? "block" : "none";
148 post({ type: "input", connected });
149 },
150 });
151 activeClient = client;
152 client.setMjpegImage(image);
153 const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => {
154 const rect = frame.getBoundingClientRect();
155 client.sendTouch(
156 phase,
157 Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)),
158 Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)),
159 );
160 };
161 frame.addEventListener("pointerdown", (event) => {
162 if (!inputConnected || pointerId !== null) return;
163 event.preventDefault();
164 pointerId = event.pointerId;
165 frame.setPointerCapture(event.pointerId);
166 frame.focus();
167 touch(event, "begin");
168 });
169 frame.addEventListener("pointermove", (event) => {
170 if (pointerId === event.pointerId) touch(event, "move");
171 });
172 const endTouch = (event: PointerEvent) => {
173 if (pointerId !== event.pointerId) return;
174 pointerId = null;
175 touch(event, "end");
176 };
177 frame.addEventListener("pointerup", endTouch);
178 frame.addEventListener("pointercancel", endTouch);
179 frame.addEventListener("lostpointercapture", endTouch);
180 frame.addEventListener("keydown", (event) => {
181 event.preventDefault();
182 client.sendKey(event, "down");
183 });
184 frame.addEventListener("keyup", (event) => client.sendKey(event, "up"));
185 window.addEventListener("pagehide", stop, { once: true });
186 post({ type: "input", connected: false });
187 client.start();