cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

split the frontend out of 1 html file.

Dami committed bca55e7
commit
bca55e70d711b8b9bb58290451db72e579e41781
parent
8f293953f25f7920479c9aa8e3d89af7873d990f
author
Dami <dami.etoile@gmail.com>
4 files changed, +1442 -1554
Asrc/codecs.js168+++
Msrc/index.html1557+------------------------------
Asrc/main.js828++++++++++++++++
Asrc/style.css443+++++++++
A src/codecs.js +168 -0
@@ -0,0 +1,168 @@
1+// ─── Annex B → AVCC/hvcC/av1C conversion ──────────────────────────────────2+// ffmpeg outputs Annex B (00 00 00 01 start codes) for H.264/H.265.3+// WebCodecs expects length-prefixed NALs + description record.4+// AV1 uses OBU format directly.5+6+export function parseAnnexBNals(data) {7+ const nals = [];8+ let i = 0;9+ while (i < data.length) {10+ let scl = 0;11+ if (i + 3 < data.length && data[i] === 0 && data[i+1] === 0 && data[i+2] === 0 && data[i+3] === 1) {12+ scl = 4;13+ } else if (i + 2 < data.length && data[i] === 0 && data[i+1] === 0 && data[i+2] === 1) {14+ scl = 3;15+ } else {16+ i++; continue;17+ }18+ const nalStart = i + scl;19+ let j = nalStart + 1;20+ while (j < data.length) {21+ if (j + 3 < data.length && data[j] === 0 && data[j+1] === 0 && data[j+2] === 0 && data[j+3] === 1) break;22+ if (j + 2 < data.length && data[j] === 0 && data[j+1] === 0 && data[j+2] === 1) break;23+ j++;24+ }25+ nals.push(data.subarray(nalStart, j));26+ i = j;27+ }28+ return nals;29+}30+31+export function nalsToLengthPrefixed(nals) {32+ let total = 0;33+ for (const nal of nals) total += 4 + nal.length;34+ const result = new Uint8Array(total);35+ let off = 0;36+ for (const nal of nals) {37+ const dv = new DataView(result.buffer, off, 4);38+ dv.setUint32(0, nal.length, false);39+ result.set(nal, off + 4);40+ off += 4 + nal.length;41+ }42+ return result;43+}44+45+// ─── H.264 ─────────────────────────────────────────────────────────────────46+47+export function h264NalType(nal) { return nal[0] & 0x1f; }48+49+export function buildAvcDescription(sps, pps) {50+ const buf = new Uint8Array(11 + sps.length + pps.length);51+ let off = 0;52+ buf[off++] = 1; // configurationVersion53+ buf[off++] = sps[1]; // profile_idc54+ buf[off++] = sps[2]; // constraint_flags55+ buf[off++] = sps[3]; // level_idc56+ buf[off++] = 0xFF; // lengthSizeMinusOne = 357+ buf[off++] = 0xE1; // numOfSPS = 158+ buf[off++] = (sps.length >> 8) & 0xFF;59+ buf[off++] = sps.length & 0xFF;60+ buf.set(sps, off); off += sps.length;61+ buf[off++] = 1; // numOfPPS = 162+ buf[off++] = (pps.length >> 8) & 0xFF;63+ buf[off++] = pps.length & 0xFF;64+ buf.set(pps, off);65+ return buf.buffer;66+}67+68+export function avcCodecStr(sps) {69+ const p = sps[1].toString(16).padStart(2, '0');70+ const c = sps[2].toString(16).padStart(2, '0');71+ const l = sps[3].toString(16).padStart(2, '0');72+ return `avc1.${p}${c}${l}`;73+}74+75+// ─── H.265 / HEVC ──────────────────────────────────────────────────────────76+77+export function hevcNalType(nal) { return (nal[0] >> 1) & 0x3f; }78+79+export function buildHvcDescription(vps, sps, pps) {80+ // hvcC record format (ISO 14496-15 8.3.3.1.2)81+ const buf = new Uint8Array(23 + 15 + vps.length + sps.length + pps.length);82+ let off = 0;83+ buf[off++] = 1; // configurationVersion84+ buf[off++] = sps[1] & 0x1f; // profile_space=0, tier=0, profile_idc from SPS85+ buf[off++] = sps[2]; // general_profile_compatibility_flags[0..7]86+ buf[off++] = sps[3]; // [8..15]87+ buf[off++] = sps[4]; // [16..23]88+ buf[off++] = sps[5]; // [24..31]89+ for (let i = 0; i < 6; i++) buf[off++] = sps[6 + i] || 0; // constraint flags90+ buf[off++] = sps[12]; // general_level_idc91+ buf[off++] = 0xF0; buf[off++] = 0x00; // min_spatial_segmentation_idc92+ buf[off++] = 0xFC; // parallelismType93+ buf[off++] = 0xFC | ((sps.length > 2 ? 0 : 0)); // chromaFormat94+ buf[off++] = 0xF8; // bitDepthLumaMinus895+ buf[off++] = 0xF8; // bitDepthChromaMinus896+ buf[off++] = 0x00; buf[off++] = 0x00; // avgFrameRate97+ buf[off++] = 0x0F; // lengthSizeMinusOne=398+ buf[off++] = 3; // numOfArrays: VPS, SPS, PPS99+100+ const writeNal = (nalType, nal) => {101+ buf[off++] = 0x80 | nalType; // array_completeness=1102+ buf[off++] = 0;103+ buf[off++] = 1; // numNalus = 1104+ buf[off++] = (nal.length >> 8) & 0xFF;105+ buf[off++] = nal.length & 0xFF;106+ buf.set(nal, off);107+ off += nal.length;108+ };109+ writeNal(32, vps);110+ writeNal(33, sps);111+ writeNal(34, pps);112+113+ return buf.buffer;114+}115+116+export function hevcCodecStr(sps) {117+ const profileIdc = (sps[1] & 0x1f);118+ const levelIdc = sps.length > 13 ? sps[13] : 0;119+ return `hvc1.${profileIdc.toString(16).padStart(2, '0')}.4.L${levelIdc}.B0`;120+}121+122+// ─── AV1 ───────────────────────────────────────────────────────────────────123+124+export function parseAv1Obus(data) {125+ // AV1 low-overhead bitstream: each OBU is length-prefixed126+ const obus = [];127+ let i = 0;128+ while (i < data.length) {129+ if (i + 1 >= data.length) break;130+ const header = data[i];131+ const obuType = (header >> 3) & 0x0f;132+ const hasExtension = (header & 0x04) !== 0;133+ const hasSize = (header & 0x02) !== 0;134+ let off = i + 1 + (hasExtension ? 1 : 0);135+ if (!hasSize) {136+ obus.push({ type: obuType, data: data.subarray(i, data.length) });137+ break;138+ }139+ let size = 0, shift = 0, bytesConsumed = 0;140+ for (let j = 0; j < 4 && off + j < data.length; j++) {141+ const b = data[off + j];142+ size |= (b & 0x7f) << shift;143+ shift += 7;144+ bytesConsumed++;145+ if ((b & 0x80) === 0) break;146+ }147+ off += bytesConsumed;148+ obus.push({ type: obuType, data: data.subarray(i, off + size) });149+ i = off + size;150+ }151+ return obus;152+}153+154+export function av1CodecStr(sequenceHeader) {155+ const obuData = sequenceHeader;156+ let off = (obuData[0] & 0x04) !== 0 ? 2 : 1;157+ while (off < obuData.length && (obuData[off] & 0x80) !== 0) off++;158+ off++;159+ const b0 = obuData[off] || 0;160+ const profile = (b0 >> 5) & 0x07;161+ const reduced = (b0 >> 3) & 0x01;162+ const levelIdx = reduced ? (b0 >> 2) & 0x1f : 0;163+ return `av01.${profile}.${levelIdx.toString().padStart(2, '0')}M.08`;164+}165+166+export function buildAv1Description(seqHeaderObu) {167+ return seqHeaderObu.buffer.slice(seqHeaderObu.byteOffset, seqHeaderObu.byteOffset + seqHeaderObu.byteLength);168+}
M src/index.html +3 -1554
@@ -7,451 +7,7 @@
77 <link rel="preconnect" href="https://fonts.googleapis.com" />88 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />99 <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inconsolata:wght@400;500;600&display=swap" rel="stylesheet" />10- <style>11- :root {12- --background: #1a1a1a;13- --foreground: #c4c4c4;14- --muted: #2a2a2a;15- --muted-fg: #808080;16- --accent: #2a2a2a;17- --border: rgba(255,255,255,0.1);18- --border-strong: rgba(255,255,255,0.15);19- --destructive: #ff6568;20- --green: #4ade80;21- --yellow: #fbbf24;22- --mono: "Inconsolata", "SF Mono", ui-monospace, monospace;23- --sans: "IBM Plex Sans", -apple-system, system-ui, sans-serif;24- }25- * { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; }26- input, textarea { -webkit-user-select: text; user-select: text; }27- .info-value,28- .overlay-status,29- .host-info .value,30- .stats,31- .config-row-value,32- .node-id { -webkit-user-select: text; user-select: text; }33- html, body { height: 100%; }34- body {35- background: var(--background);36- color: var(--foreground);37- font-family: var(--sans);38- font-size: 13px;39- display: flex;40- flex-direction: column;41- overflow: hidden;42- -webkit-font-smoothing: antialiased;43- }44-45- /* ─── Top bar ─────────────────────────────────────────────────────── */46- .topbar {47- display: flex;48- align-items: center;49- gap: 12px;50- padding: 8px 16px;51- border-bottom: 1px solid var(--border);52- flex-shrink: 0;53- }54- .brand {55- font-family: var(--mono);56- font-size: 13px;57- font-weight: 500;58- color: var(--foreground);59- }60- .sep { color: var(--muted-fg); }61- .status-line {62- font-family: var(--mono);63- font-size: 12px;64- color: var(--muted-fg);65- display: flex;66- align-items: center;67- gap: 6px;68- }69- .status-line .dot {70- width: 6px; height: 6px; border-radius: 50%;71- background: var(--muted-fg);72- }73- .status-line .dot.ok { background: var(--green); }74- .status-line .dot.err { background: var(--destructive); }75- .status-line .dot.pending { background: var(--yellow); }76- .status-line.ok { color: var(--green); }77- .status-line.err { color: var(--destructive); }78- .status-line.pending { color: var(--yellow); }79- .topbar-right {80- margin-left: auto;81- display: flex;82- align-items: center;83- gap: 12px;84- }85- .node-id {86- font-family: var(--mono);87- font-size: 11px;88- color: var(--muted-fg);89- max-width: 180px;90- overflow: hidden;91- text-overflow: ellipsis;92- white-space: nowrap;93- }94- .link-btn {95- font-family: var(--mono);96- font-size: 12px;97- color: var(--muted-fg);98- cursor: pointer;99- text-decoration: underline;100- text-decoration-color: transparent;101- background: none;102- border: none;103- padding: 0;104- transition: color 0.1s, text-decoration-color 0.1s;105- }106- .link-btn:hover {107- color: var(--foreground);108- text-decoration-color: currentColor;109- }110-111- /* ─── Main area ───────────────────────────────────────────────────── */112- .main {113- flex: 1;114- display: flex;115- align-items: center;116- justify-content: center;117- position: relative;118- min-height: 0;119- background: #0d0d0d;120- }121- #frame-canvas {122- max-width: 100%;123- max-height: 100%;124- object-fit: contain;125- display: none;126- }127- .placeholder {128- text-align: center;129- color: var(--muted-fg);130- font-family: var(--mono);131- font-size: 13px;132- padding: 40px;133- }134- .placeholder.hidden { display: none; }135-136- /* Hosting info panel */137- .host-info {138- text-align: center;139- color: var(--muted-fg);140- font-family: var(--mono);141- padding: 40px;142- }143- .host-info.hidden { display: none; }144- .host-info .label { font-size: 11px; color: var(--muted-fg); margin-bottom: 4px; }145- .host-info .value { font-size: 16px; color: var(--foreground); margin-bottom: 20px; }146- .host-info .conn-count { font-size: 24px; color: var(--green); }147-148- /* Stats overlay */149- .stats {150- position: absolute;151- top: 8px; right: 8px;152- font-family: var(--mono);153- font-size: 11px;154- color: var(--green);155- background: rgba(0,0,0,0.5);156- padding: 2px 8px;157- display: none;158- pointer-events: none;159- }160- .stats.visible { display: block; }161-162- /* Control overlay */163- .control-bar {164- position: absolute;165- bottom: 8px; left: 50%;166- transform: translateX(-50%);167- display: none;168- gap: 4px;169- background: rgba(0,0,0,0.5);170- padding: 4px;171- }172- .control-bar.visible { display: flex; }173- .control-toggle {174- font-family: var(--mono);175- font-size: 12px;176- color: var(--muted-fg);177- cursor: pointer;178- padding: 4px 10px;179- text-decoration: underline;180- text-decoration-color: transparent;181- transition: color 0.1s, text-decoration-color 0.1s;182- }183- .control-toggle:hover {184- color: var(--foreground);185- text-decoration-color: currentColor;186- }187- .control-toggle.active {188- color: var(--green);189- text-decoration-color: currentColor;190- }191-192- /* ─── Bottom bar ──────────────────────────────────────────────────── */193- .bottombar {194- display: flex;195- align-items: center;196- gap: 8px;197- padding: 6px 16px;198- border-top: 1px solid var(--border);199- background: var(--background);200- flex-shrink: 0;201- font-family: var(--mono);202- font-size: 12px;203- }204- .bottombar .prompt { color: var(--foreground); }205- .bottombar .sep { color: var(--muted-fg); }206- .bottombar .action {207- color: var(--muted-fg);208- cursor: pointer;209- text-decoration: underline;210- text-decoration-color: transparent;211- transition: color 0.1s, text-decoration-color 0.1s;212- }213- .bottombar .action:hover {214- color: var(--foreground);215- text-decoration-color: currentColor;216- }217- .bottombar .action.primary { color: var(--foreground); }218- .bottombar .action.danger { color: var(--destructive); opacity: 0.7; }219- .bottombar .action.danger:hover { opacity: 1; }220- .bottombar .hidden { display: none; }221- .bottombar .spacer { flex: 1; }222- .pin-group {223- display: flex;224- align-items: center;225- gap: 6px;226- }227- .pin-input {228- width: 80px;229- background: transparent;230- border: none;231- border-bottom: 1px solid var(--border-strong);232- color: var(--foreground);233- padding: 2px 4px;234- font-family: var(--mono);235- font-size: 12px;236- text-align: center;237- }238- .pin-input:focus {239- outline: none;240- border-bottom-color: var(--foreground);241- }242- .pin-input.hidden { display: none; }243- .pin-label {244- font-size: 11px;245- color: var(--muted-fg);246- }247- .pin-label.hidden { display: none; }248-249- /* ─── Intro / overlay screen ──────────────────────────────────────── */250- .overlay-screen {251- position: fixed;252- inset: 0;253- z-index: 100;254- display: flex;255- align-items: center;256- justify-content: center;257- background: var(--background);258- }259- .overlay-screen.hidden { display: none; }260- .overlay-content {261- text-align: center;262- width: 320px;263- padding: 32px;264- border: 1px solid var(--border-strong);265- background: var(--muted);266- }267- .overlay-content h1 {268- font-family: var(--mono);269- font-size: 16px;270- font-weight: 500;271- color: var(--foreground);272- margin-bottom: 12px;273- }274- #intro .overlay-content h1 {275- font-size: 26px;276- letter-spacing: -0.5px;277- margin-bottom: 8px;278- }279- .overlay-content p {280- font-size: 12px;281- color: var(--muted-fg);282- line-height: 1.6;283- margin-bottom: 24px;284- font-family: var(--mono);285- letter-spacing: 0.02em;286- }287- .shortcut-key {288- color: var(--foreground);289- }290- .overlay-form {291- display: flex;292- gap: 8px;293- justify-content: center;294- align-items: center;295- }296- .overlay-input {297- width: 120px;298- text-align: center;299- background: transparent;300- border: none;301- border-bottom: 1px solid var(--border-strong);302- color: var(--foreground);303- padding: 6px 8px;304- font-family: var(--mono);305- font-size: 14px;306- }307- .overlay-input:focus {308- outline: none;309- border-bottom-color: var(--foreground);310- }311- .overlay-btn {312- font-family: var(--mono);313- font-size: 13px;314- color: var(--muted-fg);315- cursor: pointer;316- background: none;317- border: none;318- padding: 6px 12px;319- text-decoration: underline;320- text-decoration-color: transparent;321- transition: color 0.1s, text-decoration-color 0.1s;322- }323- .overlay-btn:hover {324- color: var(--foreground);325- text-decoration-color: currentColor;326- }327- .overlay-btn:disabled {328- opacity: 0.4;329- cursor: not-allowed;330- }331- .overlay-status {332- font-family: var(--mono);333- font-size: 12px;334- color: var(--muted-fg);335- margin-top: 16px;336- min-height: 16px;337- }338- .overlay-status.pending { color: var(--yellow); }339- .overlay-status.err { color: var(--destructive); }340- .overlay-status.ok { color: var(--green); }341-342- /* ─── Side panel ──────────────────────────────────────────────────── */343- .panel-overlay {344- position: fixed;345- inset: 0;346- background: rgba(0,0,0,0.4);347- z-index: 90;348- display: none;349- }350- .panel-overlay.visible { display: block; }351- .panel {352- position: fixed;353- top: 0; right: 0; bottom: 0;354- width: 300px;355- background: var(--background);356- border-left: 1px solid var(--border);357- transform: translateX(100%);358- transition: transform 0.15s;359- z-index: 91;360- display: flex;361- flex-direction: column;362- }363- .panel.visible { transform: translateX(0); }364- .panel-header {365- display: flex;366- align-items: center;367- justify-content: space-between;368- padding: 10px 16px;369- border-bottom: 1px solid var(--border);370- }371- .panel-header h2 {372- font-family: var(--mono);373- font-size: 13px;374- font-weight: 500;375- }376- .panel-body {377- flex: 1;378- overflow-y: auto;379- padding: 12px 16px;380- }381- .panel-section {382- margin-bottom: 24px;383- }384- .panel-section h3 {385- font-family: var(--mono);386- font-size: 11px;387- font-weight: 500;388- color: var(--muted-fg);389- margin-bottom: 8px;390- }391- .info-row {392- display: flex;393- justify-content: space-between;394- align-items: center;395- padding: 4px 0;396- border-bottom: 1px solid var(--border);397- }398- .info-row:last-child { border-bottom: none; }399- .info-label {400- font-size: 12px;401- color: var(--muted-fg);402- }403- .info-value {404- font-family: var(--mono);405- font-size: 11px;406- color: var(--foreground);407- }408- .config-select {409- font-family: var(--mono);410- font-size: 11px;411- color: var(--foreground);412- background: var(--background);413- border: 1px solid var(--border);414- padding: 2px 4px;415- cursor: pointer;416- }417- .config-input {418- font-family: var(--mono);419- font-size: 11px;420- color: var(--foreground);421- background: var(--background);422- border: 1px solid var(--border);423- padding: 2px 4px;424- width: 80px;425- }426- .config-input:focus {427- outline: none;428- border-color: var(--foreground);429- }430-431- /* ─── Config overlay (pre-host) ───────────────────────────────────── */432- .config-rows { text-align: left; margin-bottom: 8px; }433- .config-row {434- display: flex;435- justify-content: space-between;436- align-items: center;437- padding: 6px 0;438- border-bottom: 1px solid var(--border);439- }440- .config-row:last-child { border-bottom: none; }441- .config-row-label {442- font-family: var(--mono);443- font-size: 12px;444- color: var(--muted-fg);445- }446- .config-row-value {447- font-family: var(--mono);448- font-size: 11px;449- color: var(--foreground);450- max-width: 170px;451- text-align: right;452- word-break: break-all;453- }454- </style>10+ <link rel="stylesheet" href="./style.css" />45511 </head>45612 <body>45713 <!-- Tap key overlay -->
@@ -580,7 +136,7 @@
580136 </div>581137 </div>582138 583- <!-- Bottom bar -->139+ <!-- Bottom bar -->584140 <div class="bottombar">585141 <span class="action primary" id="action-main" onclick="mainAction()">host</span>586142 <span class="sep hidden" id="sep-disconnect-main">·</span>
@@ -673,1113 +229,6 @@
673229 </div>674230 </div>675231 676- <script>677- let controlMode = false;678- let connected = false;679- let hosting = false;680- let registered = false;681- let frameWidth = 0;682- let frameHeight = 0;683-684- const invoke = (...args) => window.__TAURI__.core.invoke(...args);685- const listen = (...args) => window.__TAURI__.event.listen(...args);686-687- function updatePanelVisibility() {688- const encSection = document.getElementById('encoder-section');689- const hostPerfSection = document.getElementById('host-perf-section');690- const streamSection = document.getElementById('stream-section');691- // Show encoder config + host perf when hosting (or idle)692- // Show stream stats when connected as client693- encSection.style.display = connected ? 'none' : '';694- hostPerfSection.style.display = connected ? 'none' : '';695- streamSection.style.display = connected ? '' : 'none';696- }697-698- // ─── Status ──────────────────────────────────────────────────────────699- function setStatus(state, text) {700- const line = document.getElementById('status-line');701- const dot = document.getElementById('status-dot');702- const txt = document.getElementById('status-text');703- line.className = 'status-line ' + state;704- dot.className = 'dot ' + state;705- txt.textContent = text;706- }707-708- function showIntro() {709- document.getElementById('intro').classList.remove('hidden');710- // Focus the visible PIN input711- setTimeout(() => {712- const unregPin = document.getElementById('intro-pin-unreg');713- const regPin = document.getElementById('intro-pin-reg');714- const visible = unregPin.parentElement.parentElement.style.display !== 'none' ? unregPin : regPin;715- if (visible) visible.focus();716- }, 50);717- }718- function hideIntro() { document.getElementById('intro').classList.add('hidden'); }719- function showTap() {720- document.getElementById('tap-overlay-title').textContent = 'tap key';721- document.getElementById('tap-overlay-desc').textContent = 'Touch the sensor on your key when it blinks.';722- document.getElementById('tap-status').textContent = 'waiting...';723- document.getElementById('tap-overlay').classList.remove('hidden');724- }725- function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }726-727- // ─── Registration ───────────────────────────────────────────────────728- async function checkRegistration() {729- try {730- const reg = await invoke('host_registration_status');731- registered = reg.registered;732- const unregDiv = document.getElementById('intro-unregistered');733- const regDiv = document.getElementById('intro-registered');734- if (reg.registered) {735- unregDiv.style.display = 'none';736- regDiv.style.display = 'block';737- document.getElementById('node-id-text').textContent =738- reg.node_id.substring(0, 16) + '...';739- document.getElementById('reg-status').textContent = 'registered';740- document.getElementById('reg-node-id').textContent =741- reg.node_id.substring(0, 24) + '...';742- // Bottom bar: show host + unregister743- document.getElementById('action-main').textContent = 'host';744- document.getElementById('action-unreg').style.display = '';745- } else {746- unregDiv.style.display = 'block';747- regDiv.style.display = 'none';748- document.getElementById('reg-status').textContent = 'client-only';749- // Bottom bar: show register, hide unregister750- document.getElementById('action-main').textContent = 'register';751- document.getElementById('action-unreg').style.display = 'none';752- }753-754- // Check daemon mode — auto-host if --daemon flag755- const daemon = await invoke('is_daemon_mode');756- if (daemon && reg.registered) {757- hideIntro();758- await startHost();759- } else {760- showIntro();761- }762- } catch (e) {763- console.error('registration check failed:', e);764- showIntro();765- }766- }767-768- // ─── Intro actions ──────────────────────────────────────────────────769- async function introRegister() {770- const pin = document.getElementById('intro-pin-unreg').value.trim();771- const status = document.getElementById('intro-status-unreg');772- if (!pin) {773- status.className = 'overlay-status err';774- status.textContent = 'enter pin';775- return;776- }777- hideIntro();778- showTap();779- try {780- const result = await invoke('key_register_host', { pin });781- if (result.registered) {782- hideTap();783- registered = true;784- document.getElementById('node-id-text').textContent =785- result.node_id.substring(0, 16) + '...';786- document.getElementById('action-main').textContent = 'host';787- document.getElementById('action-unreg').style.display = '';788- // Set up registered intro state (so "back" from config works)789- document.getElementById('intro-unregistered').style.display = 'none';790- document.getElementById('intro-registered').style.display = 'block';791- document.getElementById('reg-status').textContent = 'registered';792- document.getElementById('reg-node-id').textContent =793- result.node_id.substring(0, 24) + '...';794- // Show config screen before hosting795- showConfigScreen();796- } else {797- hideTap();798- showIntro();799- status.className = 'overlay-status err';800- status.textContent = 'failed';801- }802- } catch (e) {803- hideTap();804- showIntro();805- status.className = 'overlay-status err';806- status.textContent = 'error: ' + e;807- }808- }809-810- async function introConnectUnreg() {811- const pin = document.getElementById('intro-pin-unreg').value.trim();812- const status = document.getElementById('intro-status-unreg');813- if (!pin) {814- status.className = 'overlay-status err';815- status.textContent = 'enter pin';816- return;817- }818- hideIntro();819- showTap();820- document.getElementById('pin-input').value = pin;821- await connectHost();822- hideTap();823- if (!connected) {824- showIntro();825- status.className = 'overlay-status err';826- status.textContent = 'connection failed';827- }828- }829-830- async function introConnectReg() {831- const pin = document.getElementById('intro-pin-reg').value.trim();832- const status = document.getElementById('intro-status-reg');833- if (!pin) {834- status.className = 'overlay-status err';835- status.textContent = 'enter pin';836- return;837- }838- hideIntro();839- showTap();840- document.getElementById('pin-input').value = pin;841- await connectHost();842- hideTap();843- if (!connected) {844- showIntro();845- status.className = 'overlay-status err';846- status.textContent = 'connection failed';847- }848- }849-850- async function introHost() {851- hideIntro();852- showConfigScreen();853- }854-855- // ─── Config screen (pre-host) ──────────────────────────────────────856- async function showConfigScreen() {857- // Load current encoder config into the config overlay858- try {859- const config = await invoke('get_encoder_config');860- document.getElementById('cfg-codec').value = config.codec;861- document.getElementById('cfg-backend').value = config.backend;862- document.getElementById('cfg-bitrate').value = config.bitrate;863- document.getElementById('cfg-framerate').value = config.framerate;864- document.getElementById('cfg-gop').value = config.gop;865- } catch (e) { console.error('config load failed:', e); }866- try {867- const available = await invoke('detect_available_encoders');868- document.getElementById('cfg-available').textContent = available.join(', ') || 'none';869- } catch (e) {870- document.getElementById('cfg-available').textContent = 'ffmpeg not found';871- }872- document.getElementById('config-status').textContent = '';873- document.getElementById('config-overlay').classList.remove('hidden');874- }875-876- function hideConfigScreen() {877- document.getElementById('config-overlay').classList.add('hidden');878- }879-880- function configBack() {881- hideConfigScreen();882- showIntro();883- }884-885- async function configHost() {886- const status = document.getElementById('config-status');887- status.className = 'overlay-status pending';888- status.textContent = 'saving...';889- try {890- const config = {891- codec: document.getElementById('cfg-codec').value,892- backend: document.getElementById('cfg-backend').value,893- bitrate: document.getElementById('cfg-bitrate').value,894- framerate: parseInt(document.getElementById('cfg-framerate').value) || 30,895- gop: parseInt(document.getElementById('cfg-gop').value) || 30,896- };897- await invoke('set_encoder_config', { config });898- hideConfigScreen();899- await startHost();900- } catch (e) {901- status.className = 'overlay-status err';902- status.textContent = 'error: ' + e;903- }904- }905-906- // ─── Host ───────────────────────────────────────────────────────────907- async function startHost() {908- setStatus('pending', 'starting...');909- try {910- const result = await invoke('iroh_host_start');911- if (result.running) {912- hosting = true;913- setStatus('ok', 'hosting');914- updatePanelVisibility();915- document.getElementById('node-id-text').textContent =916- result.node_id.substring(0, 16) + '...';917- // Show "stop hosting" disconnect button, hide main action918- document.getElementById('action-disconnect').textContent = 'stop hosting';919- document.getElementById('action-disconnect').classList.remove('hidden');920- document.getElementById('sep-disconnect-main').classList.remove('hidden');921- document.getElementById('action-main').style.display = 'none';922- document.getElementById('action-unreg').style.display = 'none';923- // Show host info panel, hide placeholder924- document.getElementById('placeholder').classList.add('hidden');925- document.getElementById('host-info').classList.remove('hidden');926- document.getElementById('host-node-id').textContent =927- result.node_id.substring(0, 24) + '...';928- // Hide pin + connect when hosting929- document.getElementById('pin-label').classList.add('hidden');930- document.getElementById('pin-input').classList.add('hidden');931- document.getElementById('action-connect').style.display = 'none';932- } else {933- setStatus('err', 'failed');934- }935- } catch (e) {936- setStatus('err', 'error');937- }938- }939-940- async function stopHost() {941- try {942- await invoke('iroh_host_stop');943- } catch (e) { console.error(e); }944- hosting = false;945- setStatus('', 'idle');946- updatePanelVisibility();947- document.getElementById('action-main').style.display = '';948- document.getElementById('action-main').textContent = registered ? 'host' : 'register';949- document.getElementById('action-unreg').style.display = registered ? '' : 'none';950- document.getElementById('action-disconnect').classList.add('hidden');951- document.getElementById('sep-disconnect-main').classList.add('hidden');952- document.getElementById('host-info').classList.add('hidden');953- document.getElementById('placeholder').classList.remove('hidden');954- document.getElementById('pin-label').classList.remove('hidden');955- document.getElementById('pin-input').classList.remove('hidden');956- document.getElementById('action-connect').style.display = '';957- }958-959- // ─── Main action (host/stop/register) ───────────────────────────────960- async function mainAction() {961- if (hosting) {962- // Hosting state uses disconnect button, not main action963- return;964- } else if (registered) {965- showConfigScreen();966- } else {967- // Not registered — show intro for registration968- showIntro();969- }970- }971-972- // ─── Client ─────────────────────────────────────────────────────────973- async function connectHost() {974- const pin = document.getElementById('pin-input').value.trim();975- if (!pin) {976- setStatus('err', 'enter pin');977- return;978- }979- setStatus('pending', 'connecting...');980- try {981- const result = await invoke('iroh_client_connect', { pin });982- if (result.connected) {983- connected = true;984- setStatus('ok', 'connected');985- droppedFrames = 0;986- receivedFrames = 0;987- updatePanelVisibility();988- document.getElementById('node-id-text').textContent =989- result.host_node_id.substring(0, 16) + '...';990- document.getElementById('frame-canvas').style.display = 'block';991- document.getElementById('placeholder').classList.add('hidden');992- document.getElementById('control-bar').classList.add('visible');993- // Show disconnect, hide connect + main action994- document.getElementById('action-connect').style.display = 'none';995- document.getElementById('action-disconnect').textContent = 'disconnect';996- document.getElementById('action-disconnect').classList.remove('hidden');997- document.getElementById('sep-disconnect').classList.remove('hidden');998- document.getElementById('action-main').style.display = 'none';999- } else {1000- setStatus('err', 'failed');1001- }1002- } catch (e) {1003- setStatus('err', 'error');1004- }1005- }1006-1007- async function disconnect() {1008- if (hosting) {1009- await stopHost();1010- return;1011- }1012- try {1013- await invoke('iroh_client_disconnect');1014- } catch (e) { console.error(e); }1015- connected = false;1016- controlMode = false;1017- updateControlUI();1018- updatePanelVisibility();1019- setStatus('', 'idle');1020- document.getElementById('frame-canvas').style.display = 'none';1021- document.getElementById('placeholder').classList.remove('hidden');1022- document.getElementById('placeholder').textContent = 'no stream';1023- document.getElementById('stats').classList.remove('visible');1024- document.getElementById('control-bar').classList.remove('visible');1025- document.getElementById('action-connect').style.display = '';1026- document.getElementById('action-disconnect').classList.add('hidden');1027- document.getElementById('sep-disconnect').classList.add('hidden');1028- document.getElementById('action-main').style.display = '';1029- // Clear intro status fields1030- document.getElementById('intro-status-unreg').textContent = '';1031- document.getElementById('intro-status-reg').textContent = '';1032- }1033-1034- // ─── Unregister ─────────────────────────────────────────────────────1035- function unregister() {1036- // Show inline yes/no instead of confirm dialog1037- document.getElementById('action-unreg').style.display = 'none';1038- document.getElementById('action-unreg-confirm').classList.remove('hidden');1039- document.getElementById('action-unreg-cancel').classList.remove('hidden');1040- }1041-1042- function unregisterCancel() {1043- document.getElementById('action-unreg-confirm').classList.add('hidden');1044- document.getElementById('action-unreg-cancel').classList.add('hidden');1045- document.getElementById('action-unreg').style.display = '';1046- }1047-1048- async function unregisterConfirm() {1049- document.getElementById('action-unreg-confirm').classList.add('hidden');1050- document.getElementById('action-unreg-cancel').classList.add('hidden');1051- try {1052- await invoke('host_unregister');1053- registered = false;1054- setStatus('', 'idle');1055- document.getElementById('node-id-text').textContent = '';1056- document.getElementById('reg-status').textContent = 'client-only';1057- document.getElementById('reg-node-id').textContent = '—';1058- document.getElementById('action-main').textContent = 'register';1059- document.getElementById('action-unreg').style.display = 'none';1060- showIntro();1061- } catch (e) {1062- alert('unregister failed: ' + e);1063- document.getElementById('action-unreg').style.display = '';1064- }1065- }1066-1067- // ─── FIDO scan ──────────────────────────────────────────────────────1068- async function scanFido() {1069- try {1070- const info = await invoke('fido_device_info');1071- const el = document.getElementById('fido-status');1072- if (!info.found) {1073- el.textContent = 'no device';1074- el.style.color = 'var(--muted-fg)';1075- return;1076- }1077- // Device found — populate basic info regardless of whether CTAP query succeeded1078- el.textContent = info.error ? `found (${info.error})` : 'connected';1079- el.style.color = info.error ? 'var(--yellow)' : 'var(--green)';1080- document.getElementById('fido-product').textContent = info.product || '—';1081- document.getElementById('fido-vidpid').textContent =1082- `${info.vid.toString(16).padStart(4,'0')}:${info.pid.toString(16).padStart(4,'0')}`;1083- document.getElementById('fido-versions').textContent = info.versions.join(', ') || '—';1084- document.getElementById('fido-extensions').textContent = info.extensions.join(', ') || '—';1085- document.getElementById('fido-pin').textContent = info.pin_retries ?? '—';1086- } catch (e) {1087- console.error('scanFido failed:', e);1088- document.getElementById('fido-status').textContent = 'error';1089- }1090- }1091-1092- // ─── Panel ──────────────────────────────────────────────────────────1093- function togglePanel() {1094- document.getElementById('panel').classList.toggle('visible');1095- document.getElementById('panel-overlay').classList.toggle('visible');1096- }1097-1098- // ─── Host connection events ─────────────────────────────────────────1099- listen('host-connections', (event) => {1100- document.getElementById('host-conn-count').textContent = event.payload;1101- });1102-1103- // ─── Encode performance sparkline ───────────────────────────────────1104- const encodeHistory = [];1105- const MAX_HISTORY = 60;1106- const sparkCanvas = document.getElementById('encode-spark');1107- const sparkCtx = sparkCanvas.getContext('2d');1108-1109- function drawSparkline() {1110- const w = sparkCanvas.width;1111- const h = sparkCanvas.height;1112- sparkCtx.clearRect(0, 0, w, h);1113- if (encodeHistory.length < 2) return;1114-1115- const max = Math.max(...encodeHistory, 1);1116- const step = w / (MAX_HISTORY - 1);1117-1118- // Fill area1119- sparkCtx.beginPath();1120- sparkCtx.moveTo(0, h);1121- encodeHistory.forEach((ms, i) => {1122- const x = i * step;1123- const y = h - (ms / max) * (h - 4) - 2;1124- sparkCtx.lineTo(x, y);1125- });1126- sparkCtx.lineTo((encodeHistory.length - 1) * step, h);1127- sparkCtx.closePath();1128- sparkCtx.fillStyle = 'rgba(74, 222, 128, 0.15)';1129- sparkCtx.fill();1130-1131- // Line1132- sparkCtx.beginPath();1133- encodeHistory.forEach((ms, i) => {1134- const x = i * step;1135- const y = h - (ms / max) * (h - 4) - 2;1136- if (i === 0) sparkCtx.moveTo(x, y);1137- else sparkCtx.lineTo(x, y);1138- });1139- sparkCtx.strokeStyle = '#4ade80';1140- sparkCtx.lineWidth = 1;1141- sparkCtx.stroke();1142- }1143-1144- listen('host-encode-stats', (event) => {1145- const { encode_ms, capture_ms, size_bytes, fps, encoder } = event.payload;1146- document.getElementById('perf-encode').textContent = encode_ms.toFixed(1) + ' ms';1147- document.getElementById('perf-capture').textContent = encoder || (capture_ms > 0 ? capture_ms.toFixed(1) + ' ms' : 'ffmpeg');1148- document.getElementById('perf-size').textContent = (size_bytes / 1024).toFixed(1) + ' KB';1149- document.getElementById('perf-fps').textContent = fps.toFixed(1);1150-1151- encodeHistory.push(encode_ms);1152- if (encodeHistory.length > MAX_HISTORY) encodeHistory.shift();1153- drawSparkline();1154- });1155-1156- // ─── WebCodecs detection ────────────────────────────────────────────1157- let hasWebCodecs = ('VideoDecoder' in window);1158- let videoDecoder = null;1159- let decoderConfigured = false;1160-1161- (async function detectWebCodecs() {1162- if (hasWebCodecs) {1163- await invoke('set_webcodecs_available', { available: true });1164- } else {1165- await invoke('set_webcodecs_available', { available: false });1166- }1167- })();1168-1169- // ─── Encoder config ────────────────────────────────────────────────1170- (async function loadEncoderConfig() {1171- try {1172- const config = await invoke('get_encoder_config');1173- document.getElementById('enc-codec').value = config.codec;1174- document.getElementById('enc-backend').value = config.backend;1175- document.getElementById('enc-bitrate').value = config.bitrate;1176- document.getElementById('enc-framerate').value = config.framerate;1177- document.getElementById('enc-gop').value = config.gop;1178- } catch (e) { console.error('encoder config load failed:', e); }1179-1180- try {1181- const available = await invoke('detect_available_encoders');1182- document.getElementById('enc-available').textContent = available.join(', ') || 'none';1183- } catch (e) {1184- document.getElementById('enc-available').textContent = 'ffmpeg not found';1185- }1186- })();1187-1188- async function saveEncoderConfig() {1189- const config = {1190- codec: document.getElementById('enc-codec').value,1191- backend: document.getElementById('enc-backend').value,1192- bitrate: document.getElementById('enc-bitrate').value,1193- framerate: parseInt(document.getElementById('enc-framerate').value) || 30,1194- gop: parseInt(document.getElementById('enc-gop').value) || 30,1195- };1196- await invoke('set_encoder_config', { config });1197- }1198-1199- // ─── Annex B → AVCC/hvcC/av1C conversion ────────────────────────────1200- // ffmpeg outputs Annex B (00 00 00 01 start codes) for H.264/H.265.1201- // WebCodecs expects length-prefixed NALs + description record.1202- // AV1 uses OBU format directly.1203-1204- let activeCodec = 'h264'; // updated from frame payload1205- let droppedFrames = 0;1206- let receivedFrames = 0;1207-1208- function parseAnnexBNals(data) {1209- const nals = [];1210- let i = 0;1211- while (i < data.length) {1212- let scl = 0;1213- if (i + 3 < data.length && data[i] === 0 && data[i+1] === 0 && data[i+2] === 0 && data[i+3] === 1) {1214- scl = 4;1215- } else if (i + 2 < data.length && data[i] === 0 && data[i+1] === 0 && data[i+2] === 1) {1216- scl = 3;1217- } else {1218- i++; continue;1219- }1220- const nalStart = i + scl;1221- let j = nalStart + 1;1222- while (j < data.length) {1223- if (j + 3 < data.length && data[j] === 0 && data[j+1] === 0 && data[j+2] === 0 && data[j+3] === 1) break;1224- if (j + 2 < data.length && data[j] === 0 && data[j+1] === 0 && data[j+2] === 1) break;1225- j++;1226- }1227- nals.push(data.subarray(nalStart, j));1228- i = j;1229- }1230- return nals;1231- }1232-1233- function nalsToLengthPrefixed(nals) {1234- let total = 0;1235- for (const nal of nals) total += 4 + nal.length;1236- const result = new Uint8Array(total);1237- let off = 0;1238- for (const nal of nals) {1239- const dv = new DataView(result.buffer, off, 4);1240- dv.setUint32(0, nal.length, false);1241- result.set(nal, off + 4);1242- off += 4 + nal.length;1243- }1244- return result;1245- }1246-1247- // ─── H.264 ─────────────────────────────────────────────────────────1248-1249- function buildAvcDescription(sps, pps) {1250- const buf = new Uint8Array(11 + sps.length + pps.length);1251- let off = 0;1252- buf[off++] = 1; // configurationVersion1253- buf[off++] = sps[1]; // profile_idc1254- buf[off++] = sps[2]; // constraint_flags1255- buf[off++] = sps[3]; // level_idc1256- buf[off++] = 0xFF; // lengthSizeMinusOne = 31257- buf[off++] = 0xE1; // numOfSPS = 11258- buf[off++] = (sps.length >> 8) & 0xFF;1259- buf[off++] = sps.length & 0xFF;1260- buf.set(sps, off); off += sps.length;1261- buf[off++] = 1; // numOfPPS = 11262- buf[off++] = (pps.length >> 8) & 0xFF;1263- buf[off++] = pps.length & 0xFF;1264- buf.set(pps, off);1265- return buf.buffer;1266- }1267-1268- function avcCodecStr(sps) {1269- const p = sps[1].toString(16).padStart(2, '0');1270- const c = sps[2].toString(16).padStart(2, '0');1271- const l = sps[3].toString(16).padStart(2, '0');1272- return `avc1.${p}${c}${l}`;1273- }1274-1275- function h264NalType(nal) { return nal[0] & 0x1f; }1276-1277- // ─── H.265 / HEVC ──────────────────────────────────────────────────1278-1279- function buildHvcDescription(vps, sps, pps) {1280- // hvcC record format (ISO 14496-15 8.3.3.1.2)1281- // Header: 23 bytes. Per array: 1 (type) + 2 (count) + 2 (length) + nal data = 5 + nal1282- const buf = new Uint8Array(23 + 15 + vps.length + sps.length + pps.length);1283- let off = 0;1284- buf[off++] = 1; // configurationVersion1285- // profile space(2) + tier(1) + profile_idc(5)1286- buf[off++] = sps[1] & 0x1f; // profile_space=0, tier=0, profile_idc from SPS1287- buf[off++] = sps[2]; // general_profile_compatibility_flags[0..7]1288- buf[off++] = sps[3]; // [8..15]1289- buf[off++] = sps[4]; // [16..23]1290- buf[off++] = sps[5]; // [24..31]1291- // constraint flags (6 bytes)1292- for (let i = 0; i < 6; i++) buf[off++] = sps[6 + i] || 0;1293- buf[off++] = sps[12]; // general_level_idc1294- // min_spatial_segmentation_idc (12 bits, 0xF000 = reserved)1295- buf[off++] = 0xF0;1296- buf[off++] = 0x00;1297- // parallelismType (2 bits, 0xFC = reserved)1298- buf[off++] = 0xFC;1299- // chromaFormat (2 bits, 0xFC = reserved) — from SPS1300- buf[off++] = 0xFC | ((sps.length > 2 ? 0 : 0));1301- // bitDepthLumaMinus8 (3 bits, 0xF8 = reserved)1302- buf[off++] = 0xF8;1303- // bitDepthChromaMinus8 (3 bits, 0xF8 = reserved)1304- buf[off++] = 0xF8;1305- // avgFrameRate1306- buf[off++] = 0x00;1307- buf[off++] = 0x00;1308- // constantFrameRate(2) + numTemporalLayers(3) + temporalIdNested(1) + lengthSizeMinusOne(2)1309- buf[off++] = 0x0F; // lengthSizeMinusOne=31310- // numOfArrays1311- buf[off++] = 3; // VPS, SPS, PPS1312-1313- // Helper to write a NAL array entry1314- const writeNal = (nalType, nal) => {1315- buf[off++] = 0x80 | nalType; // array_completeness=11316- buf[off++] = 0; // numNalus high byte1317- buf[off++] = 1; // numNalus = 11318- buf[off++] = (nal.length >> 8) & 0xFF;1319- buf[off++] = nal.length & 0xFF;1320- buf.set(nal, off);1321- off += nal.length;1322- };1323-1324- writeNal(32, vps); // VPS1325- writeNal(33, sps); // SPS1326- writeNal(34, pps); // PPS1327-1328- return buf.buffer;1329- }1330-1331- function hevcCodecStr(sps) {1332- // General profile space (2 bits) = 0, tier (1 bit) = 0 (Main tier)1333- const profileIdc = (sps[1] & 0x1f);1334- // General level idc is at byte 12 in the SPS NAL (after the NAL header byte)1335- // For HEVC, NAL header is 2 bytes: byte[0] = (forbidden<<7 | nal_type<<1 | nuh_layer_id>>5)1336- // byte[1] = (nuh_layer_id<<3 | nuh_temporal_id_plus1)1337- // So SPS data starts at byte 21338- const levelIdc = sps.length > 13 ? sps[13] : 0;1339- // Simplified: use profile_idc from SPS, level from SPS byte 131340- // Format: hev1.X.Y.L where X=profile_space(0), Y=tier(0 for Main), L=level1341- const p = profileIdc.toString(16).padStart(2, '0');1342- const l = levelIdc.toString(16).padStart(2, '0');1343- // WebCodecs uses 'hev1' or 'hvc1' — 'hev1' is the parameter-set-in-band variant1344- // Use 'hvc1' since we provide description1345- return `hvc1.${p}.4.L${levelIdc}.B0`;1346- }1347-1348- function hevcNalType(nal) { return (nal[0] >> 1) & 0x3f; }1349-1350- // ─── AV1 ───────────────────────────────────────────────────────────1351-1352- // AV1 uses OBU format, no start codes. The sequence header OBU1353- // serves as the description. WebCodecs accepts the raw sequence header1354- // bytes as the description buffer.1355-1356- function parseAv1Obus(data) {1357- // AV1 low-overhead bitstream format: each OBU is length-prefixed1358- // With temporal delimiter (td=insert), each frame starts with a TD OBU1359- const obus = [];1360- let i = 0;1361- while (i < data.length) {1362- if (i + 1 >= data.length) break;1363- const header = data[i];1364- const obuType = (header >> 3) & 0x0f;1365- const hasExtension = (header & 0x04) !== 0;1366- const hasSize = (header & 0x02) !== 0;1367- let off = i + 1 + (hasExtension ? 1 : 0);1368- if (!hasSize) {1369- // Rest of data is this OBU1370- obus.push({ type: obuType, data: data.subarray(i, data.length) });1371- break;1372- }1373- // Read leb128 size1374- let size = 0;1375- let shift = 0;1376- let bytesConsumed = 0;1377- for (let j = 0; j < 4 && off + j < data.length; j++) {1378- const b = data[off + j];1379- size |= (b & 0x7f) << shift;1380- shift += 7;1381- bytesConsumed++;1382- if ((b & 0x80) === 0) break;1383- }1384- off += bytesConsumed;1385- obus.push({ type: obuType, data: data.subarray(i, off + size) });1386- i = off + size;1387- }1388- return obus;1389- }1390-1391- function av1CodecStr(sequenceHeader) {1392- // Parse the AV1 sequence header to build codec string1393- // seq_profile (3 bits), level (5 bits)1394- // For 'av01.P.LLT.MMM' format1395- const obuData = sequenceHeader;1396- // Skip OBU header byte(s) to get to sequence_header_obu data1397- let off = 1; // obu_header byte1398- // Check for extension flag1399- if ((obuData[0] & 0x04) !== 0) off = 2;1400- // Skip size leb1281401- while (off < obuData.length && (obuData[off] & 0x80) !== 0) off++;1402- off++;1403- // Now at sequence_header_obu data1404- // seq_profile (3 bits) + still_picture (1) + reduced_still_picture_header (1)1405- const b0 = obuData[off] || 0;1406- const profile = (b0 >> 5) & 0x07;1407- const reduced = (b0 >> 3) & 0x01;1408- let levelIdx;1409- if (reduced) {1410- levelIdx = (b0 >> 2) & 0x1f;1411- } else {1412- // More complex parsing needed; use defaults1413- levelIdx = 0;1414- }1415- return `av01.${profile}.${levelIdx.toString().padStart(2, '0')}M.08`;1416- }1417-1418- function buildAv1Description(seqHeaderObu) {1419- // WebCodecs accepts the raw sequence header OBU bytes1420- return seqHeaderObu.buffer.slice(seqHeaderObu.byteOffset, seqHeaderObu.byteOffset + seqHeaderObu.byteLength);1421- }1422-1423- // ─── WebCodecs init per codec ──────────────────────────────────────1424-1425- function initWebCodecsDecoder(width, height, desc, codecStr) {1426- if (videoDecoder) {1427- try { videoDecoder.close(); } catch(e) {}1428- }1429- console.log('WebCodecs configure:', codecStr, 'w:', width, 'h:', height, 'desc:', desc.byteLength, 'bytes');1430- videoDecoder = new VideoDecoder({1431- output: (frame) => {1432- ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);1433- frame.close();1434- },1435- error: (e) => {1436- console.error('VideoDecoder error:', e);1437- decoderConfigured = false;1438- try { videoDecoder.close(); } catch(_) {}1439- videoDecoder = null;1440- }1441- });1442- videoDecoder.configure({1443- codec: codecStr,1444- codedWidth: width,1445- codedHeight: height,1446- description: desc,1447- optimizeForRealtimeUse: true,1448- });1449- decoderConfigured = true;1450- }1451-1452- // ─── Frames — multi-codec WebCodecs or JPEG fallback ───────────────1453- const canvas = document.getElementById('frame-canvas');1454- const ctx = canvas.getContext('2d');1455-1456- function updateStreamStats() {1457- document.getElementById('stream-received').textContent = receivedFrames;1458- document.getElementById('stream-dropped').textContent = droppedFrames;1459- document.getElementById('stream-codec').textContent = activeCodec;1460- }1461-1462- listen('frame', (event) => {1463- const { width, height, data, codec } = event.payload;1464- frameWidth = width;1465- frameHeight = height;1466- if (canvas.width !== width) canvas.width = width;1467- if (canvas.height !== height) canvas.height = height;1468-1469- // Update codec from payload — reset decoder if codec changed1470- if (codec && codec !== activeCodec) {1471- activeCodec = codec;1472- decoderConfigured = false;1473- if (videoDecoder) {1474- try { videoDecoder.close(); } catch(_) {}1475- videoDecoder = null;1476- }1477- console.log('codec changed:', activeCodec);1478- }1479-1480- receivedFrames++;1481-1482- if (hasWebCodecs) {1483- const raw = Uint8Array.from(atob(data), c => c.charCodeAt(0));1484-1485- if (activeCodec === 'av1') {1486- // ── AV1 path ──1487- const obus = parseAv1Obus(raw);1488-1489- if (event.payload.keyframe) {1490- // Find sequence header OBU (type 12)1491- const seqHeader = obus.find(o => o.type === 12);1492- if (seqHeader && !decoderConfigured) {1493- const desc = buildAv1Description(seqHeader.data);1494- const codecStr = av1CodecStr(seqHeader.data);1495- initWebCodecsDecoder(width, height, desc, codecStr);1496- }1497- }1498-1499- if (!decoderConfigured || !videoDecoder) {1500- droppedFrames++;1501- updateStreamStats();1502- return;1503- }1504-1505- // Filter out TD and sequence header OBUs — already in description1506- const frameObus = obus.filter(o => o.type !== 2 && o.type !== 12);1507- if (frameObus.length > 0) {1508- // Concatenate remaining OBUs as the chunk data1509- let totalLen = 0;1510- for (const o of frameObus) totalLen += o.data.length;1511- const chunkData = new Uint8Array(totalLen);1512- let off = 0;1513- for (const o of frameObus) { chunkData.set(o.data, off); off += o.data.length; }1514-1515- const chunk = new EncodedVideoChunk({1516- type: event.payload.keyframe ? 'key' : 'delta',1517- timestamp: performance.now() * 1000,1518- data: chunkData,1519- });1520- try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('av1 decode failed:', e); }1521- }1522-1523- } else if (activeCodec === 'h265') {1524- // ── H.265 path ──1525- const nals = parseAnnexBNals(raw);1526-1527- if (event.payload.keyframe) {1528- let vps = null, sps = null, pps = null;1529- for (const nal of nals) {1530- const t = hevcNalType(nal);1531- if (t === 32) vps = nal;1532- else if (t === 33) sps = nal;1533- else if (t === 34) pps = nal;1534- }1535- if (vps && sps && pps && !decoderConfigured) {1536- const desc = buildHvcDescription(vps, sps, pps);1537- const codecStr = hevcCodecStr(sps);1538- initWebCodecsDecoder(width, height, desc, codecStr);1539- }1540- }1541-1542- if (!decoderConfigured || !videoDecoder) {1543- droppedFrames++;1544- updateStreamStats();1545- return;1546- }1547-1548- // Filter out VPS/SPS/PPS/AUD — already in description1549- const sliceNals = nals.filter(nal => {1550- const t = hevcNalType(nal);1551- return t !== 32 && t !== 33 && t !== 34 && t !== 35;1552- });1553- if (sliceNals.length > 0) {1554- const lpData = nalsToLengthPrefixed(sliceNals);1555- const chunk = new EncodedVideoChunk({1556- type: event.payload.keyframe ? 'key' : 'delta',1557- timestamp: performance.now() * 1000,1558- data: lpData,1559- });1560- try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('hevc decode failed:', e); }1561- }1562-1563- } else {1564- // ── H.264 path (default) ──1565- const nals = parseAnnexBNals(raw);1566-1567- if (event.payload.keyframe) {1568- let sps = null, pps = null;1569- for (const nal of nals) {1570- const t = h264NalType(nal);1571- if (t === 7) sps = nal;1572- else if (t === 8) pps = nal;1573- }1574- if (sps && pps && !decoderConfigured) {1575- const desc = buildAvcDescription(sps, pps);1576- const codecStr = avcCodecStr(sps);1577- initWebCodecsDecoder(width, height, desc, codecStr);1578- }1579- }1580-1581- if (!decoderConfigured || !videoDecoder) {1582- droppedFrames++;1583- updateStreamStats();1584- return;1585- }1586-1587- // Filter out SPS/PPS/AUD — already in description1588- const sliceNals = nals.filter(nal => {1589- const t = h264NalType(nal);1590- return t !== 7 && t !== 8 && t !== 9;1591- });1592- if (sliceNals.length > 0) {1593- const lpData = nalsToLengthPrefixed(sliceNals);1594- const chunk = new EncodedVideoChunk({1595- type: event.payload.keyframe ? 'key' : 'delta',1596- timestamp: performance.now() * 1000,1597- data: lpData,1598- });1599- try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('h264 decode failed:', e); }1600- }1601- }1602- } else {1603- // Path 2: JPEG from backend (openh264 decode → JPEG encode in Rust)1604- const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));1605- const blob = new Blob([bytes], { type: 'image/jpeg' });1606- const url = URL.createObjectURL(blob);1607- const img = new Image();1608- img.onload = () => {1609- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);1610- URL.revokeObjectURL(url);1611- };1612- img.src = url;1613- }1614-1615- updateStreamStats();1616- });1617-1618- listen('frame-stats', (event) => {1619- const stats = document.getElementById('stats');1620- const kf = event.payload.keyframe ? ' [kf]' : '';1621- stats.textContent = `${event.payload.fps.toFixed(1)} fps · ${event.payload.count}${kf}`;1622- stats.classList.add('visible');1623- });1624-1625- listen('frame-error', (event) => {1626- if (connected) {1627- disconnect();1628- }1629- });1630-1631- // ─── Input ──────────────────────────────────────────────────────────1632- function toggleControl() {1633- if (!connected) return;1634- controlMode = !controlMode;1635- updateControlUI();1636- }1637-1638- function updateControlUI() {1639- const el = document.getElementById('control-toggle');1640- el.textContent = controlMode ? 'controlling' : 'take control';1641- el.classList.toggle('active', controlMode);1642- }1643-1644- function scaleCoords(clientX, clientY) {1645- const rect = canvas.getBoundingClientRect();1646- const scaleX = frameWidth / rect.width;1647- const scaleY = frameHeight / rect.height;1648- return {1649- x: Math.round((clientX - rect.left) * scaleX),1650- y: Math.round((clientY - rect.top) * scaleY),1651- };1652- }1653-1654- function mapKey(e) {1655- const key = e.key;1656- const map = {1657- 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',1658- ' ': 'Space', 'Delete': 'Delete', 'Backspace': 'Backspace',1659- 'Enter': 'Enter', 'Tab': 'Tab', 'Escape': 'Escape',1660- 'Shift': 'Shift', 'Control': 'Control', 'Alt': 'Alt', 'Meta': 'Meta',1661- 'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',1662- };1663- return map[key] || (key.length === 1 ? key : null);1664- }1665-1666- async function sendInput(event) {1667- try {1668- await invoke('iroh_client_send_input', { event });1669- } catch (e) {1670- console.warn('input send failed:', e);1671- }1672- }1673-1674- canvas.addEventListener('mousemove', (e) => {1675- if (!controlMode) return;1676- const { x, y } = scaleCoords(e.clientX, e.clientY);1677- sendInput({ t: 'mm', x, y });1678- });1679-1680- canvas.addEventListener('mousedown', (e) => {1681- if (!controlMode) return;1682- e.preventDefault();1683- const btn = e.button === 2 ? 2 : (e.button === 1 ? 3 : 1);1684- sendInput({ t: 'md', b: btn });1685- });1686-1687- canvas.addEventListener('mouseup', (e) => {1688- if (!controlMode) return;1689- e.preventDefault();1690- const btn = e.button === 2 ? 2 : (e.button === 1 ? 3 : 1);1691- sendInput({ t: 'mu', b: btn });1692- });1693-1694- canvas.addEventListener('contextmenu', (e) => {1695- if (!controlMode) return;1696- e.preventDefault();1697- });1698-1699- canvas.addEventListener('wheel', (e) => {1700- if (!controlMode) return;1701- e.preventDefault();1702- sendInput({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });1703- }, { passive: false });1704-1705- window.addEventListener('keydown', (e) => {1706- if (!controlMode) return;1707- e.preventDefault();1708- const k = mapKey(e);1709- if (k) sendInput({ t: 'kd', k });1710- });1711-1712- window.addEventListener('keyup', (e) => {1713- if (!controlMode) return;1714- e.preventDefault();1715- const k = mapKey(e);1716- if (k) sendInput({ t: 'ku', k });1717- });1718-1719- window.addEventListener('keypress', (e) => {1720- if (!controlMode) return;1721- if (e.key.length === 1) {1722- e.preventDefault();1723- sendInput({ t: 'tx', s: e.key });1724- }1725- });1726-1727- // ─── PIN keyboard shortcuts ─────────────────────────────────────────1728- // When PIN has content: c=connect, r=register, h=host1729- document.addEventListener('keydown', (e) => {1730- const intro = document.getElementById('intro');1731- if (intro.classList.contains('hidden')) return;1732-1733- // Only trigger on c/r/h when focused in a PIN field with content1734- const active = document.activeElement;1735- if (!active || !active.classList.contains('overlay-input')) return;1736- const pin = active.value.trim();1737- if (!pin) return;1738-1739- const key = e.key.toLowerCase();1740- if (key === 'c') {1741- e.preventDefault();1742- if (active.id === 'intro-pin-unreg') introConnectUnreg();1743- else introConnectReg();1744- } else if (key === 'r' && active.id === 'intro-pin-unreg') {1745- e.preventDefault();1746- introRegister();1747- } else if (key === 'h' && active.id === 'intro-pin-reg') {1748- e.preventDefault();1749- introHost();1750- }1751- });1752-1753- // ─── PIN shortcut hints ─────────────────────────────────────────────1754- function updateIntroDes() {1755- const unregVisible = document.getElementById('intro-unregistered').style.display !== 'none';1756- const pinEl = unregVisible1757- ? document.getElementById('intro-pin-unreg')1758- : document.getElementById('intro-pin-reg');1759- const desc = document.getElementById('intro-desc');1760- if (!pinEl || !pinEl.value.trim()) {1761- desc.textContent = 'type pin';1762- return;1763- }1764- if (unregVisible) {1765- desc.textContent = '[c]=connect · [r]=register';1766- } else {1767- desc.textContent = '[c]=connect · [h]=host';1768- }1769- }1770- document.getElementById('intro-pin-unreg').addEventListener('input', updateIntroDes);1771- document.getElementById('intro-pin-reg').addEventListener('input', updateIntroDes);1772-1773- // ─── Fido done — key tapped, now waiting for relay ──────────────────1774- listen('fido-done', () => {1775- document.getElementById('tap-overlay-title').textContent = 'connecting';1776- document.getElementById('tap-overlay-desc').textContent = 'Key recognised. Waiting for host to respond.';1777- document.getElementById('tap-status').textContent = 'please wait...';1778- });1779-1780- // ─── Init ───────────────────────────────────────────────────────────1781- checkRegistration();1782- scanFido();1783- </script>232+ <script type="module" src="./main.js"></script>1784233 </body>1785234 </html>
A src/main.js +828 -0
@@ -0,0 +1,828 @@
1+import {2+ parseAnnexBNals,3+ nalsToLengthPrefixed,4+ buildAvcDescription,5+ avcCodecStr,6+ h264NalType,7+ buildHvcDescription,8+ hevcCodecStr,9+ hevcNalType,10+ parseAv1Obus,11+ av1CodecStr,12+ buildAv1Description,13+} from './codecs.js';14+15+let controlMode = false;16+let connected = false;17+let hosting = false;18+let registered = false;19+let frameWidth = 0;20+let frameHeight = 0;21+22+const invoke = (...args) => window.__TAURI__.core.invoke(...args);23+const listen = (...args) => window.__TAURI__.event.listen(...args);24+25+function updatePanelVisibility() {26+ const encSection = document.getElementById('encoder-section');27+ const hostPerfSection = document.getElementById('host-perf-section');28+ const streamSection = document.getElementById('stream-section');29+ encSection.style.display = connected ? 'none' : '';30+ hostPerfSection.style.display = connected ? 'none' : '';31+ streamSection.style.display = connected ? '' : 'none';32+}33+34+// ─── Status ──────────────────────────────────────────────────────────────────35+function setStatus(state, text) {36+ const line = document.getElementById('status-line');37+ const dot = document.getElementById('status-dot');38+ const txt = document.getElementById('status-text');39+ line.className = 'status-line ' + state;40+ dot.className = 'dot ' + state;41+ txt.textContent = text;42+}43+44+function showIntro() {45+ document.getElementById('intro').classList.remove('hidden');46+ setTimeout(() => {47+ const unregPin = document.getElementById('intro-pin-unreg');48+ const regPin = document.getElementById('intro-pin-reg');49+ const visible = unregPin.parentElement.parentElement.style.display !== 'none' ? unregPin : regPin;50+ if (visible) visible.focus();51+ }, 50);52+}53+function hideIntro() { document.getElementById('intro').classList.add('hidden'); }54+function showTap() {55+ document.getElementById('tap-overlay-title').textContent = 'tap key';56+ document.getElementById('tap-overlay-desc').textContent = 'Touch the sensor on your key when it blinks.';57+ document.getElementById('tap-status').textContent = 'waiting...';58+ document.getElementById('tap-overlay').classList.remove('hidden');59+}60+function hideTap() { document.getElementById('tap-overlay').classList.add('hidden'); }61+62+// ─── Registration ─────────────────────────────────────────────────────────────63+async function checkRegistration() {64+ try {65+ const reg = await invoke('host_registration_status');66+ registered = reg.registered;67+ const unregDiv = document.getElementById('intro-unregistered');68+ const regDiv = document.getElementById('intro-registered');69+ if (reg.registered) {70+ unregDiv.style.display = 'none';71+ regDiv.style.display = 'block';72+ document.getElementById('node-id-text').textContent = reg.node_id.substring(0, 16) + '...';73+ document.getElementById('reg-status').textContent = 'registered';74+ document.getElementById('reg-node-id').textContent = reg.node_id.substring(0, 24) + '...';75+ document.getElementById('action-main').textContent = 'host';76+ document.getElementById('action-unreg').style.display = '';77+ } else {78+ unregDiv.style.display = 'block';79+ regDiv.style.display = 'none';80+ document.getElementById('reg-status').textContent = 'client-only';81+ document.getElementById('action-main').textContent = 'register';82+ document.getElementById('action-unreg').style.display = 'none';83+ }84+85+ const daemon = await invoke('is_daemon_mode');86+ if (daemon && reg.registered) {87+ hideIntro();88+ await startHost();89+ } else {90+ showIntro();91+ }92+ } catch (e) {93+ console.error('registration check failed:', e);94+ showIntro();95+ }96+}97+98+// ─── Intro actions ────────────────────────────────────────────────────────────99+async function introRegister() {100+ const pin = document.getElementById('intro-pin-unreg').value.trim();101+ const status = document.getElementById('intro-status-unreg');102+ if (!pin) {103+ status.className = 'overlay-status err';104+ status.textContent = 'enter pin';105+ return;106+ }107+ hideIntro();108+ showTap();109+ try {110+ const result = await invoke('key_register_host', { pin });111+ if (result.registered) {112+ hideTap();113+ registered = true;114+ document.getElementById('node-id-text').textContent = result.node_id.substring(0, 16) + '...';115+ document.getElementById('action-main').textContent = 'host';116+ document.getElementById('action-unreg').style.display = '';117+ document.getElementById('intro-unregistered').style.display = 'none';118+ document.getElementById('intro-registered').style.display = 'block';119+ document.getElementById('reg-status').textContent = 'registered';120+ document.getElementById('reg-node-id').textContent = result.node_id.substring(0, 24) + '...';121+ showConfigScreen();122+ } else {123+ hideTap();124+ showIntro();125+ status.className = 'overlay-status err';126+ status.textContent = 'failed';127+ }128+ } catch (e) {129+ hideTap();130+ showIntro();131+ status.className = 'overlay-status err';132+ status.textContent = 'error: ' + e;133+ }134+}135+136+async function introConnectUnreg() {137+ const pin = document.getElementById('intro-pin-unreg').value.trim();138+ const status = document.getElementById('intro-status-unreg');139+ if (!pin) {140+ status.className = 'overlay-status err';141+ status.textContent = 'enter pin';142+ return;143+ }144+ hideIntro();145+ showTap();146+ document.getElementById('pin-input').value = pin;147+ await connectHost();148+ hideTap();149+ if (!connected) {150+ showIntro();151+ status.className = 'overlay-status err';152+ status.textContent = 'connection failed';153+ }154+}155+156+async function introConnectReg() {157+ const pin = document.getElementById('intro-pin-reg').value.trim();158+ const status = document.getElementById('intro-status-reg');159+ if (!pin) {160+ status.className = 'overlay-status err';161+ status.textContent = 'enter pin';162+ return;163+ }164+ hideIntro();165+ showTap();166+ document.getElementById('pin-input').value = pin;167+ await connectHost();168+ hideTap();169+ if (!connected) {170+ showIntro();171+ status.className = 'overlay-status err';172+ status.textContent = 'connection failed';173+ }174+}175+176+async function introHost() {177+ hideIntro();178+ showConfigScreen();179+}180+181+// ─── Config screen (pre-host) ─────────────────────────────────────────────────182+async function showConfigScreen() {183+ try {184+ const config = await invoke('get_encoder_config');185+ document.getElementById('cfg-codec').value = config.codec;186+ document.getElementById('cfg-backend').value = config.backend;187+ document.getElementById('cfg-bitrate').value = config.bitrate;188+ document.getElementById('cfg-framerate').value = config.framerate;189+ document.getElementById('cfg-gop').value = config.gop;190+ } catch (e) { console.error('config load failed:', e); }191+ try {192+ const available = await invoke('detect_available_encoders');193+ document.getElementById('cfg-available').textContent = available.join(', ') || 'none';194+ } catch (e) {195+ document.getElementById('cfg-available').textContent = 'ffmpeg not found';196+ }197+ document.getElementById('config-status').textContent = '';198+ document.getElementById('config-overlay').classList.remove('hidden');199+}200+201+function hideConfigScreen() {202+ document.getElementById('config-overlay').classList.add('hidden');203+}204+205+function configBack() {206+ hideConfigScreen();207+ showIntro();208+}209+210+async function configHost() {211+ const status = document.getElementById('config-status');212+ status.className = 'overlay-status pending';213+ status.textContent = 'saving...';214+ try {215+ const config = {216+ codec: document.getElementById('cfg-codec').value,217+ backend: document.getElementById('cfg-backend').value,218+ bitrate: document.getElementById('cfg-bitrate').value,219+ framerate: parseInt(document.getElementById('cfg-framerate').value) || 30,220+ gop: parseInt(document.getElementById('cfg-gop').value) || 30,221+ };222+ await invoke('set_encoder_config', { config });223+ hideConfigScreen();224+ await startHost();225+ } catch (e) {226+ status.className = 'overlay-status err';227+ status.textContent = 'error: ' + e;228+ }229+}230+231+// ─── Host ─────────────────────────────────────────────────────────────────────232+async function startHost() {233+ setStatus('pending', 'starting...');234+ try {235+ const result = await invoke('iroh_host_start');236+ if (result.running) {237+ hosting = true;238+ setStatus('ok', 'hosting');239+ updatePanelVisibility();240+ document.getElementById('node-id-text').textContent = result.node_id.substring(0, 16) + '...';241+ document.getElementById('action-disconnect').textContent = 'stop hosting';242+ document.getElementById('action-disconnect').classList.remove('hidden');243+ document.getElementById('sep-disconnect-main').classList.remove('hidden');244+ document.getElementById('action-main').style.display = 'none';245+ document.getElementById('action-unreg').style.display = 'none';246+ document.getElementById('placeholder').classList.add('hidden');247+ document.getElementById('host-info').classList.remove('hidden');248+ document.getElementById('host-node-id').textContent = result.node_id.substring(0, 24) + '...';249+ document.getElementById('pin-label').classList.add('hidden');250+ document.getElementById('pin-input').classList.add('hidden');251+ document.getElementById('action-connect').style.display = 'none';252+ } else {253+ setStatus('err', 'failed');254+ }255+ } catch (e) {256+ setStatus('err', 'error');257+ }258+}259+260+async function stopHost() {261+ try {262+ await invoke('iroh_host_stop');263+ } catch (e) { console.error(e); }264+ hosting = false;265+ setStatus('', 'idle');266+ updatePanelVisibility();267+ document.getElementById('action-main').style.display = '';268+ document.getElementById('action-main').textContent = registered ? 'host' : 'register';269+ document.getElementById('action-unreg').style.display = registered ? '' : 'none';270+ document.getElementById('action-disconnect').classList.add('hidden');271+ document.getElementById('sep-disconnect-main').classList.add('hidden');272+ document.getElementById('host-info').classList.add('hidden');273+ document.getElementById('placeholder').classList.remove('hidden');274+ document.getElementById('pin-label').classList.remove('hidden');275+ document.getElementById('pin-input').classList.remove('hidden');276+ document.getElementById('action-connect').style.display = '';277+}278+279+// ─── Main action (host/register) ──────────────────────────────────────────────280+async function mainAction() {281+ if (hosting) return;282+ if (registered) {283+ showConfigScreen();284+ } else {285+ showIntro();286+ }287+}288+289+// ─── Client ───────────────────────────────────────────────────────────────────290+async function connectHost() {291+ const pin = document.getElementById('pin-input').value.trim();292+ if (!pin) {293+ setStatus('err', 'enter pin');294+ return;295+ }296+ setStatus('pending', 'connecting...');297+ try {298+ const result = await invoke('iroh_client_connect', { pin });299+ if (result.connected) {300+ connected = true;301+ setStatus('ok', 'connected');302+ droppedFrames = 0;303+ receivedFrames = 0;304+ updatePanelVisibility();305+ document.getElementById('node-id-text').textContent = result.host_node_id.substring(0, 16) + '...';306+ document.getElementById('frame-canvas').style.display = 'block';307+ document.getElementById('placeholder').classList.add('hidden');308+ document.getElementById('control-bar').classList.add('visible');309+ document.getElementById('action-connect').style.display = 'none';310+ document.getElementById('action-disconnect').textContent = 'disconnect';311+ document.getElementById('action-disconnect').classList.remove('hidden');312+ document.getElementById('sep-disconnect').classList.remove('hidden');313+ document.getElementById('action-main').style.display = 'none';314+ } else {315+ setStatus('err', 'failed');316+ }317+ } catch (e) {318+ setStatus('err', 'error');319+ }320+}321+322+async function disconnect() {323+ if (hosting) {324+ await stopHost();325+ return;326+ }327+ try {328+ await invoke('iroh_client_disconnect');329+ } catch (e) { console.error(e); }330+ connected = false;331+ controlMode = false;332+ updateControlUI();333+ updatePanelVisibility();334+ setStatus('', 'idle');335+ document.getElementById('frame-canvas').style.display = 'none';336+ document.getElementById('placeholder').classList.remove('hidden');337+ document.getElementById('placeholder').textContent = 'no stream';338+ document.getElementById('stats').classList.remove('visible');339+ document.getElementById('control-bar').classList.remove('visible');340+ document.getElementById('action-connect').style.display = '';341+ document.getElementById('action-disconnect').classList.add('hidden');342+ document.getElementById('sep-disconnect').classList.add('hidden');343+ document.getElementById('action-main').style.display = '';344+ document.getElementById('intro-status-unreg').textContent = '';345+ document.getElementById('intro-status-reg').textContent = '';346+}347+348+// ─── Unregister ───────────────────────────────────────────────────────────────349+function unregister() {350+ document.getElementById('action-unreg').style.display = 'none';351+ document.getElementById('action-unreg-confirm').classList.remove('hidden');352+ document.getElementById('action-unreg-cancel').classList.remove('hidden');353+}354+355+function unregisterCancel() {356+ document.getElementById('action-unreg-confirm').classList.add('hidden');357+ document.getElementById('action-unreg-cancel').classList.add('hidden');358+ document.getElementById('action-unreg').style.display = '';359+}360+361+async function unregisterConfirm() {362+ document.getElementById('action-unreg-confirm').classList.add('hidden');363+ document.getElementById('action-unreg-cancel').classList.add('hidden');364+ try {365+ await invoke('host_unregister');366+ registered = false;367+ setStatus('', 'idle');368+ document.getElementById('node-id-text').textContent = '';369+ document.getElementById('reg-status').textContent = 'client-only';370+ document.getElementById('reg-node-id').textContent = '—';371+ document.getElementById('action-main').textContent = 'register';372+ document.getElementById('action-unreg').style.display = 'none';373+ showIntro();374+ } catch (e) {375+ alert('unregister failed: ' + e);376+ document.getElementById('action-unreg').style.display = '';377+ }378+}379+380+// ─── FIDO scan ────────────────────────────────────────────────────────────────381+async function scanFido() {382+ try {383+ const info = await invoke('fido_device_info');384+ const el = document.getElementById('fido-status');385+ if (!info.found) {386+ el.textContent = 'no device';387+ el.style.color = 'var(--muted-fg)';388+ return;389+ }390+ el.textContent = info.error ? `found (${info.error})` : 'connected';391+ el.style.color = info.error ? 'var(--yellow)' : 'var(--green)';392+ document.getElementById('fido-product').textContent = info.product || '—';393+ document.getElementById('fido-vidpid').textContent =394+ `${info.vid.toString(16).padStart(4,'0')}:${info.pid.toString(16).padStart(4,'0')}`;395+ document.getElementById('fido-versions').textContent = info.versions.join(', ') || '—';396+ document.getElementById('fido-extensions').textContent = info.extensions.join(', ') || '—';397+ document.getElementById('fido-pin').textContent = info.pin_retries ?? '—';398+ } catch (e) {399+ console.error('scanFido failed:', e);400+ document.getElementById('fido-status').textContent = 'error';401+ }402+}403+404+// ─── Panel ────────────────────────────────────────────────────────────────────405+function togglePanel() {406+ document.getElementById('panel').classList.toggle('visible');407+ document.getElementById('panel-overlay').classList.toggle('visible');408+}409+410+// ─── Host connection events ───────────────────────────────────────────────────411+listen('host-connections', (event) => {412+ document.getElementById('host-conn-count').textContent = event.payload;413+});414+415+// ─── Encode performance sparkline ─────────────────────────────────────────────416+const encodeHistory = [];417+const MAX_HISTORY = 60;418+const sparkCanvas = document.getElementById('encode-spark');419+const sparkCtx = sparkCanvas.getContext('2d');420+421+function drawSparkline() {422+ const w = sparkCanvas.width;423+ const h = sparkCanvas.height;424+ sparkCtx.clearRect(0, 0, w, h);425+ if (encodeHistory.length < 2) return;426+427+ const max = Math.max(...encodeHistory, 1);428+ const step = w / (MAX_HISTORY - 1);429+430+ sparkCtx.beginPath();431+ sparkCtx.moveTo(0, h);432+ encodeHistory.forEach((ms, i) => {433+ const x = i * step;434+ const y = h - (ms / max) * (h - 4) - 2;435+ sparkCtx.lineTo(x, y);436+ });437+ sparkCtx.lineTo((encodeHistory.length - 1) * step, h);438+ sparkCtx.closePath();439+ sparkCtx.fillStyle = 'rgba(74, 222, 128, 0.15)';440+ sparkCtx.fill();441+442+ sparkCtx.beginPath();443+ encodeHistory.forEach((ms, i) => {444+ const x = i * step;445+ const y = h - (ms / max) * (h - 4) - 2;446+ if (i === 0) sparkCtx.moveTo(x, y);447+ else sparkCtx.lineTo(x, y);448+ });449+ sparkCtx.strokeStyle = '#4ade80';450+ sparkCtx.lineWidth = 1;451+ sparkCtx.stroke();452+}453+454+listen('host-encode-stats', (event) => {455+ const { encode_ms, capture_ms, size_bytes, fps, encoder } = event.payload;456+ document.getElementById('perf-encode').textContent = encode_ms.toFixed(1) + ' ms';457+ document.getElementById('perf-capture').textContent = encoder || (capture_ms > 0 ? capture_ms.toFixed(1) + ' ms' : 'ffmpeg');458+ document.getElementById('perf-size').textContent = (size_bytes / 1024).toFixed(1) + ' KB';459+ document.getElementById('perf-fps').textContent = fps.toFixed(1);460+461+ encodeHistory.push(encode_ms);462+ if (encodeHistory.length > MAX_HISTORY) encodeHistory.shift();463+ drawSparkline();464+});465+466+// ─── WebCodecs detection ──────────────────────────────────────────────────────467+let hasWebCodecs = ('VideoDecoder' in window);468+let videoDecoder = null;469+let decoderConfigured = false;470+471+(async function detectWebCodecs() {472+ await invoke('set_webcodecs_available', { available: hasWebCodecs });473+})();474+475+// ─── Encoder config ───────────────────────────────────────────────────────────476+(async function loadEncoderConfig() {477+ try {478+ const config = await invoke('get_encoder_config');479+ document.getElementById('enc-codec').value = config.codec;480+ document.getElementById('enc-backend').value = config.backend;481+ document.getElementById('enc-bitrate').value = config.bitrate;482+ document.getElementById('enc-framerate').value = config.framerate;483+ document.getElementById('enc-gop').value = config.gop;484+ } catch (e) { console.error('encoder config load failed:', e); }485+486+ try {487+ const available = await invoke('detect_available_encoders');488+ document.getElementById('enc-available').textContent = available.join(', ') || 'none';489+ } catch (e) {490+ document.getElementById('enc-available').textContent = 'ffmpeg not found';491+ }492+})();493+494+async function saveEncoderConfig() {495+ const config = {496+ codec: document.getElementById('enc-codec').value,497+ backend: document.getElementById('enc-backend').value,498+ bitrate: document.getElementById('enc-bitrate').value,499+ framerate: parseInt(document.getElementById('enc-framerate').value) || 30,500+ gop: parseInt(document.getElementById('enc-gop').value) || 30,501+ };502+ await invoke('set_encoder_config', { config });503+}504+505+// ─── Frame decoding ───────────────────────────────────────────────────────────506+let activeCodec = 'h264';507+let droppedFrames = 0;508+let receivedFrames = 0;509+510+const canvas = document.getElementById('frame-canvas');511+const ctx = canvas.getContext('2d');512+513+function initWebCodecsDecoder(width, height, desc, codecStr) {514+ if (videoDecoder) {515+ try { videoDecoder.close(); } catch(e) {}516+ }517+ console.log('WebCodecs configure:', codecStr, 'w:', width, 'h:', height, 'desc:', desc.byteLength, 'bytes');518+ videoDecoder = new VideoDecoder({519+ output: (frame) => {520+ ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);521+ frame.close();522+ },523+ error: (e) => {524+ console.error('VideoDecoder error:', e);525+ decoderConfigured = false;526+ try { videoDecoder.close(); } catch(_) {}527+ videoDecoder = null;528+ }529+ });530+ videoDecoder.configure({531+ codec: codecStr,532+ codedWidth: width,533+ codedHeight: height,534+ description: desc,535+ optimizeForRealtimeUse: true,536+ });537+ decoderConfigured = true;538+}539+540+function updateStreamStats() {541+ document.getElementById('stream-received').textContent = receivedFrames;542+ document.getElementById('stream-dropped').textContent = droppedFrames;543+ document.getElementById('stream-codec').textContent = activeCodec;544+}545+546+listen('frame', (event) => {547+ const { width, height, data, codec } = event.payload;548+ frameWidth = width;549+ frameHeight = height;550+ if (canvas.width !== width) canvas.width = width;551+ if (canvas.height !== height) canvas.height = height;552+553+ if (codec && codec !== activeCodec) {554+ activeCodec = codec;555+ decoderConfigured = false;556+ if (videoDecoder) {557+ try { videoDecoder.close(); } catch(_) {}558+ videoDecoder = null;559+ }560+ console.log('codec changed:', activeCodec);561+ }562+563+ receivedFrames++;564+565+ if (hasWebCodecs) {566+ const raw = Uint8Array.from(atob(data), c => c.charCodeAt(0));567+568+ if (activeCodec === 'av1') {569+ // ── AV1 path ──570+ const obus = parseAv1Obus(raw);571+ if (event.payload.keyframe) {572+ const seqHeader = obus.find(o => o.type === 12);573+ if (seqHeader && !decoderConfigured) {574+ initWebCodecsDecoder(width, height, buildAv1Description(seqHeader.data), av1CodecStr(seqHeader.data));575+ }576+ }577+ if (!decoderConfigured || !videoDecoder) { droppedFrames++; updateStreamStats(); return; }578+ const frameObus = obus.filter(o => o.type !== 2 && o.type !== 12);579+ if (frameObus.length > 0) {580+ let totalLen = 0;581+ for (const o of frameObus) totalLen += o.data.length;582+ const chunkData = new Uint8Array(totalLen);583+ let off = 0;584+ for (const o of frameObus) { chunkData.set(o.data, off); off += o.data.length; }585+ const chunk = new EncodedVideoChunk({586+ type: event.payload.keyframe ? 'key' : 'delta',587+ timestamp: performance.now() * 1000,588+ data: chunkData,589+ });590+ try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('av1 decode failed:', e); }591+ }592+593+ } else if (activeCodec === 'h265') {594+ // ── H.265 path ──595+ const nals = parseAnnexBNals(raw);596+ if (event.payload.keyframe) {597+ let vps = null, sps = null, pps = null;598+ for (const nal of nals) {599+ const t = hevcNalType(nal);600+ if (t === 32) vps = nal;601+ else if (t === 33) sps = nal;602+ else if (t === 34) pps = nal;603+ }604+ if (vps && sps && pps && !decoderConfigured) {605+ initWebCodecsDecoder(width, height, buildHvcDescription(vps, sps, pps), hevcCodecStr(sps));606+ }607+ }608+ if (!decoderConfigured || !videoDecoder) { droppedFrames++; updateStreamStats(); return; }609+ const sliceNals = nals.filter(nal => {610+ const t = hevcNalType(nal);611+ return t !== 32 && t !== 33 && t !== 34 && t !== 35;612+ });613+ if (sliceNals.length > 0) {614+ const chunk = new EncodedVideoChunk({615+ type: event.payload.keyframe ? 'key' : 'delta',616+ timestamp: performance.now() * 1000,617+ data: nalsToLengthPrefixed(sliceNals),618+ });619+ try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('hevc decode failed:', e); }620+ }621+622+ } else {623+ // ── H.264 path (default) ──624+ const nals = parseAnnexBNals(raw);625+ if (event.payload.keyframe) {626+ let sps = null, pps = null;627+ for (const nal of nals) {628+ const t = h264NalType(nal);629+ if (t === 7) sps = nal;630+ else if (t === 8) pps = nal;631+ }632+ if (sps && pps && !decoderConfigured) {633+ initWebCodecsDecoder(width, height, buildAvcDescription(sps, pps), avcCodecStr(sps));634+ }635+ }636+ if (!decoderConfigured || !videoDecoder) { droppedFrames++; updateStreamStats(); return; }637+ const sliceNals = nals.filter(nal => {638+ const t = h264NalType(nal);639+ return t !== 7 && t !== 8 && t !== 9;640+ });641+ if (sliceNals.length > 0) {642+ const chunk = new EncodedVideoChunk({643+ type: event.payload.keyframe ? 'key' : 'delta',644+ timestamp: performance.now() * 1000,645+ data: nalsToLengthPrefixed(sliceNals),646+ });647+ try { videoDecoder.decode(chunk); } catch (e) { droppedFrames++; console.warn('h264 decode failed:', e); }648+ }649+ }650+ } else {651+ // JPEG fallback (openh264 decode → JPEG encode in Rust)652+ const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));653+ const blob = new Blob([bytes], { type: 'image/jpeg' });654+ const url = URL.createObjectURL(blob);655+ const img = new Image();656+ img.onload = () => {657+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);658+ URL.revokeObjectURL(url);659+ };660+ img.src = url;661+ }662+663+ updateStreamStats();664+});665+666+listen('frame-stats', (event) => {667+ const stats = document.getElementById('stats');668+ const kf = event.payload.keyframe ? ' [kf]' : '';669+ stats.textContent = `${event.payload.fps.toFixed(1)} fps · ${event.payload.count}${kf}`;670+ stats.classList.add('visible');671+});672+673+listen('frame-error', () => {674+ if (connected) disconnect();675+});676+677+// ─── Input ────────────────────────────────────────────────────────────────────678+function toggleControl() {679+ if (!connected) return;680+ controlMode = !controlMode;681+ updateControlUI();682+}683+684+function updateControlUI() {685+ const el = document.getElementById('control-toggle');686+ el.textContent = controlMode ? 'controlling' : 'take control';687+ el.classList.toggle('active', controlMode);688+}689+690+function scaleCoords(clientX, clientY) {691+ const rect = canvas.getBoundingClientRect();692+ const scaleX = frameWidth / rect.width;693+ const scaleY = frameHeight / rect.height;694+ return {695+ x: Math.round((clientX - rect.left) * scaleX),696+ y: Math.round((clientY - rect.top) * scaleY),697+ };698+}699+700+function mapKey(e) {701+ const key = e.key;702+ const map = {703+ 'ArrowUp': 'Up', 'ArrowDown': 'Down', 'ArrowLeft': 'Left', 'ArrowRight': 'Right',704+ ' ': 'Space', 'Delete': 'Delete', 'Backspace': 'Backspace',705+ 'Enter': 'Enter', 'Tab': 'Tab', 'Escape': 'Escape',706+ 'Shift': 'Shift', 'Control': 'Control', 'Alt': 'Alt', 'Meta': 'Meta',707+ 'Home': 'Home', 'End': 'End', 'PageUp': 'PageUp', 'PageDown': 'PageDown',708+ };709+ return map[key] || (key.length === 1 ? key : null);710+}711+712+async function sendInput(event) {713+ try {714+ await invoke('iroh_client_send_input', { event });715+ } catch (e) {716+ console.warn('input send failed:', e);717+ }718+}719+720+canvas.addEventListener('mousemove', (e) => {721+ if (!controlMode) return;722+ const { x, y } = scaleCoords(e.clientX, e.clientY);723+ sendInput({ t: 'mm', x, y });724+});725+726+canvas.addEventListener('mousedown', (e) => {727+ if (!controlMode) return;728+ e.preventDefault();729+ const btn = e.button === 2 ? 2 : (e.button === 1 ? 3 : 1);730+ sendInput({ t: 'md', b: btn });731+});732+733+canvas.addEventListener('mouseup', (e) => {734+ if (!controlMode) return;735+ e.preventDefault();736+ const btn = e.button === 2 ? 2 : (e.button === 1 ? 3 : 1);737+ sendInput({ t: 'mu', b: btn });738+});739+740+canvas.addEventListener('contextmenu', (e) => {741+ if (!controlMode) return;742+ e.preventDefault();743+});744+745+canvas.addEventListener('wheel', (e) => {746+ if (!controlMode) return;747+ e.preventDefault();748+ sendInput({ t: 'ms', dx: Math.round(e.deltaX), dy: Math.round(e.deltaY) });749+}, { passive: false });750+751+window.addEventListener('keydown', (e) => {752+ if (!controlMode) return;753+ e.preventDefault();754+ const k = mapKey(e);755+ if (k) sendInput({ t: 'kd', k });756+});757+758+window.addEventListener('keyup', (e) => {759+ if (!controlMode) return;760+ e.preventDefault();761+ const k = mapKey(e);762+ if (k) sendInput({ t: 'ku', k });763+});764+765+window.addEventListener('keypress', (e) => {766+ if (!controlMode) return;767+ if (e.key.length === 1) {768+ e.preventDefault();769+ sendInput({ t: 'tx', s: e.key });770+ }771+});772+773+// ─── PIN keyboard shortcuts ───────────────────────────────────────────────────774+document.addEventListener('keydown', (e) => {775+ const intro = document.getElementById('intro');776+ if (intro.classList.contains('hidden')) return;777+ const active = document.activeElement;778+ if (!active || !active.classList.contains('overlay-input')) return;779+ const pin = active.value.trim();780+ if (!pin) return;781+ const key = e.key.toLowerCase();782+ if (key === 'c') {783+ e.preventDefault();784+ if (active.id === 'intro-pin-unreg') introConnectUnreg();785+ else introConnectReg();786+ } else if (key === 'r' && active.id === 'intro-pin-unreg') {787+ e.preventDefault();788+ introRegister();789+ } else if (key === 'h' && active.id === 'intro-pin-reg') {790+ e.preventDefault();791+ introHost();792+ }793+});794+795+// ─── PIN shortcut hints ───────────────────────────────────────────────────────796+function updateIntroDes() {797+ const unregVisible = document.getElementById('intro-unregistered').style.display !== 'none';798+ const pinEl = unregVisible799+ ? document.getElementById('intro-pin-unreg')800+ : document.getElementById('intro-pin-reg');801+ const desc = document.getElementById('intro-desc');802+ if (!pinEl || !pinEl.value.trim()) {803+ desc.textContent = 'type pin';804+ return;805+ }806+ desc.textContent = unregVisible ? '[c]=connect · [r]=register' : '[c]=connect · [h]=host';807+}808+document.getElementById('intro-pin-unreg').addEventListener('input', updateIntroDes);809+document.getElementById('intro-pin-reg').addEventListener('input', updateIntroDes);810+811+listen('fido-done', () => {812+ document.getElementById('tap-overlay-title').textContent = 'connecting';813+ document.getElementById('tap-overlay-desc').textContent = 'Key recognised. Waiting for host to respond.';814+ document.getElementById('tap-status').textContent = 'please wait...';815+});816+817+// ─── Expose to HTML onclick handlers ─────────────────────────────────────────818+Object.assign(window, {819+ introRegister, introConnectUnreg, introConnectReg, introHost,820+ configBack, configHost,821+ mainAction, connectHost, disconnect,822+ unregister, unregisterCancel, unregisterConfirm,823+ scanFido, togglePanel, toggleControl, saveEncoderConfig,824+});825+826+// ─── Init ─────────────────────────────────────────────────────────────────────827+checkRegistration();828+scanFido();
A src/style.css +443 -0
@@ -0,0 +1,443 @@
1+:root {2+ --background: #1a1a1a;3+ --foreground: #c4c4c4;4+ --muted: #2a2a2a;5+ --muted-fg: #808080;6+ --accent: #2a2a2a;7+ --border: rgba(255,255,255,0.1);8+ --border-strong: rgba(255,255,255,0.15);9+ --destructive: #ff6568;10+ --green: #4ade80;11+ --yellow: #fbbf24;12+ --mono: "Inconsolata", "SF Mono", ui-monospace, monospace;13+ --sans: "IBM Plex Sans", -apple-system, system-ui, sans-serif;14+}15+* { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; }16+input, textarea { -webkit-user-select: text; user-select: text; }17+.info-value,18+.overlay-status,19+.host-info .value,20+.stats,21+.config-row-value,22+.node-id { -webkit-user-select: text; user-select: text; }23+html, body { height: 100%; }24+body {25+ background: var(--background);26+ color: var(--foreground);27+ font-family: var(--sans);28+ font-size: 13px;29+ display: flex;30+ flex-direction: column;31+ overflow: hidden;32+ -webkit-font-smoothing: antialiased;33+}34+35+/* ─── Top bar ─────────────────────────────────────────────────────── */36+.topbar {37+ display: flex;38+ align-items: center;39+ gap: 12px;40+ padding: 8px 16px;41+ border-bottom: 1px solid var(--border);42+ flex-shrink: 0;43+}44+.brand {45+ font-family: var(--mono);46+ font-size: 13px;47+ font-weight: 500;48+ color: var(--foreground);49+}50+.sep { color: var(--muted-fg); }51+.status-line {52+ font-family: var(--mono);53+ font-size: 12px;54+ color: var(--muted-fg);55+ display: flex;56+ align-items: center;57+ gap: 6px;58+}59+.status-line .dot {60+ width: 6px; height: 6px; border-radius: 50%;61+ background: var(--muted-fg);62+}63+.status-line .dot.ok { background: var(--green); }64+.status-line .dot.err { background: var(--destructive); }65+.status-line .dot.pending { background: var(--yellow); }66+.status-line.ok { color: var(--green); }67+.status-line.err { color: var(--destructive); }68+.status-line.pending { color: var(--yellow); }69+.topbar-right {70+ margin-left: auto;71+ display: flex;72+ align-items: center;73+ gap: 12px;74+}75+.node-id {76+ font-family: var(--mono);77+ font-size: 11px;78+ color: var(--muted-fg);79+ max-width: 180px;80+ overflow: hidden;81+ text-overflow: ellipsis;82+ white-space: nowrap;83+}84+.link-btn {85+ font-family: var(--mono);86+ font-size: 12px;87+ color: var(--muted-fg);88+ cursor: pointer;89+ text-decoration: underline;90+ text-decoration-color: transparent;91+ background: none;92+ border: none;93+ padding: 0;94+ transition: color 0.1s, text-decoration-color 0.1s;95+}96+.link-btn:hover {97+ color: var(--foreground);98+ text-decoration-color: currentColor;99+}100+101+/* ─── Main area ───────────────────────────────────────────────────── */102+.main {103+ flex: 1;104+ display: flex;105+ align-items: center;106+ justify-content: center;107+ position: relative;108+ min-height: 0;109+ background: #0d0d0d;110+}111+#frame-canvas {112+ max-width: 100%;113+ max-height: 100%;114+ object-fit: contain;115+ display: none;116+}117+.placeholder {118+ text-align: center;119+ color: var(--muted-fg);120+ font-family: var(--mono);121+ font-size: 13px;122+ padding: 40px;123+}124+.placeholder.hidden { display: none; }125+126+/* Hosting info panel */127+.host-info {128+ text-align: center;129+ color: var(--muted-fg);130+ font-family: var(--mono);131+ padding: 40px;132+}133+.host-info.hidden { display: none; }134+.host-info .label { font-size: 11px; color: var(--muted-fg); margin-bottom: 4px; }135+.host-info .value { font-size: 16px; color: var(--foreground); margin-bottom: 20px; }136+.host-info .conn-count { font-size: 24px; color: var(--green); }137+138+/* Stats overlay */139+.stats {140+ position: absolute;141+ top: 8px; right: 8px;142+ font-family: var(--mono);143+ font-size: 11px;144+ color: var(--green);145+ background: rgba(0,0,0,0.5);146+ padding: 2px 8px;147+ display: none;148+ pointer-events: none;149+}150+.stats.visible { display: block; }151+152+/* Control overlay */153+.control-bar {154+ position: absolute;155+ bottom: 8px; left: 50%;156+ transform: translateX(-50%);157+ display: none;158+ gap: 4px;159+ background: rgba(0,0,0,0.5);160+ padding: 4px;161+}162+.control-bar.visible { display: flex; }163+.control-toggle {164+ font-family: var(--mono);165+ font-size: 12px;166+ color: var(--muted-fg);167+ cursor: pointer;168+ padding: 4px 10px;169+ text-decoration: underline;170+ text-decoration-color: transparent;171+ transition: color 0.1s, text-decoration-color 0.1s;172+}173+.control-toggle:hover {174+ color: var(--foreground);175+ text-decoration-color: currentColor;176+}177+.control-toggle.active {178+ color: var(--green);179+ text-decoration-color: currentColor;180+}181+182+/* ─── Bottom bar ──────────────────────────────────────────────────── */183+.bottombar {184+ display: flex;185+ align-items: center;186+ gap: 8px;187+ padding: 6px 16px;188+ border-top: 1px solid var(--border);189+ background: var(--background);190+ flex-shrink: 0;191+ font-family: var(--mono);192+ font-size: 12px;193+}194+.bottombar .prompt { color: var(--foreground); }195+.bottombar .sep { color: var(--muted-fg); }196+.bottombar .action {197+ color: var(--muted-fg);198+ cursor: pointer;199+ text-decoration: underline;200+ text-decoration-color: transparent;201+ transition: color 0.1s, text-decoration-color 0.1s;202+}203+.bottombar .action:hover {204+ color: var(--foreground);205+ text-decoration-color: currentColor;206+}207+.bottombar .action.primary { color: var(--foreground); }208+.bottombar .action.danger { color: var(--destructive); opacity: 0.7; }209+.bottombar .action.danger:hover { opacity: 1; }210+.bottombar .hidden { display: none; }211+.bottombar .spacer { flex: 1; }212+.pin-group {213+ display: flex;214+ align-items: center;215+ gap: 6px;216+}217+.pin-input {218+ width: 80px;219+ background: transparent;220+ border: none;221+ border-bottom: 1px solid var(--border-strong);222+ color: var(--foreground);223+ padding: 2px 4px;224+ font-family: var(--mono);225+ font-size: 12px;226+ text-align: center;227+}228+.pin-input:focus {229+ outline: none;230+ border-bottom-color: var(--foreground);231+}232+.pin-input.hidden { display: none; }233+.pin-label {234+ font-size: 11px;235+ color: var(--muted-fg);236+}237+.pin-label.hidden { display: none; }238+239+/* ─── Intro / overlay screen ──────────────────────────────────────── */240+.overlay-screen {241+ position: fixed;242+ inset: 0;243+ z-index: 100;244+ display: flex;245+ align-items: center;246+ justify-content: center;247+ background: var(--background);248+}249+.overlay-screen.hidden { display: none; }250+.overlay-content {251+ text-align: center;252+ width: 320px;253+ padding: 32px;254+ border: 1px solid var(--border-strong);255+ background: var(--muted);256+}257+.overlay-content h1 {258+ font-family: var(--mono);259+ font-size: 16px;260+ font-weight: 500;261+ color: var(--foreground);262+ margin-bottom: 12px;263+}264+#intro .overlay-content h1 {265+ font-size: 26px;266+ letter-spacing: -0.5px;267+ margin-bottom: 8px;268+}269+.overlay-content p {270+ font-size: 12px;271+ color: var(--muted-fg);272+ line-height: 1.6;273+ margin-bottom: 24px;274+ font-family: var(--mono);275+ letter-spacing: 0.02em;276+}277+.shortcut-key {278+ color: var(--foreground);279+}280+.overlay-form {281+ display: flex;282+ gap: 8px;283+ justify-content: center;284+ align-items: center;285+}286+.overlay-input {287+ width: 120px;288+ text-align: center;289+ background: transparent;290+ border: none;291+ border-bottom: 1px solid var(--border-strong);292+ color: var(--foreground);293+ padding: 6px 8px;294+ font-family: var(--mono);295+ font-size: 14px;296+}297+.overlay-input:focus {298+ outline: none;299+ border-bottom-color: var(--foreground);300+}301+.overlay-btn {302+ font-family: var(--mono);303+ font-size: 13px;304+ color: var(--muted-fg);305+ cursor: pointer;306+ background: none;307+ border: none;308+ padding: 6px 12px;309+ text-decoration: underline;310+ text-decoration-color: transparent;311+ transition: color 0.1s, text-decoration-color 0.1s;312+}313+.overlay-btn:hover {314+ color: var(--foreground);315+ text-decoration-color: currentColor;316+}317+.overlay-btn:disabled {318+ opacity: 0.4;319+ cursor: not-allowed;320+}321+.overlay-status {322+ font-family: var(--mono);323+ font-size: 12px;324+ color: var(--muted-fg);325+ margin-top: 16px;326+ min-height: 16px;327+}328+.overlay-status.pending { color: var(--yellow); }329+.overlay-status.err { color: var(--destructive); }330+.overlay-status.ok { color: var(--green); }331+332+/* ─── Side panel ──────────────────────────────────────────────────── */333+.panel-overlay {334+ position: fixed;335+ inset: 0;336+ background: rgba(0,0,0,0.4);337+ z-index: 90;338+ display: none;339+}340+.panel-overlay.visible { display: block; }341+.panel {342+ position: fixed;343+ top: 0; right: 0; bottom: 0;344+ width: 300px;345+ background: var(--background);346+ border-left: 1px solid var(--border);347+ transform: translateX(100%);348+ transition: transform 0.15s;349+ z-index: 91;350+ display: flex;351+ flex-direction: column;352+}353+.panel.visible { transform: translateX(0); }354+.panel-header {355+ display: flex;356+ align-items: center;357+ justify-content: space-between;358+ padding: 10px 16px;359+ border-bottom: 1px solid var(--border);360+}361+.panel-header h2 {362+ font-family: var(--mono);363+ font-size: 13px;364+ font-weight: 500;365+}366+.panel-body {367+ flex: 1;368+ overflow-y: auto;369+ padding: 12px 16px;370+}371+.panel-section {372+ margin-bottom: 24px;373+}374+.panel-section h3 {375+ font-family: var(--mono);376+ font-size: 11px;377+ font-weight: 500;378+ color: var(--muted-fg);379+ margin-bottom: 8px;380+}381+.info-row {382+ display: flex;383+ justify-content: space-between;384+ align-items: center;385+ padding: 4px 0;386+ border-bottom: 1px solid var(--border);387+}388+.info-row:last-child { border-bottom: none; }389+.info-label {390+ font-size: 12px;391+ color: var(--muted-fg);392+}393+.info-value {394+ font-family: var(--mono);395+ font-size: 11px;396+ color: var(--foreground);397+}398+.config-select {399+ font-family: var(--mono);400+ font-size: 11px;401+ color: var(--foreground);402+ background: var(--background);403+ border: 1px solid var(--border);404+ padding: 2px 4px;405+ cursor: pointer;406+}407+.config-input {408+ font-family: var(--mono);409+ font-size: 11px;410+ color: var(--foreground);411+ background: var(--background);412+ border: 1px solid var(--border);413+ padding: 2px 4px;414+ width: 80px;415+}416+.config-input:focus {417+ outline: none;418+ border-color: var(--foreground);419+}420+421+/* ─── Config overlay (pre-host) ───────────────────────────────────── */422+.config-rows { text-align: left; margin-bottom: 8px; }423+.config-row {424+ display: flex;425+ justify-content: space-between;426+ align-items: center;427+ padding: 6px 0;428+ border-bottom: 1px solid var(--border);429+}430+.config-row:last-child { border-bottom: none; }431+.config-row-label {432+ font-family: var(--mono);433+ font-size: 12px;434+ color: var(--muted-fg);435+}436+.config-row-value {437+ font-family: var(--mono);438+ font-size: 11px;439+ color: var(--foreground);440+ max-width: 170px;441+ text-align: right;442+ word-break: break-all;443+}