1import { VMMenu } from "./VMMenu.tsx";
2import { createEffect, createMemo, createSignal, type JSX, on, onCleanup, Show } from "solid-js";
3import { reason } from "../api.ts";
4import Keyboard from "lucide-solid/icons/keyboard";
5import Clipboard from "lucide-solid/icons/clipboard";
6import Maximize from "lucide-solid/icons/maximize";
7import RefreshCw from "lucide-solid/icons/refresh-cw";
8import Check from "lucide-solid/icons/check";
9import RemoteKeyboard from "@novnc/keyboard";
10import { VM_SETUP, type Domain } from "../types/vms.ts";
11import { StatusLabel } from "./Status.tsx";
12
13type WindowSurface = {
14 id: number; x: number; y: number; width: number; height: number; atlas_x: number; atlas_y: number;
15 caption_left: number; caption_top: number; caption_right: number; caption_bottom: number;
16};
17type WindowFrame = { windows: WindowSurface[]; rtpTimestamp: number };
18
19export function VMConsole(props: { name: string; enabled: boolean; active: boolean; setup?: Domain["setup"]; toolbar: JSX.Element; content: (toolbar: HTMLDivElement) => JSX.Element }) {
20 const enabled = createMemo(() => props.enabled);
21 const preparing = () => !!props.setup && props.setup !== "ready";
22 let reconnectTimer: number | undefined;
23 let attempt = 0;
24 let video!: HTMLVideoElement;
25 let canvas!: HTMLCanvasElement;
26 let cursorImage!: HTMLImageElement;
27 let cursorInvert!: HTMLImageElement;
28 let panel!: HTMLDivElement;
29 let input: RTCDataChannel | undefined;
30 let movement: RTCDataChannel | undefined;
31 let pointerSequence = 0;
32 let windowFrame: WindowFrame | undefined;
33 let pendingFrames: WindowFrame[] = [];
34 let frameTimestamp: number | undefined;
35 let drag: { id: number; startX: number; startY: number; x: number; y: number; dx: number; dy: number; released: number } | undefined;
36 let disconnect = () => {};
37 let width = 0;
38 let height = 0;
39 let buttons = 0;
40 let disposed = false;
41 let cursorFrames: { image: string; invert?: string; duration: number }[] = [];
42 let cursorFrame = 0;
43 let cursorTimer: number | undefined;
44 let cursorX = 0;
45 let cursorY = 0;
46 let pointerX = 0;
47 let pointerY = 0;
48 let cursorInside = false;
49 let cursorVisible = true;
50 const drawCursor = () => {
51 const frame = cursorFrames[cursorFrame];
52 const overlay = !!frame?.invert && cursorInside && cursorVisible;
53 cursorImage.style.display = cursorInvert.style.display = overlay ? "block" : "none";
54 if (!frame || !cursorVisible) { video.style.cursor = cursorVisible ? "default" : "none"; return; }
55 video.style.cursor = frame.invert ? (cursorInside ? "none" : "default") : `url(${frame.image}) ${cursorX} ${cursorY}, default`;
56 if (overlay) {
57 cursorImage.src = frame.image;
58 cursorInvert.src = frame.invert!;
59 cursorImage.style.transform = cursorInvert.style.transform = `translate(${pointerX - cursorX}px, ${pointerY - cursorY}px)`;
60 }
61 };
62 const animateCursor = () => {
63 const frame = cursorFrames[cursorFrame];
64 if (!frame) return;
65 cursorTimer = window.setTimeout(() => {
66 cursorFrame = (cursorFrame + 1) % cursorFrames.length;
67 drawCursor();
68 animateCursor();
69 }, frame.duration);
70 };
71 const [state, setState] = createSignal<"Connecting…" | "Connected" | "Disconnected">("Connecting…");
72 const connected = () => state() === "Connected";
73 const [problem, setProblem] = createSignal("");
74 const [clipboard, setClipboard] = createSignal("");
75 const [showClipboard, setShowClipboard] = createSignal(false);
76 const [windowsAvailable, setWindowsAvailable] = createSignal(false);
77 const [separateWindows, setSeparateWindows] = createSignal(false);
78 const [frameSync, setFrameSync] = createSignal(false);
79 const [windowCapture, setWindowCapture] = createSignal(false);
80 const [showStats, setShowStats] = createSignal(false);
81 const [stats, setStats] = createSignal<{ fps: number; bitrate: number; lost: number; dropped: number; delay: number; rtt: number }>();
82 const [capture, setCapture] = createSignal<{ fps: number; capture: number; scan: number; write: number }>();
83 const [tools, setTools] = createSignal<HTMLDivElement>();
84 const send = (message: object) => { if (input?.readyState === "open") input.send(JSON.stringify(message)); };
85 const compose = () => {
86 const frame = windowFrame;
87 const composed = !!frame?.windows.length;
88 video.classList.toggle("vm-video-composed", composed);
89 canvas.hidden = !composed;
90 if (!frame || !composed || !width || !height || video.readyState < 2) return;
91 if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
92 const context = canvas.getContext("2d", { alpha: false });
93 if (!context) return;
94 context.fillStyle = "#101014";
95 context.fillRect(0, 0, width, height);
96 for (const window of [...frame.windows].reverse()) {
97 let x = window.x, y = window.y;
98 if (drag?.id === window.id) {
99 const acknowledged = Math.abs(window.x - drag.x - drag.dx) <= 3 && Math.abs(window.y - drag.y - drag.dy) <= 3;
100 if (drag.released && (acknowledged || performance.now() - drag.released > 500)) drag = undefined;
101 else { x = drag.x + drag.dx; y = drag.y + drag.dy; }
102 }
103 context.drawImage(video, window.atlas_x, window.atlas_y, window.width, window.height, x, y, window.width, window.height);
104 }
105 canvas.dataset.windows = String(frame.windows.length);
106 };
107 const release = () => { buttons = 0; drag = undefined; compose(); send({ type: "release", sequence: ++pointerSequence }); };
108 const connect = async () => {
109 const current = ++attempt;
110 disconnect();
111 input = undefined;
112 setProblem("");
113 setState("Connecting…");
114 try {
115 if (disposed || !enabled() || current !== attempt) return;
116 if (!window.RTCPeerConnection) {
117 setProblem("This browser cannot stream VM screens. Open it in a current browser.");
118 setState("Disconnected");
119 return;
120 }
121 const url = new URL(`/api/vms/${encodeURIComponent(props.name)}/console`, location.href);
122 url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
123 const socket = new WebSocket(url.href, ["binary"]);
124 socket.binaryType = "arraybuffer";
125 const keyboard = new RemoteKeyboard(video);
126 const focus = () => { if (input?.readyState === "open") keyboard.grab(); };
127 const blur = () => { keyboard.ungrab(); release(); };
128 const releaseKeyboard = (event: KeyboardEvent) => {
129 if (event.key !== "Escape" || !event.ctrlKey || !event.altKey) return;
130 event.preventDefault();
131 event.stopImmediatePropagation();
132 tools()?.querySelector<HTMLElement>("summary")?.focus();
133 };
134 video.addEventListener("keydown", releaseKeyboard, true);
135 video.addEventListener("focus", focus);
136 video.addEventListener("blur", blur);
137 let peer: RTCPeerConnection | undefined;
138 let frameCallback: number | undefined;
139 let statsTimer: number | undefined;
140 let previousStats: { time: number; bytes: number; frames: number } | undefined;
141 const frameReady: VideoFrameRequestCallback = (_, metadata) => {
142 if (current !== attempt || disposed) return;
143 frameTimestamp = (metadata as VideoFrameCallbackMetadata & { rtpTimestamp?: number }).rtpTimestamp;
144 setFrameSync(Number.isFinite(frameTimestamp));
145 if (!frameSync()) {
146 frameTimestamp = undefined; windowFrame = undefined; pendingFrames = [];
147 if (separateWindows()) { setSeparateWindows(false); send({ type: "experiment", enabled: false }); }
148 }
149 while (pendingFrames[0] && frameTimestamp !== undefined && ((frameTimestamp - pendingFrames[0].rtpTimestamp) | 0) >= 0) {
150 windowFrame = pendingFrames.shift();
151 }
152 compose();
153 frameCallback = video.requestVideoFrameCallback(frameReady);
154 };
155 const measure = async () => {
156 if (!peer || current !== attempt || disposed) return;
157 try {
158 const report = await peer.getStats();
159 if (current !== attempt || disposed || peer.connectionState === "closed") return;
160 let rtt = 0;
161 report.forEach((value) => { if (value.type === "candidate-pair" && value.state === "succeeded" && value.nominated) rtt = (value.currentRoundTripTime ?? 0) * 1000; });
162 report.forEach((value) => {
163 if (value.type !== "inbound-rtp" || value.kind !== "video") return;
164 const elapsed = previousStats ? (value.timestamp - previousStats.time) / 1000 : 0;
165 setStats({ fps: elapsed ? (value.framesDecoded - previousStats!.frames) / elapsed : 0,
166 bitrate: elapsed ? (value.bytesReceived - previousStats!.bytes) * 8 / elapsed / 1000000 : 0,
167 lost: value.packetsLost ?? 0, dropped: value.framesDropped ?? 0,
168 delay: value.jitterBufferEmittedCount ? value.jitterBufferDelay / value.jitterBufferEmittedCount * 1000 : 0, rtt });
169 previousStats = { time: value.timestamp, bytes: value.bytesReceived, frames: value.framesDecoded };
170 });
171 } catch {
172 if (peer.connectionState !== "closed") return;
173 } finally {
174 if (current === attempt && !disposed && peer.connectionState !== "closed") statsTimer = window.setTimeout(measure, 1000);
175 }
176 };
177 let pending = "";
178 let messages = Promise.resolve();
179 const decoder = new TextDecoder();
180 const candidates: RTCIceCandidateInit[] = [];
181 const signal = (message: object) => socket.send(JSON.stringify(message) + "\n");
182 const fail = (message: string) => {
183 if (current !== attempt || disposed) return;
184 setState("Disconnected");
185 disconnect();
186 if (preparing() && props.setup !== "failed") {
187 setProblem("");
188 reconnectTimer = window.setTimeout(connect, 1000);
189 } else setProblem(message);
190 };
191 const timeout = window.setTimeout(() => fail("The screen couldn't connect. Reconnect to try again."), 20000);
192 const ready = () => {
193 if (current !== attempt || disposed || video.readyState < 2 || input?.readyState !== "open") return;
194 clearTimeout(timeout);
195 setState("Connected");
196 };
197 disconnect = () => {
198 clearTimeout(reconnectTimer);
199 clearTimeout(timeout);
200 blur();
201 video.removeEventListener("keydown", releaseKeyboard, true);
202 video.removeEventListener("focus", focus);
203 video.removeEventListener("blur", blur);
204 socket.onclose = null;
205 socket.close();
206 peer?.close();
207 clearTimeout(statsTimer);
208 if (frameCallback !== undefined) video.cancelVideoFrameCallback(frameCallback);
209 windowFrame = undefined; pendingFrames = []; frameTimestamp = undefined; drag = undefined;
210 movement = undefined; pointerSequence = 0;
211 setFrameSync(false); setWindowsAvailable(false); setWindowCapture(false); setStats(undefined); setCapture(undefined);
212 compose();
213 video.onplaying = null;
214 video.srcObject = null;
215 clearTimeout(cursorTimer);
216 cursorFrames = [];
217 cursorFrame = 0;
218 cursorVisible = true;
219 cursorImage.style.display = cursorInvert.style.display = "none";
220 video.style.cursor = "default";
221 };
222 socket.onclose = () => fail("The screen disconnected. Check that the VM is running, then reconnect.");
223 socket.onerror = () => fail("Unable to connect the screen. Reconnect to try again.");
224 socket.onmessage = (event: MessageEvent<ArrayBuffer>) => {
225 pending += decoder.decode(event.data, { stream: true });
226 if (pending.length > 262144) { fail("The screen connection stopped. Reconnect to try again."); return; }
227 let end: number;
228 while ((end = pending.indexOf("\n")) !== -1) {
229 const line = pending.slice(0, end);
230 pending = pending.slice(end + 1);
231 messages = messages.then(async () => {
232 if (current !== attempt || disposed) return;
233 const message = JSON.parse(line);
234 if (message.type === "ready") {
235 peer = new RTCPeerConnection({ iceServers: message.iceServers });
236 const transceiver = peer.addTransceiver("video", { direction: "recvonly" });
237 const codecs = RTCRtpReceiver.getCapabilities("video")?.codecs.filter((codec) => codec.mimeType === "video/H264" || codec.mimeType === "video/rtx");
238 if (codecs?.length) transceiver.setCodecPreferences(codecs);
239 input = peer.createDataChannel("input");
240 movement = peer.createDataChannel("pointer", { ordered: false, maxRetransmits: 0 });
241 keyboard.onkeyevent = (key, _code, down) => send({ type: "key", key, down });
242 input.onopen = () => {
243 ready();
244 if (document.activeElement === video) focus();
245 if (windowsAvailable()) send({ type: "experiment", enabled: separateWindows() });
246 };
247 peer.onicecandidate = ({ candidate }) => { if (candidate) signal({ type: "candidate", ...candidate.toJSON() }); };
248 peer.onconnectionstatechange = () => {
249 if (peer?.connectionState === "failed" || peer?.connectionState === "disconnected") {
250 fail("The screen disconnected. Reconnect to try again.");
251 }
252 };
253 peer.ontrack = ({ track }) => {
254 const receiver = transceiver.receiver as RTCRtpReceiver & { jitterBufferTarget?: number };
255 if ("jitterBufferTarget" in receiver) receiver.jitterBufferTarget = 0;
256 video.srcObject = new MediaStream([track]);
257 if (video.requestVideoFrameCallback) frameCallback = video.requestVideoFrameCallback(frameReady);
258 void measure();
259 void video.play().catch(() => {
260 if (current === attempt && video.srcObject) fail("The browser couldn't play the screen. Reconnect to try again.");
261 });
262 };
263 await peer.setLocalDescription(await peer.createOffer());
264 signal({ type: "offer", sdp: peer.localDescription!.sdp });
265 } else if (message.type === "answer" && peer) {
266 await peer.setRemoteDescription({ type: "answer", sdp: message.sdp });
267 for (const candidate of candidates.splice(0)) await peer.addIceCandidate(candidate);
268 } else if (message.type === "candidate") {
269 if (peer?.remoteDescription) await peer.addIceCandidate(message);
270 else candidates.push(message);
271 } else if (message.type === "display") {
272 width = message.width;
273 height = message.height;
274 video.dataset.encoder = message.encoder;
275 video.dataset.source = message.source ?? "qemu";
276 } else if (message.type === "capabilities") {
277 setWindowsAvailable(message.windows);
278 if (message.windows) send({ type: "experiment", enabled: separateWindows() });
279 } else if (message.type === "window-capture") {
280 setWindowCapture(message.available);
281 } else if (message.type === "windows") {
282 pendingFrames.push(message);
283 if (pendingFrames.length > 16) pendingFrames.shift();
284 if (frameTimestamp !== undefined && ((frameTimestamp - message.rtpTimestamp) | 0) >= 0) {
285 windowFrame = message; pendingFrames = []; compose();
286 }
287 } else if (message.type === "window-positions") {
288 if (windowFrame?.windows.length && windowFrame.windows.length === message.windows.length &&
289 windowFrame.windows.every((window) => message.windows.some((next: WindowSurface) => window.id === next.id && window.width === next.width && window.height === next.height))) {
290 windowFrame = { ...windowFrame, windows: message.windows }; compose();
291 }
292 } else if (message.type === "capture-stats") {
293 setCapture({ fps: message.frames * 1000 / message.elapsed_ms,
294 capture: message.frames ? message.capture_us / message.frames / 1000 : 0,
295 scan: message.frames ? message.scan_us / message.frames / 1000 : 0,
296 write: message.frames ? message.write_us / message.frames / 1000 : 0 });
297 } else if (message.type === "cursor") {
298 if (message.frame === 0) {
299 clearTimeout(cursorTimer);
300 cursorFrames = [];
301 cursorFrame = 0;
302 cursorX = message.x;
303 cursorY = message.y;
304 if (message.source !== "guest") cursorVisible = !!message.fallback || (!!message.width && !!message.height);
305 video.dataset.cursorSource = message.source;
306 }
307 if (!message.image) { drawCursor(); return; }
308 for (const url of [message.image, message.invert].filter(Boolean)) {
309 const image = new Image();
310 image.src = url;
311 await image.decode();
312 }
313 if (current !== attempt || disposed) return;
314 cursorFrames.push({ image: message.image, invert: message.invert, duration: message.duration });
315 if (message.frame === 0) drawCursor();
316 if (cursorFrames.length === message.frames && message.frames > 1) animateCursor();
317 } else if (message.type === "cursor-position") {
318 cursorVisible = message.visible;
319 drawCursor();
320 } else if (message.type === "clipboard") setClipboard(message.text);
321 else if (message.type === "error") {
322 if (message.fatal) fail(message.message);
323 else setProblem(message.message);
324 }
325 }).catch((failure) => {
326 console.error("VM screen connection", failure);
327 fail("The browser couldn't connect the screen. Reconnect to try again.");
328 });
329 }
330 };
331 video.onplaying = () => {
332 if (current !== attempt || disposed) return;
333 ready();
334 compose();
335 };
336 } catch (failure) {
337 console.error("VM screen connection", failure);
338 disconnect();
339 setProblem("The browser couldn't connect the screen. Reconnect to try again.");
340 setState("Disconnected");
341 }
342 };
343 createEffect(() => {
344 if (enabled()) void connect();
345 else {
346 attempt++;
347 disconnect();
348 input = undefined;
349 setState("Disconnected");
350 setProblem("");
351 }
352 });
353 createEffect(() => { if (props.active && connected() && !preparing()) video.focus(); else release(); });
354 createEffect(on(() => props.setup, setup => {
355 if (setup === "ready" && enabled() && state() === "Disconnected") void connect();
356 }, { defer: true }));
357 onCleanup(() => { disposed = true; disconnect(); });
358 const pointer = (event: PointerEvent | WheelEvent, mask = buttons) => {
359 if (!connected() || !width || !height) return;
360 const rect = video.getBoundingClientRect();
361 const scale = Math.min(rect.width / width, rect.height / height);
362 const x = Math.floor((event.clientX - rect.left - (rect.width - width * scale) / 2) / scale);
363 const y = Math.floor((event.clientY - rect.top - (rect.height - height * scale) / 2) / scale);
364 pointerX = event.clientX;
365 pointerY = event.clientY;
366 cursorInside = x >= 0 && y >= 0 && x < width && y < height;
367 drawCursor();
368 if ((event.type === "pointerdown" || event.type === "wheel") && (x < 0 || y < 0 || x >= width || y >= height)) return false;
369 if (event.type === "pointerdown" && mask === 1) {
370 const window = windowFrame?.windows.find((window) => x >= window.x && y >= window.y && x < window.x + window.width && y < window.y + window.height);
371 if (window && x >= window.caption_left && x < window.caption_right && y >= window.caption_top && y < window.caption_bottom) {
372 drag = { id: window.id, startX: x, startY: y, x: window.x, y: window.y, dx: 0, dy: 0, released: 0 };
373 }
374 }
375 if (drag && event.type === "pointermove" && buttons === 1) { drag.dx = x - drag.startX; drag.dy = y - drag.startY; compose(); }
376 if (drag && event.type === "pointerup" && !mask) { drag.released = performance.now(); window.setTimeout(compose, 510); }
377 const message = JSON.stringify({ type: "pointer", x, y, buttons: mask, sequence: ++pointerSequence });
378 if (event.type === "pointermove" && !mask && movement?.readyState === "open") {
379 if (movement.bufferedAmount < 16384) movement.send(message);
380 } else if (input?.readyState === "open" && (event.type !== "pointermove" || input.bufferedAmount < 16384)) input.send(message);
381 return true;
382 };
383 const ctrlAltDel = () => {
384 for (const key of [0xffe3, 0xffe9, 0xffff]) send({ type: "key", key, down: true });
385 for (const key of [0xffff, 0xffe9, 0xffe3]) send({ type: "key", key, down: false });
386 };
387 return <div class="vm-console" ref={panel}>
388 <div class="vm-console-toolbar" ref={setTools}>
389 {props.toolbar}
390 <Show when={props.active}>
391 <VMMenu label="Input">
392 <button disabled={!connected()} onClick={ctrlAltDel}><Keyboard size={14} aria-hidden="true" />Ctrl+Alt+Del</button>
393 <button disabled={!connected()} onClick={() => setShowClipboard(!showClipboard())}><Clipboard size={14} aria-hidden="true" />Clipboard</button>
394 </VMMenu>
395 <VMMenu label="View">
396 <button disabled={!connected() || !windowsAvailable() || !frameSync()} aria-pressed={separateWindows()}
397 onClick={() => { const enabled = !separateWindows(); setSeparateWindows(enabled); send({ type: "experiment", enabled }); }}>
398 <Check size={14} aria-hidden="true" style={{ visibility: separateWindows() ? "visible" : "hidden" }} />
399 Separate windows (experimental)
400 </button>
401 <button aria-pressed={showStats()} onClick={() => setShowStats(!showStats())}><Check size={14} aria-hidden="true" style={{ visibility: showStats() ? "visible" : "hidden" }} />Stream statistics</button>
402 <button onClick={() => panel.requestFullscreen().catch((failure) => setProblem(reason(failure)))}><Maximize size={14} aria-hidden="true" />Fullscreen</button>
403 <button disabled={!props.enabled || state() === "Connecting…"} onClick={connect}><RefreshCw size={14} aria-hidden="true" />Reconnect</button>
404 </VMMenu>
405 <span class="spacer" />
406 <Show when={connected() && !preparing()}><span class="vm-console-status" id={`keyboard-release-${props.name}`}>Ctrl+Alt+Esc releases keyboard</span></Show>
407 <span class="vm-console-status" role="status">{preparing() ? "" : props.enabled ? state() : "Off"}</span>
408 </Show>
409 </div>
410 <Show when={props.active && showStats()}>
411 <div class="vm-stream-stats">
412 <Show when={stats()}>{(value) => <><span>{value().fps.toFixed(1)} FPS</span><span>{value().bitrate.toFixed(2)} Mbps</span><span>{value().lost} packets lost</span><span>{value().dropped} frames dropped</span><span>{value().rtt.toFixed(0)} ms round trip</span><span>{value().delay.toFixed(0)} ms buffer</span></>}</Show>
413 <Show when={capture()}>{(value) => <><span>Guest {value().fps.toFixed(1)} FPS</span><span>Desktop capture {value().capture.toFixed(1)} ms</span><span>Compare {value().scan.toFixed(1)} ms</span><span>Stream work {value().write.toFixed(1)} ms</span></>}</Show>
414 <Show when={separateWindows()}><span>{windowCapture() ? "Separate windows" : "Desktop fallback"}</span></Show>
415 </div>
416 </Show>
417 <Show when={props.active && !preparing() && problem()}><p class="error vm-console-error" role="alert">{problem()}</p></Show>
418 <Show when={props.active && showClipboard()}>
419 <div class="vm-clipboard">
420 <label class="field">clipboard<textarea class="search" rows="3" value={clipboard()} onInput={(event) => setClipboard(event.currentTarget.value)} /></label>
421 <span class="hint">Clipboard sharing requires support in the guest</span>
422 <div class="row">
423 <button class="button small" disabled={!connected()} onClick={() => send({ type: "clipboard", text: clipboard() })}>send text</button>
424 <button class="button small" onClick={() => navigator.clipboard.writeText(clipboard()).catch((failure) => setProblem(reason(failure)))}>copy text</button>
425 </div>
426 </div>
427 </Show>
428 <Show when={props.active && props.enabled && preparing()}>
429 <div class="empty vm-setup" role={props.setup === "failed" ? "alert" : "status"}>
430 <StatusLabel health={props.setup === "failed" ? "down" : "starting"}>{VM_SETUP[props.setup!]}</StatusLabel>
431 <p>{props.setup === "failed" ? "Restart this VM from the Machine menu to retry." : "Your desktop opens here when setup finishes."}</p>
432 </div>
433 </Show>
434 <div class="vm-console-screen" hidden={!props.active || !props.enabled || preparing()}>
435 <canvas ref={canvas} class="vm-window-canvas" hidden aria-hidden="true" />
436 <video ref={video} autoplay muted playsinline tabindex="0" aria-label="VM screen" aria-describedby={`keyboard-release-${props.name}`}
437 onContextMenu={(event) => event.preventDefault()}
438 onPointerMove={pointer}
439 onPointerLeave={() => { cursorInside = false; drawCursor(); }}
440 onPointerDown={(event) => {
441 event.preventDefault();
442 const mask = buttons | (event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0);
443 if (!pointer(event)) return;
444 pointer(event, mask);
445 buttons = mask;
446 video.focus(); video.setPointerCapture(event.pointerId);
447 }}
448 onPointerUp={(event) => {
449 if (buttons) pointer(event);
450 buttons &= ~(event.button === 0 ? 1 : event.button === 1 ? 2 : event.button === 2 ? 4 : 0);
451 pointer(event);
452 if (!event.buttons && video.hasPointerCapture(event.pointerId)) video.releasePointerCapture(event.pointerId);
453 }}
454 onPointerCancel={release}
455 onWheel={(event) => {
456 event.preventDefault();
457 const mask = event.deltaY ? (event.deltaY < 0 ? 8 : 16) : event.deltaX < 0 ? 32 : 64;
458 pointer(event, buttons | mask); pointer(event);
459 }} />
460 <img ref={cursorImage} class="vm-cursor" alt="" aria-hidden="true" />
461 <img ref={cursorInvert} class="vm-cursor vm-cursor-invert" alt="" aria-hidden="true" />
462 </div>
463 <Show when={tools()}>{(toolbar) => props.content(toolbar())}</Show>
464 <Show when={props.active && !props.enabled}><div class="empty"><p>The VM is off. Start it from the Machine menu.</p></div></Show>
465 </div>;
466}