mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 17:01:40 +02:00
- docs/mac-space.md: what Vision Pro's Mac Virtual Display does, what the Frame allows (another process can't move SteamVR's panels: verified, PermissionDenied), and why a WebXR page comes first. - ui/mac-stream.js: the stream protocol shared by the viewers (connect, clock sync, decode, acks/reports, reconnect, input); mac-view.html uses it. - ui/mac-space.html: WebXR (passthrough behind) or an in-page preview. Desktop panel with a Vision Pro-style bar and close button; laser clicks, thumbstick scrolls, squeeze or the bar grabs; dragging a window's title bar off the desktop opens it on its own display as a new panel that follows the pointer; dropping it back sends it home. - Agent: space tickets -> a space key (/space/key) that lists windows and displays (/space/windows, now with positions) and opens any stream; Stop All revokes it. Serves /space and /mac-stream.js. - Frame Control: "Open Mac space" in the Tools card. Verified on the Mac (preview in headless Chrome, lab agent): the main display streams into the panel; a test window detached onto its own display and streamed; dropped back, it returned to its exact frame and the display was removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
181 lines
7.8 KiB
HTML
181 lines
7.8 KiB
HTML
<!doctype html>
|
||
<!-- Frame Control: one Mac window (or display) inside the Steam Frame.
|
||
Served by the Mac's frame-mac-view agent through an SSH tunnel and opened
|
||
on the Frame as its own Chromium app window, which gamescope turns into a
|
||
SteamVR panel. Video arrives over a WebSocket (H.264 Annex B for
|
||
WebCodecs, or JPEG); pointer, wheel and keys go back the same way. -->
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Mac</title>
|
||
<style>
|
||
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; cursor: default; }
|
||
canvas { position: fixed; inset: 0; width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }
|
||
#note { position: fixed; left: 50%; top: 16px; transform: translateX(-50%); max-width: 80%;
|
||
font: 15px/1.4 system-ui, sans-serif; color: #eee; background: rgba(20,22,26,.88);
|
||
padding: 8px 14px; border-radius: 10px; pointer-events: none; transition: opacity .4s; }
|
||
#note[hidden] { display: block; opacity: 0; }
|
||
#hud { position: fixed; left: 8px; top: 8px; font: 12px/1.35 ui-monospace, monospace; color: #dfe;
|
||
background: rgba(0,0,0,.72); padding: 6px 9px; border-radius: 6px; pointer-events: none; white-space: pre; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="c" width="16" height="9"></canvas>
|
||
<div id="note">Connecting to the Mac…</div>
|
||
<div id="hud" hidden></div>
|
||
<script src="/mac-stream.js"></script>
|
||
<script>
|
||
"use strict";
|
||
const q = new URLSearchParams(location.search);
|
||
// t: a single-use ticket from Frame Control. After the first connection the
|
||
// Mac sends a reconnect key, which the stream keeps in memory only.
|
||
const src = q.get("src") || "test", tag = q.get("tag") || "";
|
||
const c = document.getElementById("c"), ctx = c.getContext("2d", { alpha: false, desynchronized: true });
|
||
const note = document.getElementById("note");
|
||
let noteTimer = 0, hideNoteOnFrame = true; // "Connecting…"/"Reconnecting…" go once video flows again
|
||
function show(text, ms) {
|
||
note.textContent = text;
|
||
note.hidden = false;
|
||
clearTimeout(noteTimer);
|
||
if (ms) noteTimer = setTimeout(() => { note.hidden = true; }, ms);
|
||
}
|
||
const hud = document.getElementById("hud");
|
||
function showHud(on) { hud.hidden = !on; }
|
||
function ms(o) { return o && o.p50 !== undefined ? `${o.p50}/${o.p95}` : "–"; }
|
||
function onStats(m) {
|
||
if (hud.hidden) return;
|
||
hud.textContent = `${m.size} ${m.fps} fps shown (${m.sentFps} sent) ${m.mbps} Mbit/s` +
|
||
(m.bitrate ? ` of ${(m.bitrate / 1e6).toFixed(1)}` : "") + (m.tier !== undefined ? ` tier ${m.tier}` : "") +
|
||
`\nlatency p50/p95 ms total ${ms(m.total)}\n capture ${ms(m.capture)} queue ${ms(m.queue)} encode ${ms(m.encode)}` +
|
||
`\n network ${ms(m.network)} decode ${ms(m.decode)} draw ${ms(m.draw)}` +
|
||
`\ninput→shown p50 ${m.input ? m.input.p50.toFixed(0) + " ms" : "–"} rtt ${m.rtt} ms` +
|
||
`\nskipped ${m.skipped}/${m.captured} dropped ${m.dropped} ${MacEnv.decoder}`;
|
||
}
|
||
showHud(q.get("stats") === "1");
|
||
// Frame Control finds this window on the Frame by the tag in its title.
|
||
document.title = tag ? `Mac [${tag}]` : "Mac";
|
||
|
||
const stream = new MacStream({
|
||
src, ticket: q.get("t") || "", codec: q.get("codec"), max: q.get("max"), fps: q.get("fps"), bpp: q.get("bpp"),
|
||
onFrame(img, w, h, seq, decodedAt) {
|
||
if (c.width !== w || c.height !== h) { c.width = w; c.height = h; }
|
||
ctx.drawImage(img, 0, 0);
|
||
img.close();
|
||
stream.drawn(seq, decodedAt);
|
||
if (hideNoteOnFrame) { hideNoteOnFrame = false; note.hidden = true; }
|
||
},
|
||
onStatus(text) { hideNoteOnFrame = true; show(text); },
|
||
onInfo(m) {
|
||
const name = m.title && m.app && m.title !== m.app ? `${m.title} — ${m.app}` : (m.title || m.app || "Mac");
|
||
document.title = tag ? `${name} [${tag}]` : name;
|
||
if (!m.input) {
|
||
hideNoteOnFrame = false; // a warning, not a connection notice: let it stay its 8 s
|
||
show("Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
|
||
}
|
||
},
|
||
onError(message) { show(message); },
|
||
onClosed(reason) {
|
||
if (reason === "expired") return;
|
||
show(reason ? `Stopped: ${reason}.` : "Stopped.");
|
||
window.close();
|
||
},
|
||
onStats,
|
||
onBench: bench,
|
||
});
|
||
// Counters for Frame Control's tests (read over DevTools).
|
||
window.stats = stream.stats;
|
||
|
||
// ---- input ----
|
||
// Where the picture sits inside the window (object-fit: contain letterboxes it).
|
||
function toPicture(e) {
|
||
const r = c.getBoundingClientRect(), s = Math.min(r.width / c.width, r.height / c.height);
|
||
const w = c.width * s, h = c.height * s, left = r.left + (r.width - w) / 2, top = r.top + (r.height - h) / 2;
|
||
const x = (e.clientX - left) / w, y = (e.clientY - top) / h;
|
||
return { x, y, inside: x >= 0 && x <= 1 && y >= 0 && y <= 1 };
|
||
}
|
||
// Moves go at most every 8 ms, on a timer rather than the next animation frame:
|
||
// the Frame throttles a panel's animation frames to 15-36 Hz when it thinks
|
||
// nobody is looking, which would hold a move back by up to 60 ms.
|
||
let pendingMove = null, moveQueued = false, lastMove = 0;
|
||
function flushMove() {
|
||
moveQueued = false;
|
||
if (pendingMove) { stream.move(pendingMove.x, pendingMove.y); lastMove = performance.now(); }
|
||
pendingMove = null;
|
||
}
|
||
addEventListener("pointermove", e => {
|
||
const p = toPicture(e);
|
||
if (!p.inside && !e.buttons) return;
|
||
pendingMove = p;
|
||
if (moveQueued) return;
|
||
const wait = lastMove + 8 - performance.now();
|
||
if (wait <= 0) return flushMove();
|
||
moveQueued = true;
|
||
setTimeout(flushMove, wait);
|
||
});
|
||
addEventListener("pointerdown", e => {
|
||
const p = toPicture(e);
|
||
if (!p.inside) return;
|
||
if (e.pointerId !== undefined) try { c.setPointerCapture(e.pointerId); } catch (err) {}
|
||
pendingMove = null;
|
||
stream.down(p.x, p.y, e.button < 0 ? 0 : e.button, e.timeStamp);
|
||
e.preventDefault();
|
||
});
|
||
addEventListener("pointerup", e => {
|
||
const p = toPicture(e);
|
||
stream.up(p.x, p.y, e.button < 0 ? 0 : e.button);
|
||
});
|
||
// Let go of any held button where the pointer is on the Mac, not at a corner.
|
||
addEventListener("pointercancel", () => stream.release());
|
||
addEventListener("contextmenu", e => e.preventDefault());
|
||
addEventListener("wheel", e => {
|
||
const p = toPicture(e), unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
|
||
stream.wheel(e.deltaX * unit, e.deltaY * unit, p.x, p.y, e.timeStamp);
|
||
e.preventDefault();
|
||
}, { passive: false });
|
||
function mods(e) {
|
||
return ["shift", "ctrl", "alt", "meta"].filter(m => e[m + "Key"]);
|
||
}
|
||
function onKey(e, down) {
|
||
// Ctrl+Alt+Shift+S shows the numbers; it isn't sent to the Mac.
|
||
if (e.code === "KeyS" && e.ctrlKey && e.altKey && e.shiftKey) {
|
||
if (down) showHud(hud.hidden);
|
||
return e.preventDefault();
|
||
}
|
||
stream.key(down, e.code, e.key, mods(e), e.timeStamp);
|
||
e.preventDefault();
|
||
}
|
||
|
||
// ---- benchmarks: the Mac asks the viewer to act as if someone did ----
|
||
function keyCode(ch) {
|
||
if (/[a-z]/i.test(ch)) return "Key" + ch.toUpperCase();
|
||
if (/[0-9]/.test(ch)) return "Digit" + ch;
|
||
return { " ": "Space", "\n": "Enter", ".": "Period", ",": "Comma" }[ch] || "";
|
||
}
|
||
function bench(m) {
|
||
if (m.action === "overlay") return showHud(m.on !== 0 && m.on !== "0");
|
||
if (m.action === "click") {
|
||
const x = +m.x || 0.5, y = +m.y || 0.5;
|
||
stream.down(x, y, 0);
|
||
setTimeout(() => stream.up(x, y, 0), 40);
|
||
} else if (m.action === "type") {
|
||
const text = String(m.text || ""), gap = +m.interval || 150;
|
||
[...text].forEach((ch, n) => setTimeout(() => {
|
||
const code = keyCode(ch);
|
||
if (!code) return stream.text(ch);
|
||
const mods = ch !== ch.toLowerCase() ? ["shift"] : [];
|
||
stream.key(true, code, ch, mods);
|
||
setTimeout(() => stream.key(false, code, ch, mods), 30);
|
||
}, n * gap));
|
||
}
|
||
}
|
||
addEventListener("keydown", e => onKey(e, true));
|
||
addEventListener("keyup", e => onKey(e, false));
|
||
addEventListener("blur", () => stream.release());
|
||
|
||
show("Connecting to the Mac…");
|
||
stream.start();
|
||
</script>
|
||
</body>
|
||
</html>
|