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