Files
2026-10-01 21:38:54 +10:00

4939 lines
308 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Frame Control</title>
<style>
/* Motiva Sans is Steam's UI font; Valve's CDN serves it with CORS open. */
@font-face { font-family: "Motiva Sans"; font-weight: 300; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Light.ttf?v=4.015"); }
@font-face { font-family: "Motiva Sans"; font-weight: 400; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Regular.ttf?v=4.015"); }
@font-face { font-family: "Motiva Sans"; font-weight: 500; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Medium.ttf?v=4.015"); }
@font-face { font-family: "Motiva Sans"; font-weight: 700; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Bold.ttf?v=4.015"); }
:root {
/* Steam client palette */
--bg: #1b2838; --bg-deep: #0e141b;
--text: #dcdedf; --bright: #ffffff; --muted: #8f98a0; --dim: #5e6873;
--blue: #1a9fff; --link: #66c0f4;
--green: #59bf40; --green-hi: #a4d007;
--warn: #d9a23a; --bad: #d94126;
--btn: rgba(103, 112, 123, .2); --btn-hi: rgba(103, 112, 123, .45);
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
--play-hi: linear-gradient(to right, #8ed629 5%, #6aa621 95%);
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--action-hi: linear-gradient(to right, #5fcaff 5%, #2458d6 95%);
color-scheme: dark;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* class display rules would otherwise win */
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
::selection { background: var(--blue); color: #fff; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #3d4450; border-radius: 5px; border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
.brand svg { width: 30px; height: 30px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
nav { display: flex; gap: 4px; height: 100%; }
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
border-bottom: 3px solid transparent; transition: color .15s; }
nav a:hover { color: var(--bright); }
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
.spacer { flex: 1; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
.dot.off { background: var(--bad); }
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b { display: none; } }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
.mobile-only { display: none; }
body.mobile .mobile-only { display: revert; }
body.mobile .desk-only { display: none; }
/* ---- pages: one per tab; the header nav switches between them ---- */
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
@media (max-width: 1150px) { nav kbd { display: none; } }
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
background: rgba(217,65,38,.12); box-shadow: inset 3px 0 0 var(--bad); }
.banner svg { width: 22px; height: 22px; flex: none; color: #ff8a73; }
.banner .grow { flex: 1; min-width: 0; }
.banner .t { color: var(--bright); font-weight: 500; }
.banner .s { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
.dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
.dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
.dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
.dlg label input, .dev-form label input { margin-top: 5px; }
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; }
.facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
.stages { list-style: none; margin: 0; padding: 0; }
.stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); }
.stages li:first-child { border-top: 0; }
.stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; }
.stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); }
.stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; }
.stages .pending .ic { border: 1.5px solid var(--dim); }
.stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; }
.stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); }
.stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; }
.stages .failed .dt { color: #ff8a73; }
.stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
.probes { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; }
.probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; }
.res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; }
.res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); }
.res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); }
.dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; }
@media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } }
.dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; }
.dev-item:hover { background: rgba(255,255,255,.04); }
.dev-item.sel { background: rgba(26,159,255,.12); }
.dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; }
.dev-form input[readonly] { color: var(--muted); }
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
.found { margin-top: 10px; }
/* ---- drop anywhere ---- */
#media select { min-width: 0; max-width: 100%; flex: 1; }
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
background: rgba(26,159,255,.08); color: var(--text); max-width: 520px; }
.dropzone b { display: block; color: var(--bright); font-size: 20px; font-weight: 500; margin-bottom: 6px; }
/* ---- panels and section headers ---- */
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
.shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .spacer { flex: 1; }
.sub { color: var(--muted); font-size: 12.5px; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; }
/* ---- buttons ---- */
button { appearance: none; border: 0; border-radius: 3px; padding: 0 14px; height: 34px; font: inherit; font-size: 14px;
color: var(--text); background: var(--btn); cursor: pointer; white-space: nowrap;
transition: background .12s, color .12s, filter .12s; display: inline-flex; align-items: center; gap: 8px; }
button:hover { background: var(--btn-hi); color: var(--bright); }
button:active { filter: brightness(.9); }
button:disabled { opacity: .45; cursor: default; }
button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
button.action { background: var(--action); color: #fff; font-weight: 500; }
button.action:hover { background: var(--action-hi); }
button.play { background: var(--play); color: #fff; font-weight: 700; letter-spacing: .5px; }
button.play:hover { background: var(--play-hi); }
button.danger:hover { background: rgba(217,65,38,.35); color: #fff; }
button.small { height: 28px; padding: 0 11px; font-size: 13px; }
button.on { background: rgba(26,159,255,.22); color: var(--link); }
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
.seg button.on { background: var(--btn-hi); color: var(--bright); }
input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* ---- headset view ---- */
.viewer { position: relative; aspect-ratio: 16 / 9; max-height: calc(100vh - 190px); margin: 0 auto; background: #000; border-radius: 3px; overflow: hidden;
display: grid; place-items: center; box-shadow: inset 0 0 0 1px rgba(255,255,255,.05); }
/* Fit the whole frame; zoom and pan are a transform on top of that. */
.viewer canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block;
transform-origin: 0 0; cursor: zoom-in; }
.viewer.zoomed canvas { cursor: grab; }
.viewer.panning canvas { cursor: grabbing; }
.viewer:fullscreen { aspect-ratio: auto; border-radius: 0; }
.zoombar { position: absolute; left: 12px; bottom: 12px; display: flex; gap: 2px; padding: 2px; border-radius: 3px;
background: rgba(0,0,0,.6); }
.zoombar button { height: 26px; min-width: 28px; padding: 0 8px; font-size: 13px; background: transparent; justify-content: center; }
.zoombar button:hover { background: rgba(255,255,255,.12); }
.zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; }
.viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; }
.viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; }
.viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase;
background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; }
.viewer .badge.live { left: auto; right: 12px; background: var(--bad); color: #fff; font-weight: 700; }
.view-actions { display: flex; gap: 10px; }
.ctrl-status { margin-top: 10px; font-size: 13px; color: var(--blue); }
.ctrl-status::before { content: "● "; }
.ctrl-status.warn { color: var(--warn); }
/* Control: the view takes taps, clicks, drags and keys, and none of them zoom or pan it. */
.viewer.control { outline: 1px solid var(--blue); }
.viewer.control canvas { cursor: crosshair; touch-action: none; }
.viewer.control.rel canvas { cursor: move; }
.viewer.immersive { position: fixed; z-index: 40; inset: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
max-height: none; aspect-ratio: auto; border-radius: 0; }
.ctrl-keys { margin-top: 10px; }
#panelPick { max-width: 240px; height: 30px; }
.viewer .stamp { position: absolute; right: 12px; bottom: 12px; font-size: 11px; color: #c7d5e0;
background: rgba(0,0,0,.6); padding: 3px 8px; border-radius: 2px; }
.viewer .asleep { position: absolute; inset: auto 0 0 0; padding: 14px 18px; text-align: center; color: var(--text);
background: linear-gradient(to top, rgba(0,0,0,.85), transparent); font-size: 13px; }
.viewer .loading { position: absolute; inset: 0; display: none; place-items: center; background: rgba(0,0,0,.35); }
.viewer.busy .loading { display: grid; }
.spinner { width: 34px; height: 34px; border: 3px solid rgba(255,255,255,.15); border-top-color: var(--blue);
border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
/* ---- device ---- */
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
.battery svg { width: 74px; height: 38px; flex: none; }
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums; }
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top: 6px; }
.battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); }
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted); }
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stat .d { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meter { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; margin-top: 7px; overflow: hidden; }
.meter > i { display: block; width: 0; height: 100%; background: var(--link); transition: width .4s; }
.meter > i.warn { background: var(--warn); } .meter > i.bad { background: var(--bad); }
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 14px; }
.chips .chip { height: 26px; font-size: 12px; background: rgba(0,0,0,.22); }
.vol { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.vol input[type=range] { flex: 1; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px;
background: linear-gradient(to right, var(--blue) var(--fill, 0%), rgba(255,255,255,.12) var(--fill, 0%)); }
.vol input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ---- Steam screenshots from the headset ---- */
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.shot-card { display: flex; flex-direction: column; gap: 6px; }
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
.shot-card .row { flex-wrap: nowrap; }
.shot-card .grow { flex: 1; min-width: 0; }
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38;
box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); }
.ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; }
.ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; }
.ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
box-shadow: 0 6px 16px rgba(0,0,0,.45); transition: transform .18s ease, box-shadow .18s ease; }
.capsule:hover, .capsule:focus-within { transform: translateY(-4px) scale(1.02); box-shadow: 0 14px 30px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.12); }
.capsule .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 12px; text-align: center;
font-weight: 500; color: var(--text); background: linear-gradient(160deg, #3d4450, #1e2329); }
.capsule .over { position: absolute; inset: auto 0 0 0; padding: 40px 10px 10px; display: flex; flex-direction: column; gap: 8px;
background: linear-gradient(to top, rgba(0,0,0,.92) 20%, transparent); opacity: 0; transition: opacity .18s; }
.capsule:hover .over, .capsule:focus-within .over { opacity: 1; }
.capsule .name { font-weight: 700; font-size: 13px; color: #fff; line-height: 1.25; }
.capsule .size { color: var(--muted); font-size: 11.5px; }
.capsule button { justify-content: center; }
/* ---- transfer / apps / power ---- */
.drop { border: 2px dashed rgba(143,152,160,.35); border-radius: 3px; padding: 24px 16px; text-align: center;
color: var(--muted); cursor: pointer; transition: border-color .15s, background .15s; }
.drop:hover { border-color: rgba(143,152,160,.6); }
.drop.over { border-color: var(--blue); background: rgba(26,159,255,.08); color: var(--text); }
.drop b { display: block; color: var(--bright); font-size: 15px; font-weight: 500; margin-bottom: 4px; }
.progress { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; margin-top: 12px; overflow: hidden; display: none; }
.progress > i { display: block; height: 100%; width: 0; background: var(--action); transition: width .2s; }
.list .item { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid rgba(255,255,255,.06); }
.list .item:first-child { border-top: 0; padding-top: 0; }
.item .grow { flex: 1; min-width: 0; }
.item .t { color: var(--bright); font-size: 14px; }
.item .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag { font-size: 10.5px; letter-spacing: 1px; text-transform: uppercase; padding: 3px 7px; border-radius: 2px;
background: rgba(0,0,0,.3); color: var(--muted); }
.actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.actions button { justify-content: flex-start; height: 40px; }
.actions svg { width: 16px; height: 16px; flex: none; opacity: .85; }
.notice { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-radius: 4px;
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
.notice .grow { flex: 1; min-width: 260px; }
.notice .progress { width: 160px; margin-top: 0; display: block; }
.contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; }
.contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; }
.contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); }
#contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; }
#cEmail { max-width: 420px; }
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
.popt b { font-weight: 600; color: var(--text); }
.popt .sub { grid-column: 2; line-height: 1.45; }
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
details summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 12px; }
.links { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 13px; color: var(--muted); }
.links a { color: var(--link); text-decoration: none; } .links a:hover { color: #fff; }
.links div { margin: 5px 0; }
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
.bottombar .last.e { color: #ff8a73; }
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
.drawer.open { max-height: 300px; }
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
.toast.show { opacity: 1; transform: none; }
.toast.err { border-left-color: var(--bad); }
/* ---- Android ---- */
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
#bugDlg { width: min(640px, calc(100vw - 40px)); }
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
#bugForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
#bugForm .popt { margin: 14px 0 0; }
#bugForm .sentlog { max-height: 200px; }
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
.about-text { max-height: 55vh; overflow: auto; }
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
.about-text summary { cursor: pointer; margin: 8px 0; }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
#wiFacts dt { color: var(--muted); }
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
#wiProg:not([hidden]) { display: block; }
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
#repForm input:invalid:not(:placeholder-shown) { border-color: var(--bad); }
.rep-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
.rep-file { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.rep-actions { margin-top: 16px; }
.and-item { flex-wrap: wrap; }
.and-btns { flex-basis: 100%; padding-left: 46px; }
.and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; }
.cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.cat-tools input[type=text], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; }
.cat-tools select, .source-offer select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
.vchip[aria-pressed=true] { opacity: 1; box-shadow: inset 0 0 0 1px currentColor; }
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.cat-card { background: rgba(0,0,0,.2); border-radius: 3px; padding: 10px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.cat-card .top { display: flex; gap: 10px; align-items: flex-start; }
.cat-card .t { color: var(--bright); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
.cat-card .s { color: var(--muted); font-size: 12px; }
.cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; }
.cat-card .row { margin-top: auto; }
.v-works, .v-likely { color: var(--green-hi); } .v-maybe { color: var(--warn); }
.v-unlikely, .v-no { color: var(--bad); }
.vbadge { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; margin-left: auto; }
@media (max-width: 1100px) { .and-grid { grid-template-columns: 1fr; } }
/* ---- Steam games (owned + store) ---- */
.game-card .art { width: 100%; aspect-ratio: 231 / 87; border-radius: 2px; object-fit: cover; background: rgba(0,0,0,.25); display: block; }
.game-card .price { color: var(--bright); font-size: 13px; }
.game-card .price s { color: var(--dim); margin-right: 6px; }
.cat-card .row a { color: var(--link); text-decoration: none; font-size: 13px; padding: 0 4px; }
.cat-card .row a:hover { color: #fff; }
.game-card .bar { height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; }
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); }
/* ---- Android display ---- */
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
.mv-windows { max-height: 420px; overflow-y: auto; }
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
.mv-perm .row { margin-top: 8px; }
.mv-live { color: var(--green, #7fd67f); font-size: 12px; }
.mv-opt { display: block; font-size: 13px; margin: 0 0 12px; }
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp select:disabled { opacity: .6; }
.disp .stat .v { font-size: 15px; }
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
.disp .seg { flex-wrap: wrap; }
.disp .seg button { padding: 0 10px; }
.disp input[type=number], #comfort input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
/* Touch screens can't hover: keep the library's name and Play button showing. */
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
/* ---- keyboard and trackpad ---- */
.pad-area { position: relative; height: 190px; margin: 12px 0; border-radius: 4px; background: rgba(0,0,0,.28);
border: 1px dashed rgba(255,255,255,.14); display: grid; place-items: center; cursor: pointer;
touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none; }
.pad-area:focus-visible, .pad-area.captured { border: 1px solid var(--blue); box-shadow: 0 0 0 1px var(--blue) inset; }
.pad-area.off { cursor: default; opacity: .55; }
.pad-hint { max-width: 420px; padding: 0 16px; text-align: center; color: var(--muted); font-size: 13px; pointer-events: none; }
.pad-keys { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.pad-keys input { flex: 1 1 220px; min-width: 0; height: 32px; padding: 0 10px; border-radius: 3px; border: 1px solid transparent;
background: rgba(0,0,0,.28); color: var(--text); font: inherit; }
.pad-keys input:focus { outline: none; border-color: var(--blue); }
#pad .hint { margin-top: 10px; }
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
/* No blur here: it would make the header the containing block of the fixed tab bar. */
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
header .chip { height: 30px; padding: 0 10px; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
nav a.on { border-bottom: 0; }
nav svg { display: block; width: 23px; height: 23px; }
.bottombar, .drawer { display: none; }
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
main, .page { gap: 16px; }
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
.panel { padding: 14px; }
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
.toolbar { gap: 8px; }
.toolbar .spacer { display: none; }
.view-actions { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
.view-actions button { justify-content: center; padding: 0 4px; }
#panelPick { flex: 1 1 100%; max-width: none; }
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
.cat-grid { grid-template-columns: 1fr; }
.shot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.and-btns { padding-left: 0; }
.banner { flex-wrap: wrap; }
.banner .s { white-space: normal; }
button { height: 38px; }
button.small { height: 32px; }
.actions button { height: 46px; }
.cat-tools select, .source-offer select { max-width: none; flex: 1 1 100%; }
.pad-area { height: 240px; }
.pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); }
.pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); }
.pad-keys .seg button { padding: 0 4px; justify-content: center; text-align: center; }
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
body.typing nav { display: none; }
}
/* ---- Discover: artwork first, details when you need them ---- */
.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }
#androidLibrary { grid-template-columns:1fr; }
#androidLibrary .and-col { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); align-items:start; }
.store-tabs { display:flex; align-items:center; gap:8px; margin-bottom:20px; border-bottom:1px solid #ffffff12; padding-bottom:14px; }
.store-tabs > button { background:transparent; color:var(--muted); border-radius:6px; }
.store-tabs > button.on { color:var(--bright); background:#ffffff0e; }
.store-heading h1 { font-size:clamp(28px,3.2vw,38px); letter-spacing:-1.2px; line-height:1.15; margin:8px 0 10px; font-weight:700; color:var(--bright); }
.store-heading p, .sources-intro p { color:#a8b5c2; margin:0 0 24px; font-size:15px; }
.store-eyebrow { color:var(--link); font-size:11px; text-transform:uppercase; font-weight:700; letter-spacing:1.8px; }
.store-toolbar { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,400px); align-items:center; gap:32px; }
.store-toolbar .store-heading p { margin-bottom:0; }
@media(max-width:900px) { .store-toolbar { display:block; } .store-toolbar .store-heading p { margin-bottom:20px; } }
.store-search { display:flex; align-items:center; gap:12px; border:1px solid #ffffff12; background:#0e1723a6; border-radius:8px; padding:6px 8px 6px 16px; }
.store-search svg { width:21px; height:21px; color:var(--muted); flex:none; }
.store-search input[type=search] { background:transparent; padding:9px 0; font-size:15px; border:0; box-shadow:none; min-width:0; }
.store-search:focus-within { border-color:var(--link); }
.store-search button { border-radius:5px; }
.store-filters { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:16px 0 24px; }
.store-chips { display:flex; flex-wrap:wrap; gap:7px; }
.store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; }
.store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; }
.store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; }
.store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; }
body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); }
body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; }
.store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; }
.store-result-count:empty { display:none; }
.store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; }
.store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); }
.store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
.art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; }
.store-hero > .store-art { position:absolute; inset:0; }
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
.hero-copy .store-eyebrow { color:#c2e7ff; }
.hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; }
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; }
.hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; }
.store-row { margin-bottom:34px; }
.store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
.store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; }
.store-row-head p { color:var(--muted); font-size:12px; margin:0; }
.store-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(225px,1fr)); gap:18px; }
.store-card { display:block; width:100%; height:auto; min-width:0; padding:0; border:1px solid #ffffff0b; border-radius:9px; overflow:hidden; text-align:left; background:#1b2938; color:var(--text); transition:transform .18s,box-shadow .18s; font-weight:400; }
.store-card:hover { transform:translateY(-4px); background:#223448; box-shadow:0 12px 28px #0004; }
.store-card .store-art { aspect-ratio:16/9; width:100%; }
.store-card-content { padding:15px 14px; display:block; }
.store-card-title { display:flex; align-items:center; gap:9px; margin-bottom:9px; min-width:0; }
.store-icon { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; overflow:hidden; flex:none; background:hsl(var(--hue) 35% 28%); color:#fff; font-size:17px; position:relative; }
.store-icon img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
.store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; }
.store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; }
.store-card-foot b { color:#dbeecf; font-weight:500; }
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
.store-empty p { max-width:390px; color:var(--muted); margin:0 0 22px; }
.store-skeleton { aspect-ratio:1.2; background:linear-gradient(110deg,#233446 30%,#30455b 45%,#233446 60%); background-size:250% 100%; border-radius:9px; animation:store-shimmer 1.8s ease infinite; }
@keyframes store-shimmer { to { background-position:-150% 0; } }
.store-dialog { padding:0; border:1px solid #ffffff1a; border-radius:14px; background:#172331; color:var(--text); width:min(980px,calc(100vw - 40px)); max-width:none; max-height:calc(100dvh - 48px); overflow:auto; box-shadow:0 30px 100px #0009; }
.store-dialog::backdrop { background:#030a13c9; backdrop-filter:blur(7px); }
.store-close { position:sticky; float:right; top:14px; margin:14px 14px -54px 0; z-index:6; height:36px; width:36px; padding:0; border-radius:50%; background:#09131dcc; color:white; font-size:25px; line-height:1; border:1px solid #ffffff30; }
.detail-banner { height:195px; }
.detail-banner::after { content:""; position:absolute; inset:0; z-index:2; background:linear-gradient(0deg,#172331,transparent 70%); }
.detail-content { padding:0 30px 30px; position:relative; }
.detail-heading { display:flex; gap:16px; align-items:center; margin-top:-24px; position:relative; z-index:3; }
.detail-heading .store-icon { width:70px; height:70px; border-radius:15px; box-shadow:0 3px 18px #0004; font-size:32px; }
.detail-heading h1 { font-size:30px; color:white; letter-spacing:-.7px; margin:0 0 4px; }
.detail-heading p { margin:0; color:#a8b5c2; font-size:13px; }
.detail-content > .store-badges { margin:20px 0; }
.detail-layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:30px; margin-top:22px; }
.detail-layout h2 { text-transform:none; letter-spacing:0; font-size:17px; color:white; margin:0 0 14px; }
.detail-description { color:#b5c2ce; font-size:14px; line-height:1.75; white-space:pre-line; }
.detail-gallery { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x mandatory; margin:0 0 10px; }
.detail-gallery .store-art { flex:0 0 86%; aspect-ratio:16/9; border-radius:7px; scroll-snap-align:start; }
.detail-gallery .store-art img { object-fit:contain; background:#0b1420; }
.gallery-controls { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; font-size:11px; color:var(--muted); }
.gallery-controls button { height:30px; width:34px; padding:0; border-radius:5px; }
.detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; }
.detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; }
.detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; }
.detail-verdict.blocked { color:#e3b77a; }
.detail-verdict.unknown { color:#adbac7; }
.detail-price { font-size:23px; color:white; font-weight:600; }
.detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; }
.offer-choice { display:flex; align-items:flex-start; gap:9px; padding:11px 9px; margin-top:8px; border:1px solid #ffffff12; border-radius:7px; cursor:pointer; }
.offer-choice:has(input:checked) { background:#1e3c52; border-color:#5386a6; }
.offer-choice input { margin:4px 0 0; accent-color:var(--blue); }
.offer-choice strong { font-size:12px; display:block; color:var(--bright); }
.offer-choice small { display:block; font-size:10px; color:#a8bbc9; margin-top:4px; }
.offer-mark { display:inline-flex; justify-content:center; align-items:center; height:26px; width:26px; flex:none; border-radius:6px; background:hsl(var(--hue) 30% 30%); color:white; font-size:11px; }
.detail-technical { border-top:1px solid #ffffff14; margin-top:25px; padding-top:16px; font-size:12px; color:var(--muted); }
.detail-technical summary { cursor:pointer; }
.detail-technical dl { display:grid; grid-template-columns:110px minmax(0,1fr); gap:6px; }
.detail-technical dd { margin:0; overflow-wrap:anywhere; }
.detail-buy .store-primary:disabled { opacity:1; color:#d6e8f7; background:#274f71; cursor:default; }
#detailProgress::-webkit-progress-bar { background:#283b4d; border-radius:3px; }
#detailProgress::-webkit-progress-value { background:var(--blue); border-radius:3px; }
#detailProgress { height:4px; width:100%; accent-color:var(--blue); display:block; margin-bottom:10px; }
.sources-dialog { width:min(560px,calc(100vw - 32px)); padding:30px; }
.sources-dialog .store-close { margin:-16px -16px -20px 0; top:0; }
.sources-intro h1 { font-size:28px; color:white; letter-spacing:-.7px; margin:9px 0; }
.source-setting { display:flex; align-items:center; gap:13px; padding:17px 0; border-top:1px solid #ffffff0c; }
.source-setting .offer-mark { width:42px; height:42px; font-size:16px; border-radius:10px; }
.source-setting .grow { flex:1; min-width:0; }
.store-dialog a { color:var(--link); }
.source-setting strong { display:block; font-size:14px; color:white; }
.source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; }
.source-setting small { color:var(--muted); font-size:10px; }
.source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; }
.source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; }
.source-toggle:checked { background:#208dca; }
.source-toggle:checked::after { transform:translateX(15px); }
.source-repo-form { display:grid; gap:12px; border-top:1px solid #ffffff14; margin-top:10px; padding-top:24px; }
.source-repo-form h2 { font-size:19px; letter-spacing:0; text-transform:none; color:white; margin:0; }
.source-repo-form p { color:var(--muted); font-size:12px; margin:0; }
.source-repo-form label { font-size:12px; }
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
.source-repo-form details { font-size:11px; color:var(--muted); }
.source-repo-form button { justify-self:start; border-radius:6px; }
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
.store-search input:focus-visible { outline:0!important; }
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
@media(max-width:1000px) { .store-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } .detail-layout { grid-template-columns:minmax(0,1fr) 260px; gap:20px; } }
@media(max-width:680px) {
.store-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.store-tabs { margin-bottom:24px; gap:2px; } .store-tabs button { padding:0 10px; font-size:12px; }
.store-heading p { font-size:13px; max-width:320px; }
.store-filters { align-items:flex-start; flex-direction:column; gap:10px; margin-bottom:18px; }
.store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; }
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; }
.store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
.store-dialog { width:calc(100vw - 16px); max-height:calc(100dvh - 20px); border-radius:10px; }
.detail-banner { height:190px; } .detail-content { padding:0 18px 24px; } .detail-heading h1 { font-size:24px; }
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
}
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
</style>
</head>
<body>
<header>
<a class="brand" href="#home" aria-label="Frame Control">
<svg viewBox="0 0 32 32" aria-hidden="true">
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1a9fff"/><stop offset="1" stop-color="#1a44c2"/></linearGradient></defs>
<circle cx="16" cy="16" r="15" fill="url(#lg)"/>
<rect x="6.5" y="11" width="19" height="10" rx="4" fill="none" stroke="#fff" stroke-width="2"/>
<circle cx="12" cy="16" r="1.8" fill="#fff"/><circle cx="20" cy="16" r="1.8" fill="#fff"/>
</svg>
<b>FRAME CONTROL</b>
</a>
<nav id="nav">
<a href="#home" class="on" title="Headset view, status and screenshots (1)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="4"/><circle cx="8.5" cy="12" r="1.6"/><circle cx="15.5" cy="12" r="1.6"/></svg>Home<kbd>1</kbd></a>
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
</nav>
<div class="spacer"></div>
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
<span class="chip" id="battChip" title="Battery">—</span>
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
</button>
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
</button>
</header>
<main>
<div class="notice" id="updateBar" hidden>
<div class="grow" id="updateText"></div>
<div class="progress" id="updateProg" hidden><i></i></div>
<button class="small" id="updateNotes">What's new</button>
<button class="action small" id="updateGo">Update and restart</button>
<button class="small" id="updateLater">Later</button>
</div>
<div class="notice" id="privacyNotice" hidden>
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
worked and the details of errors, which helps fix problems faster.</div>
<button class="small" id="noticeSettings">Privacy settings</button>
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
<button class="action small" id="noticeOk">OK</button>
</div>
<form class="notice" id="contactNotice" hidden>
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
you can change or remove it any time in Privacy &amp; updates.
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
<div class="contact-opts">
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
<span class="sub" id="cpMsg" role="status"></span>
<button type="button" class="small" id="cpNo">No thanks</button>
<button type="submit" class="action small" id="cpSave">Save</button>
</form>
<div class="banner" id="offline" role="alert" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
<button class="small desk-only" id="offDetails" hidden>Details</button>
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
<button class="action small" id="offRetry">Retry now</button>
</div>
<div class="page on" data-page="home">
<div class="grid-top" id="view">
<section class="panel">
<div class="toolbar">
<div class="seg" aria-label="What to capture">
<button data-view="headset" class="on" title="What the headset shows, via the SteamVR compositor">Headset view</button>
<button data-view="flat" title="The app panel in use, flat: it stays still however the wearer looks around">Desktop</button>
</div>
<select id="panelPick" class="small" hidden aria-label="Which panel to show"></select>
<div class="seg" id="eyeSeg" aria-label="Crop">
<button data-eye="left" class="on" title="One eye, like pointing a camera into one lens">Left eye</button>
<button data-eye="both" title="Both eyes side by side">Both eyes</button>
</div>
<div class="spacer"></div>
<span class="view-actions">
<button class="action" id="shotBtn">Capture</button>
<button id="liveBtn" title="Keep updating, as video">Live</button>
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
<button id="saveBtn" disabled>Save</button>
</span>
</div>
<div class="viewer" id="viewer">
<div class="empty" id="viewerEmpty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="2" y="7" width="20" height="10" rx="4"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="12" r="2"/></svg>
<div>Press <b>Capture</b> to grab what the Frame is showing, or <b>Live</b> to keep it updating.</div>
</div>
<canvas id="canvas" hidden></canvas>
<span class="badge" id="srcBadge" hidden></span>
<span class="badge live" id="liveBadge" hidden>Live</span>
<span class="stamp" id="stamp" hidden></span>
<div class="zoombar" id="zoombar" hidden>
<button id="zoomOut" title="Zoom out (−)" aria-label="Zoom out">−</button>
<button id="zoomFit" class="lvl" title="Fit the whole frame (0)">Fit</button>
<button id="zoomIn" title="Zoom in (+)" aria-label="Zoom in">+</button>
<button id="fullBtn" title="Full screen (F)" aria-label="Full screen">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6"/></svg>
</button>
</div>
<div class="asleep" id="asleep" hidden>The headset is showing a blank frame. It's probably asleep or not being worn. Put it on and capture again.</div>
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="ctrl-status" id="ctrlBadge" role="status" hidden></div>
<div class="pad-keys ctrl-keys" id="ctrlKeys" hidden aria-label="Keys and clicks for the Frame">
<input id="ctrlType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
autocorrect="off" spellcheck="false" enterkeyhint="send">
<span class="seg pad-clicks">
<button data-ctrl-click="left" title="Left click where the pointer is">Click</button>
<button data-ctrl-click="right" title="Right click where the pointer is">Right-click</button>
</span>
<span class="seg pad-special">
<button data-ctrl-key="1" title="Escape">Esc</button>
<button data-ctrl-key="15" title="Tab">Tab</button>
<button data-ctrl-key="105" title="Left arrow" aria-label="Left arrow">←</button>
<button data-ctrl-key="103" title="Up arrow" aria-label="Up arrow">↑</button>
<button data-ctrl-key="108" title="Down arrow" aria-label="Down arrow">↓</button>
<button data-ctrl-key="106" title="Right arrow" aria-label="Right arrow">→</button>
<button data-ctrl-key="14" title="Backspace" aria-label="Backspace">⌫</button>
<button data-ctrl-key="28" title="Enter">Enter</button>
</span>
</div>
<div class="hint" id="viewHint"></div>
</section>
<section class="panel" id="device">
<div class="shelf-head"><h2>Device</h2><span class="count" id="devName"></span><span class="spacer"></span><span class="sub" id="updated"></span></div>
<div class="battery">
<svg viewBox="0 0 74 38" aria-hidden="true">
<rect x="1.5" y="1.5" width="64" height="35" rx="5" fill="none" stroke="#8f98a0" stroke-width="3"/>
<rect x="67" y="12" width="5.5" height="14" rx="2" fill="#8f98a0"/>
<rect id="battFill" x="6" y="6" width="0" height="26" rx="2.5" fill="#59bf40"/>
<path id="battBolt" d="M37 5 L27 21 H34 L31 33 L42 16 H35 Z" fill="#fff" stroke="#1e2329" stroke-width="1.6" stroke-linejoin="round" visibility="hidden"/>
</svg>
<div style="min-width:0">
<div class="pct" id="battPct">—</div>
<div class="state draining" id="battState">&nbsp;</div>
<div class="detail" id="battDetail">&nbsp;</div>
</div>
</div>
<div class="stats">
<div class="stat"><div class="k">Storage free</div><div class="v" id="sDisk">—</div><div class="meter"><i id="mDisk"></i></div></div>
<div class="stat"><div class="k">Memory used</div><div class="v" id="sMem">—</div><div class="meter"><i id="mMem"></i></div></div>
<div class="stat"><div class="k">Temperature</div><div class="v" id="sTemp">—</div><div class="d" id="dTemp">&nbsp;</div></div>
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi">&nbsp;</div></div>
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp">&nbsp;</div></div>
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs">&nbsp;</div></div>
</div>
<div class="chips" id="services"></div>
<div class="vol">
<button class="small" id="muteBtn" aria-label="Mute">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path id="volWaves" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
</button>
<input type="range" id="vol" min="0" max="100" step="1" value="0" aria-label="Volume">
<span class="num" id="volNum">—</span>
</div>
</section>
</div>
<section class="panel" id="vrTools">
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
<div class="stats" id="vrMetrics"></div>
<div class="hint">Compositor FPS measures SteamVR output, not game FPS. Application FPS is unavailable when no app supplies timing. GPU time is render time, not GPU utilisation. Temperature is the hottest readable sensor.</div>
<div class="chips">
<button class="small" id="hudStart">Open HUD in headset</button>
<button class="small" id="hudStop">Close HUD</button>
</div>
<div class="hint">The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.</div>
<h3>Playspace and seated comfort</h3>
<p class="sub" id="comfortNote">Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.</p>
<div class="hint">For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.</div>
<details style="margin-top:16px"><summary>Optional VR software</summary>
<p class="sub">None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.</p>
<button class="small" id="vrUtilitiesRefresh">Check ownership and reports</button>
<div class="list" id="vrUtilities"><div class="sub">Load this optional list to check the Frame account.</div></div>
</details>
</section>
<section class="panel" id="comfort" style="margin:20px 0;padding:16px">
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
<button class="action small" id="castBtn">Cast headset view</button>
</div>
<p class="hint">Share this screen with people in the room. Casting shows everything the wearer sees, including private content.</p>
<form id="comfortForm">
<div class="row" style="flex-wrap:wrap;gap:12px">
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
<label>Break every <input id="breakMinutes" type="number" min="0" max="120" value="20" required style="width:75px"> minutes</label>
<label>Check in after <input id="stillMinutes" type="number" min="0" max="240" value="30" required style="width:75px"> active minutes</label>
</div>
<div class="row" style="flex-wrap:wrap;margin:14px 0;gap:14px">
<label><input id="batteryAlert" type="checkbox" checked> Alert on low battery</label>
<label><input id="heatAlert" type="checkbox" checked> Alert on overheating</label>
<button class="action small" id="sessionStart" type="submit">Start session</button>
<button class="small" id="sessionCancel" type="button" disabled>Cancel session</button>
<button class="small" id="testNotification" type="button">Enable / test notifications</button>
</div>
</form>
<p id="comfortStatus" role="status">Checking session…</p>
<p id="comfortNotificationStatus" class="hint" role="status" hidden></p>
<p class="hint">A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.</p>
<details class="hint"><summary>How sessions and alerts work</summary>
<p>This is a reminder, not a parental lock. The timer continues on the Frame if you disconnect; a headset restart cancels it. Cancel before changing settings. Set break/check-in to 0 to turn them off.</p>
<p>Battery, heat and check-in alerts appear on connected companions during a session. Headset warnings and break reminders continue without a companion. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.</p>
</details>
<div id="comfortEvents" class="hint"></div>
<span id="comfortAnnouncement" class="sr-only" aria-live="polite"></span>
</section>
<section class="panel" id="pad">
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
<button class="small" id="padOn">Turn on</button>
</div>
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
<div class="pad-hint" id="padHint"></div>
</div>
<div class="pad-keys" aria-label="Keys and clicks">
<input id="padType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
autocorrect="off" spellcheck="false" enterkeyhint="send">
<span class="seg pad-clicks">
<button data-pad-click="singleclick" title="Left click">Click</button>
<button data-pad-click="rightclick" title="Right click">Right-click</button>
</span>
<span class="seg pad-special">
<button data-pad-key="14" title="Escape">Esc</button>
<button data-pad-key="2" title="Tab">Tab</button>
<button data-pad-key="4" title="Left arrow" aria-label="Left arrow">←</button>
<button data-pad-key="5" title="Up arrow" aria-label="Up arrow">↑</button>
<button data-pad-key="7" title="Down arrow" aria-label="Down arrow">↓</button>
<button data-pad-key="6" title="Right arrow" aria-label="Right arrow">→</button>
<button data-pad-key="1" title="Backspace" aria-label="Backspace">⌫</button>
<button data-pad-key="12" title="Enter">Enter</button>
</span>
</div>
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
else to install. <a href="#" data-about>Licences</a></div>
</section>
<section id="shots">
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
<button class="small" id="shotsRefresh">Refresh</button>
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
</div>
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
</section>
</div>
<div class="page" data-page="games">
<section id="library">
<div class="shelf-head"><h2>Installed</h2><span class="count" id="gameCount"></span></div>
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
</section>
<section class="panel" id="sideloaded">
<div class="shelf-head"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span><span class="spacer"></span>
<button class="small" id="titleAdd" title="Pick a game's .zip or .exe to add to the Steam library">+ Add a game…</button></div>
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
<div class="hint">Your own Linux or Windows builds, added to the Steam library as Devkit Games. Drop a game's
<code>.zip</code>, folder or <code>.exe</code> anywhere in this window.</div>
</section>
<section id="getgames">
<div class="shelf-head"><h2>Get games</h2><span class="count" id="gmCount"></span><span class="spacer"></span>
<span class="sub" id="gmDl"></span></div>
<div class="panel">
<div class="cat-tools">
<div class="seg" id="gmSrc" aria-label="Where to look">
<button data-src="owned" class="on">Your games</button>
<button data-src="store">Steam store</button>
</div>
<input type="text" id="gmQ" placeholder="Filter your games…">
<button class="small" id="gmReload" title="Read the library again, e.g. after buying something">Refresh</button>
</div>
<div class="vchips" id="gmChips"></div>
<div class="cat-grid" id="gmGrid"><div class="sub">Loading your library…</div></div>
<div class="row" style="margin-top:12px"><button class="small" id="gmMore" hidden>Show more</button></div>
<div class="hint">Install downloads a game you own straight to the Frame and accepts Steam's default options;
EULAs and license prompts still need answering in the headset. To buy, open the store page in your browser
(signed in to Steam) or in the headset, then Refresh and install. Ratings are Valve's Steam Frame
compatibility categories.</div>
</div>
</section>
</div>
<div class="page" data-page="android">
<section id="android">
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<section id="appStore" aria-label="Discover apps">
<div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
<div id="searchGrid" aria-busy="false"></div>
</section>
<div class="and-grid" id="androidLibrary" hidden>
<div class="and-col">
<div class="panel">
<div class="shelf-head"><h2>On the Frame</h2></div>
<div class="list" id="andApps"><div class="sub">Loading…</div></div>
<div class="hint">Each app runs in its own Lepton instance and keeps its data. It's in the Steam
library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.</div>
</div>
<section class="panel disp" id="display">
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
<select id="dispSel" aria-label="Lepton instance"><option>Loading…</option></select>
<div class="hint" id="dispEmpty" hidden>Open an Android app in the headset, then Refresh, to change its
resolution, UI scale and text size here.</div>
<div id="dispCtl">
<div class="stats">
<div class="stat"><div class="k">Resolution</div><div class="v" id="dispSize">—</div><div class="d" id="dispSizeD">&nbsp;</div></div>
<div class="stat"><div class="k">UI scale</div><div class="v" id="dispDens">—</div><div class="d" id="dispDensD">&nbsp;</div></div>
<div class="stat"><div class="k">Text size</div><div class="v" id="dispFont">—</div><div class="d" id="dispFontD">&nbsp;</div></div>
</div>
<div class="k2">Resolution</div>
<div class="seg" id="dispRes" aria-label="Resolution">
<button data-res="native" title="1920×1080, Lepton's panel size">Native 1920×1080</button>
<button data-res="sharp" title="More Android pixels in the same panel; UI scale is raised to match">Sharp 2560×1440</button>
</div>
<div class="k2">UI scale</div>
<div class="row">
<div class="seg" id="dispScale" aria-label="UI scale">
<button data-scale="0.85">Smaller</button><button data-scale="1">Default</button><button data-scale="1.15">Larger</button>
</div>
<input type="number" id="dispDpi" min="120" max="640" step="1" aria-label="Density in dpi" title="Density (dpi), 120–640">
<button class="small" id="dispDpiSet">Set dpi</button>
</div>
<div class="k2">Text size</div>
<div class="seg" id="dispText" aria-label="Text size">
<button data-font="0.85">0.85×</button><button data-font="1">1.0×</button><button data-font="1.15">1.15×</button><button data-font="1.3">1.3×</button>
</div>
<div class="row" style="margin-top:14px"><button class="small danger" id="dispReset" title="wm size reset, wm density reset, and clear font_scale">Reset all</button></div>
<div class="hint">Changes apply at once, and the app may redraw or restart its screen. They may not survive the app
relaunching (not yet verified). Sharp gives Android more pixels in the same panel, which should make text crisper.</div>
</div>
</section>
<div class="panel">
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
<div class="list" id="repList"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:10px"><button class="small" id="repMore" hidden>Show all</button></div>
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
reported, including ones not on F-Droid.</div>
</div>
</div>
<div hidden>
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
<div class="cat-tools">
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
</div>
<div class="vchips" id="catChips"></div>
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div></div>
</div>
</div>
</section>
</div>
<div class="page" data-page="tools">
<section class="panel"><h2>Assistant and AI agents</h2><p>Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.</p><a href="/assistant">Open assistant</a></section>
<div class="grid-3">
<section class="panel" id="transfer">
<div class="shelf-head"><h2>Send to Frame</h2></div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
<b><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></b>
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
<span class="desk-only">You can also drop files anywhere in this window.</span>
<input type="file" id="fileInput" multiple hidden>
</div>
<label class="sub" for="apkDisplay">Android display</label>
<select id="apkDisplay" aria-label="Android app display mode">
<option value="auto">Automatic (detect VR)</option>
<option value="flat">Flat screen</option>
<option value="vr">VR app</option>
</select>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
<button class="small" id="clipMac">Send this computer's clipboard</button>
</div>
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
</section>
<section class="panel" id="media">
<div class="shelf-head"><h2>Media in the headset</h2><span class="count">Preview</span></div>
<p class="sub">Play your movies and stereo photos with Frame Control's own player.</p>
<div class="row"><button class="small" id="mediaChoose">Send media…</button>
<button class="small" id="mediaRefresh">Refresh</button></div>
<input type="file" id="mediaInput" accept=".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden>
<div class="row" style="margin-top:12px"><label for="mediaFile">On the Frame</label>
<select id="mediaFile"><option value="">Refresh to load your media</option></select></div>
<div class="row" style="margin-top:8px"><label for="mediaLayout">Layout</label>
<select id="mediaLayout">
<option value="auto">Detect from name or metadata</option><option value="mono">2D</option>
<option value="sbs">Half SBS · left / right</option><option value="ou">Half OU · top / bottom</option>
<option value="full-sbs">Full SBS · left / right</option><option value="full-ou">Full OU · top / bottom</option>
</select></div>
<div class="row" style="margin-top:8px"><label><input type="checkbox" id="mediaTheatre" checked>
Theatre · bigger screen, dark surround</label></div>
<div class="row" style="margin-top:12px"><button class="action small" id="mediaPlay">Play</button>
<button class="small" id="mediaStop">Stop</button></div>
<p class="sub" id="mediaState" role="status" aria-live="polite">Choose a file to begin.</p>
<div class="hint">H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
Stop works from here; playback also ends after four hours.</div>
</section>
<section class="panel" id="apps">
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
<button class="action" id="fpInstall">Install</button>
</div>
<div class="row" style="margin-top:8px" id="fpQuick"></div>
</section>
<section class="panel" id="artworkSettings">
<div class="shelf-head"><h2>Library artwork settings</h2></div>
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
<label for="steamGridKey">SteamGridDB API key</label>
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
<div class="row" style="margin-top:8px">
<button id="saveSteamGridKey" class="action small">Save key</button>
<button id="clearSteamGridKey" class="small">Remove saved key</button>
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
</div>
<div id="steamGridStatus" class="hint" role="status"></div>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
</section>
<section class="panel" id="power">
<div class="shelf-head"><h2>Remote &amp; power</h2></div>
<div class="actions">
<button data-open="terminal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-5-6-5M12 19h8"/></svg>SSH</button>
<button data-open="sftp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4"/></svg>SFTP</button>
<button data-open="steamlink"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5l12 7-12 7z"/></svg>Steam Link</button>
<button data-open="rdp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg>Remote desktop</button>
<button data-open="suspend" data-confirm="Put the Frame to sleep?"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5z"/></svg>Sleep</button>
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
</div>
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
<div class="row" style="margin-top:14px"><button class="small" data-about>About and licences</button></div>
<div class="links desk-only">
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
</div>
</section>
<section class="panel" id="panels">
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
<button class="small" id="panelsRefresh">Refresh</button>
<button class="small action" id="panelsOpen">Open in headset</button></div>
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
Saved spatial layouts are not available yet.</div>
</section>
<section class="panel" id="macview" hidden>
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
<select id="mvQuality" title="Picture quality and bandwidth">
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
</select>
<button class="small" id="mvRefresh">Refresh</button></div>
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
<div class="mv-perm" id="mvPerm" hidden></div>
<div class="list" id="mvDisplays"></div>
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
<div class="list mv-windows" id="mvWindows"><div class="sub">Loading…</div></div>
<div class="row" style="margin-top:12px">
<button class="small" data-mv="show" data-src="test" data-title="Test pattern">Test pattern</button>
<button class="small danger" data-mv="stopall">Stop all</button>
</div>
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.</div>
</section>
<section class="panel" id="privacy">
<div class="shelf-head"><h2>Privacy &amp; updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
worked (with an error category, never the message). F-Droid package names only.</span></label>
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
compatibility database, so the verdicts get better for everyone.</span></label>
<label class="popt"><input type="checkbox" id="tDiag"><b>Send error details</b>
<span class="sub">Error messages and where in Frame Control they happened, with your home
folder, user name, addresses and keys removed.</span></label>
<div class="hint" id="tStatus"></div>
<h3 style="margin-top:22px">Contact email (optional)</h3>
<form id="contactForm">
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
<div class="contact-opts">
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
<button type="button" class="small" id="cRemove">Remove my email</button></div>
</form>
<div class="hint" id="cStatus">Sent privately to the Frame Control maintainer, never shared or published.
Updates are occasional release notices; follow-up questions are mainly about problem reports you send.</div>
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
<button class="small" id="updateCheck" hidden>Check for updates</button>
<a class="sub" href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md" target="_blank">What's collected, exactly</a></div>
</section>
</div>
</div>
<div class="page" data-page="devices">
<div class="dev-grid">
<section class="panel">
<div class="shelf-head"><h2>Headsets</h2><span class="count" id="devCount"></span><span class="spacer"></span>
<button class="action small" id="devAdd">+ Add a headset…</button></div>
<div class="list" id="devList"><div class="sub">Loading…</div></div>
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
it tries them all at once and uses the best one that answers on the network you're on.</div>
<h3 style="margin-top:22px">This computer's network</h3>
<div id="netNow" class="sub">Checking…</div>
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
<input type="text" id="netName" maxlength="60" placeholder="Name this network, e.g. Home Wi-Fi">
<button class="small" id="netSave">Save</button></div>
<div class="hint">Networks are told apart by their router (its IP and hardware address), so this works on wired
networks and when your computer won't share the Wi-Fi name.</div>
</section>
<section class="panel dev-panel" id="devDetail"><div class="sub">Pick a headset.</div></section>
</div>
</div>
</main>
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
<div class="drawer" id="drawer"><div class="log" id="log"></div></div>
<div class="bottombar" id="bottombar" role="button" aria-label="Show activity">
<span class="label">ACTIVITY</span>
<span class="jobs" id="jobs" hidden><span class="spinner"></span><span id="jobsText"></span></span>
<span class="last" id="lastLog">Ready</span>
<span class="sub" id="drawerHint">Show ▴</span>
</div>
<dialog id="appDetail" class="store-dialog" aria-labelledby="detailTitle">
<button class="store-close" data-close="appDetail" aria-label="Close app details">×</button>
<div id="detailBody"></div>
</dialog>
<dialog id="sourcesDialog" class="store-dialog sources-dialog" aria-labelledby="sourcesTitle">
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
<h2 id="apkAltTitle">Try another APK version</h2>
<p id="apkAltReason"></p>
<div class="list" id="apkAltVersions"></div>
<p class="sub" id="apkAltNote"></p>
<div id="apkAltLinks"></div>
<p class="sub" id="apkAltErrors"></p>
<div class="actions"><button id="apkAltClose">Close</button></div>
</dialog>
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
<h2 id="aboutTitle">About and licences</h2>
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
licence texts follow, with where to get the source.</p>
<div id="aboutText" class="about-text">Loading…</div>
<div class="actions"><button id="aboutClose">Close</button></div>
</dialog>
<dialog id="repDlg" aria-labelledby="repTitle">
<form method="dialog" id="repForm">
<h2 id="repTitle">Report an APK</h2>
<div class="rep-file"><button type="button" class="small" id="repPick">Read details from an .apk file…</button>
<input type="file" id="repFile" accept=".apk" hidden><span class="sub" id="repFileNote"></span></div>
<label>App name<input type="text" id="repLabel" placeholder="e.g. AntennaPod" maxlength="120"></label>
<label>Package<input type="text" id="repPkg" placeholder="e.g. de.danoeh.antennapod" required
pattern="[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+" maxlength="200" list="repPkgs"></label>
<datalist id="repPkgs"></datalist>
<div class="rep-row">
<label>Version<input type="text" id="repVer" placeholder="e.g. 3.12.2" maxlength="80"></label>
<label>Where it came from<input type="text" id="repSrc" placeholder="F-Droid, APKMirror, GitHub, own build…" maxlength="300"></label>
</div>
<fieldset><legend>Did it work?</legend>
<label class="opt"><input type="radio" name="repRating" value="works" required> <span class="v-works">Works</span></label>
<label class="opt"><input type="radio" name="repRating" value="issues"> <span class="v-maybe">Works, with problems</span></label>
<label class="opt"><input type="radio" name="repRating" value="broken"> <span class="v-no">Doesn't work</span></label>
</fieldset>
<fieldset><legend>How did you run it?</legend>
<label class="opt"><input type="radio" name="repRuntime" value="instance" checked> Own instance (Frame Control)</label>
<label class="opt"><input type="radio" name="repRuntime" value="lepton-dev"> Lepton Development</label>
<label class="opt"><input type="radio" name="repRuntime" value="other"> Other</label>
</fieldset>
<label>Notes<textarea id="repNotes" maxlength="1000" placeholder="What works, what doesn't, crash messages, settings you changed…"></textarea></label>
<div class="row rep-actions"><span class="sub" id="repMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="repCancel">Cancel</button>
<button type="submit" class="action small" id="repSave">Save report</button></div>
</form>
</dialog>
<dialog id="bugDlg" aria-labelledby="bugTitle">
<form method="dialog" id="bugForm">
<h2 id="bugTitle">Report a problem</h2>
<label class="field">What kind of report?<select id="bugKind">
<option value="bug">Something's broken</option><option value="idea">An idea</option>
<option value="question">A question</option><option value="other">Something else</option></select></label>
<label class="field">Title<input type="text" id="bugTitleIn" maxlength="120" required minlength="5"
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
placeholder="What you did, what happened, and what you expected."></textarea></label>
<label class="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
<span class="sub">Optional. Your email address goes with this report only when this is ticked, and is kept as your contact email in Privacy &amp; updates, where you can remove it.</span></label>
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
<p class="hint" id="bugReplaces" role="status" hidden></p>
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
<p id="bugWarn">Sent privately to the Frame Control developer. Nothing is published.</p>
<div class="row rep-actions"><span class="sub" id="bugMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="bugCancel">Cancel</button>
<button type="button" class="small" id="bugCopy">Copy report</button>
<button type="submit" class="action small" id="bugSend">Send report</button></div>
</form>
</dialog>
<dialog id="titleDlg" aria-labelledby="titleTitle">
<form method="dialog" id="titleForm">
<h2 id="titleTitle">Add to the Steam library</h2>
<div class="sub" id="titleSrc"></div>
<label>Name<input type="text" id="titleName" maxlength="120" required></label>
<div class="note" id="titleIdNote"></div>
<label>Launches<select id="titleExe"></select></label>
<label>Runtime<select id="titleRt"></select></label>
<div class="note" id="titleNote"></div>
<div class="note warn" id="titleWarn"></div>
<div class="row rep-actions"><span class="sub" id="titleMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="titleCancel">Cancel</button>
<button type="button" class="small" id="titlePush">Copy to ~/Downloads instead</button>
<button type="submit" class="action small" id="titleInstall">Install</button></div>
</form>
</dialog>
<dialog id="wiDlg" aria-labelledby="wiTitle">
<h2 id="wiTitle">Install from a website</h2>
<dl id="wiFacts"></dl>
<p id="wiWarn">A website asked Frame Control to install this. Nothing is downloaded until you click Install.
Only install software from sites you trust.</p>
<div class="progress" id="wiProg" hidden><i></i></div>
<div class="row rep-actions"><span class="sub" id="wiMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="wiCancel">Cancel</button>
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
</dialog>
<dialog id="pwDlg" aria-labelledby="pwTitle">
<form method="dialog" id="pwForm">
<h2 id="pwTitle">Restart the Frame?</h2>
<div class="sub">SteamOS asks for the Developer Mode password for this. It's used once and not saved.</div>
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Developer Mode password" style="margin-top:12px">
<div class="row rep-actions"><span class="sub" id="pwMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="pwCancel">Cancel</button>
<button type="submit" class="action small" id="pwGo">Restart</button></div>
</form>
</dialog>
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
<h2 id="connTitle">Connection</h2>
<div class="sub" id="connWhy"></div>
<h3>This computer</h3>
<dl class="facts" id="connNet"></dl>
<h3>Steps</h3>
<ol class="stages" id="connStages"></ol>
<h3>Addresses</h3>
<table class="probes"><tbody id="connProbes"></tbody></table>
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
<button type="button" class="small" id="connClose">Close</button></div>
</dialog>
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
<form method="dialog" id="addDevForm">
<h2 id="addDevTitle">Add a headset</h2>
<div class="sub">This opens Set Up Connection in a terminal window. On the headset, first turn on Steam Settings →
System → Enable Developer Mode, then Developer → Set User Password. Setup finds the headset, adds a key and
asks for that password once (or for a tap in the headset under Developer → Pair new host).</div>
<label>SSH alias (how Terminal and the scripts reach it: <code>ssh NAME</code>)<input type="text" id="addAlias" maxlength="64" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"></label>
<label>Address (optional: an IP or host name; left empty, setup looks for it)<input type="text" id="addHost" maxlength="253" placeholder="e.g. 192.168.1.40 or frame.local"></label>
<div class="row rep-actions"><span class="sub" id="addMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="addCancel">Cancel</button>
<button type="submit" class="action small" id="addGo">Set Up…</button></div>
</form>
</dialog>
<dialog id="rmDevDlg" class="dlg" aria-labelledby="rmDevTitle">
<form method="dialog" id="rmDevForm">
<h2 id="rmDevTitle">Remove this headset?</h2>
<div class="sub" id="rmDevText"></div>
<label style="display:flex; gap:8px; align-items:center; color:var(--text)"><input type="checkbox" id="rmDevConfig">
<span id="rmDevConfigText"></span></label>
<div class="row rep-actions"><span class="sub" id="rmMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="rmCancel">Cancel</button>
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
</form>
</dialog>
<div class="toast" id="toast"></div>
<script>
const $ = (id) => document.getElementById(id);
// Every /api/ request carries this header. The desktop server takes "1"; the
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_stream.Moonlight"],
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
const HINTS = {
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
};
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
function meter(el, pct, warnAt, badAt) {
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
}
function log(msg, kind = "") {
const line = document.createElement("div");
line.className = kind;
line.innerHTML = `<time>${new Date().toLocaleTimeString()}</time> ${esc(msg)}`;
$("log").prepend(line);
while ($("log").childElementCount > 200) $("log").lastElementChild.remove();
$("lastLog").textContent = msg.split("\n")[0];
$("lastLog").className = "last " + kind;
}
let toastTimer;
function toast(msg, err = false) {
const el = $("toast");
el.textContent = msg.split("\n").slice(-1)[0] || msg;
el.className = "toast show" + (err ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
}
$("bottombar").onclick = () => {
const open = $("drawer").classList.toggle("open");
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
};
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
$("clipMac").textContent = `Send ${h.computer} clipboard`;
if (h.mobile) $("offSetup").textContent = "Change headset…";
}
// Images to the phone (the app offers Save Image to Photos, Files and so on).
function blobBase64(blob) {
return new Promise((ok, bad) => {
const r = new FileReader();
r.onload = () => ok(String(r.result).split(",")[1]);
r.onerror = () => bad(new Error("couldn't read the image"));
r.readAsDataURL(blob);
});
}
async function saveToDevice(items) {
const images = await Promise.all(items.map(async i => ({ name: i.name, data: await blobBase64(i.blob) })));
return window.frameApp.saveImages(images);
}
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
// Bumped when the app moves to another headset: answers to requests made before
// then are about the old one, so they're dropped rather than shown.
let devGen = 0;
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
async function api(path, body, device = window.connDevice) {
const gen = devGen;
// Changes name the headset the page was showing, so the server refuses one meant
// for a headset it has since switched away from.
const dev = device ? { "X-Frame-Device": device } : {};
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY, ...dev}, body: JSON.stringify(body) };
let r;
try { r = await fetch(path, opts); }
catch {
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
}
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
if (gen !== devGen && !HEADSET_FREE.test(path)) {
const err = new Error("Switched headsets");
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
throw err;
}
if (!r.ok) {
const err = new Error(data.error || `HTTP ${r.status}`);
err.offline = !!data.offline;
if (err.offline) setOnline(false, err.message);
throw err;
}
return data;
}
// What a panel shows when its data couldn't be read: a quiet placeholder while
// the Frame is unreachable (the banner explains), the real error otherwise.
function failed(el, e) {
if (e.stale) return; // about the previous headset: leave the panel to the new one
el.innerHTML = e.offline ? `<div class="wait">Waiting for the Frame</div>` : `<div class="sub">${esc(e.message)}</div>`;
}
async function act(label, fn, btn) {
if (btn) btn.disabled = true;
log(label + "…");
try {
const res = await fn();
const msg = (res && res.message) || "Done";
log(msg, "ok"); toast(msg);
return res;
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
} finally { if (btn) btn.disabled = false; }
}
function setConn(ok, text) {
if (window.connPill && window.connPill()) return; // the connector's pill (below) says more
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
}
// Whether the Frame answers. Any request that couldn't reach it flips this off;
// the next successful status check flips it back and reloads what failed.
let online = null;
function setOnline(ok, why) {
const was = online;
online = ok;
$("offline").hidden = ok;
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true;
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
} else if (was === false) {
log("Connected to the Frame again", "ok");
reloadAll();
}
}
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); }
$("offRetry").onclick = () => refresh();
if (window.frameApp && window.frameApp.setUpConnection) {
$("offSetup").hidden = false;
$("offSetup").onclick = () => window.frameApp.setUpConnection();
}
$("conn").onclick = () => { if (online === false) refresh(); };
let refreshing = null, pollTimer = null;
function schedule() { // every 30 s while connected, every 8 s while not
clearTimeout(pollTimer);
pollTimer = setTimeout(() => { if (document.hidden) schedule(); else refresh(); }, online === false ? 8000 : 30000);
}
function refresh() {
if (refreshing) return refreshing;
$("refreshAll").disabled = true; $("offRetry").disabled = true;
refreshing = (async () => {
try {
state = await api("/api/status");
render(state);
setConn(true, state.hostname);
setOnline(true);
} catch (e) {
renderPerformance(null);
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
} finally {
$("refreshAll").disabled = false; $("offRetry").disabled = false;
refreshing = null; schedule();
}
})();
return refreshing;
}
// ---- our VR HUD and optional third-party catalogue ----
function renderPerformance(s) {
const p = s?.performance || {};
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"]];
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
}
$("hudStart").onclick = () => act("Open HUD", () => api("/api/vr", {action: "hud-start"}), $("hudStart"));
$("hudStop").onclick = () => act("Close HUD", () => api("/api/vr", {action: "hud-stop"}), $("hudStop"));
async function loadUtilities() {
$("vrUtilitiesRefresh").disabled = true;
try {
const data = await api("/api/vr/utilities");
$("vrUtilities").innerHTML = data.utilities.map(u => `<div class="item">
<div class="grow"><strong>${esc(u.name)}</strong> · ${esc(u.status)} · ${u.free ? "Free" : "Paid"} · ${u.owned == null ? "Ownership unknown" : u.owned ? "Owned" : "Not in library"}
<div class="sub">${esc(u.note)} <a href="${esc(u.source)}" target="_blank" rel="noopener">Source</a></div>
${u.report ? `<div class="sub">Report ${esc(u.report.date || "")}: ${esc(u.report.notes || "")} · SteamOS ${esc(u.report.steamos || "unspecified")}</div>` : ""}</div>
<button class="small" data-utility="${u.id}" ${u.installed || !u.canInstall ? "disabled" : ""}>${u.installed ? "Installed" : "Install (optional)"}</button></div>`).join("");
} catch(e) { failed($("vrUtilities"), e); }
finally { $("vrUtilitiesRefresh").disabled = false; }
}
$("vrUtilitiesRefresh").onclick = loadUtilities;
$("vrUtilities").onclick = async e => {
const b = e.target.closest("[data-utility]");
if (!b) return;
await act("Install optional utility", () => api("/api/steam", {action:"install", appid:b.dataset.utility}), b);
await loadUtilities();
};
// The Frame owns the clock. Poll independently of status and the selected tab.
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
async function localNotification(message, request = false) {
try {
if (window.frameApp?.notify) await window.frameApp.notify(message, request);
else {
if (!("Notification" in window)) throw new Error("This browser has no notifications; use the Frame Control app.");
const permission = request ? await Notification.requestPermission() : Notification.permission;
if (permission !== "granted") throw new Error("Notifications are off. Enable them in system settings.");
new Notification("Frame Control", {body: message});
}
$("comfortNotificationStatus").hidden = true;
} catch (e) {
$("comfortNotificationStatus").hidden = false;
$("comfortNotificationStatus").textContent = "Notifications aren't available on this device. Check system notification settings, then use Enable / test notifications. Headset reminders continue during an active session.";
throw e;
}
}
function renderComfort(s) {
const firstSnapshot = !comfortHydrated;
comfortHydrated = true;
$("sessionStart").disabled = !!s.active;
for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"])
$(id).disabled = !!s.active;
$("sessionCancel").disabled = !s.active;
if (s.id !== comfortSession) {
comfortSession = s.id;
comfortSeen.clear();
if (s.options) for (const [key, value] of Object.entries(s.options)) {
const el = $(key === "minutes" ? "sessionMinutes" : key);
if (!el) continue;
if (el.type === "checkbox") el.checked = value; else el.value = value;
}
}
let statusText = s.error || (s.active
? `${Math.ceil(s.remaining / 60)} min until Steam Home · ${s.activity == null ? "activity unknown" : s.activity === 3 ? "headset in standby" : "monitoring"}`
: "No session running.");
if (s.active && s.unavailable?.length)
statusText += " · No readings: " + s.unavailable.join(", ");
if ($("comfortStatus").textContent !== statusText) $("comfortStatus").textContent = statusText;
let latest = null;
for (const e of s.events || []) {
if (comfortSeen.has(e.id)) continue;
comfortSeen.add(e.id);
// Historical events remain visible but never produce a burst on reconnect.
if (!firstSnapshot && s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) {
latest = e.message;
log(e.message); toast(e.message, e.kind === "error");
localNotification(e.message).catch(() => {}); // the notification status keeps the failure visible
}
}
// Keep only the server's bounded history; a new page seeds it without replay.
comfortSeen = new Set((s.events || []).map(e => e.id));
if (latest !== null) $("comfortAnnouncement").textContent = latest;
const history = (s.events || []).slice(-3).map(e => e.message).join(" · ");
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
}
async function pollComfort() {
if (!comfortBusy) {
comfortBusy = true;
try { renderComfort(await api("/api/comfort", {action: "status"})); }
catch (e) {
const message = "Session status unavailable: " + e.message;
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
}
finally { comfortBusy = false; }
}
setTimeout(pollComfort, 5000);
}
$("comfortForm").onsubmit = async e => {
e.preventDefault();
const result = await act("Start session", () => api("/api/comfort", {
action: "start", minutes: Number($("sessionMinutes").value), breakMinutes: Number($("breakMinutes").value),
stillMinutes: Number($("stillMinutes").value), batteryAlert: $("batteryAlert").checked, heatAlert: $("heatAlert").checked,
}), $("sessionStart"));
if (result) renderComfort(result);
};
$("sessionCancel").onclick = async () => {
const result = await act("Cancel session", () => api("/api/comfort", {action: "cancel"}), $("sessionCancel"));
if (result) renderComfort(result);
};
$("testNotification").onclick = () => act("Test notification", async () => {
await localNotification("Comfort notifications are enabled on this device.", true);
return {message: "Test notification sent. Check your device's notification settings if it didn't appear."};
});
$("castBtn").onclick = () => {
setView("headset");
if (!live) toggleLive(true);
$("viewer").scrollIntoView({behavior: "smooth", block: "center"});
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
$("viewer").requestFullscreen?.().catch(() => {});
};
pollComfort();
// ---- battery ----
function battery(b, power) {
const pct = b?.percent;
const full = b?.status === "Full" || (!!power && pct >= 100);
const charging = !full && b?.status === "Charging";
// A weak charger can lose to the load: plugged in but still draining.
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
const low = !charging && !full && pct != null && pct <= 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
$("battFill").setAttribute("fill", color);
$("battBolt").setAttribute("visibility", charging ? "visible" : "hidden");
$("battPct").textContent = pct == null ? "—" : `${pct}%`;
let label, cls, detail = [];
if (!b) { label = "Battery unknown"; cls = "draining"; }
else if (full) { label = "Fully charged"; cls = "full"; }
else if (charging) {
label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging";
if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`);
} else if (!draining) { label = "Plugged in, not charging"; cls = "draining"; }
else {
label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery";
cls = low ? "low" : "draining";
if (b?.watts < 0) detail.push(`Using ${Math.abs(b.watts).toFixed(1)} W`);
if (b?.timeToEmpty > 0 && b.timeToEmpty < 86400) detail.push(`About ${dur(b.timeToEmpty)} left`);
}
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
if (b?.tempC != null) detail.push(`${b.tempC.toFixed(0)}°C`);
$("battState").textContent = label;
$("battState").className = "state " + cls;
$("battDetail").textContent = detail.join(" · ") || " ";
const bolt = charging ? `<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden="true"><path d="M6 0L0 8h4l-1 6 7-8H6z" fill="#a4d007"/></svg>` : "";
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
$("battChip").innerHTML = pct == null ? "—" :
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
$("battChip").title = label;
$("battChip").hidden = pct == null;
}
function render(s) {
renderPerformance(s);
battery(s.battery, s.power);
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
const h = s.disk.home;
if (h) { $("sDisk").textContent = gb(h.free); meter($("mDisk"), 100 * (1 - h.free / h.total), 80, 92); }
const m = s.memory;
if (m) { const used = m.total - m.available; $("sMem").textContent = `${gb(used)} / ${gb(m.total)}`; meter($("mMem"), 100 * used / m.total, 75, 90); }
$("sTemp").textContent = s.temp == null ? "—" : `${s.temp.toFixed(0)}°C`;
$("dTemp").textContent = s.temp == null ? "" : s.temp > 75 ? "Running hot" : s.temp > 60 ? "Warm" : "Normal";
$("sWifi").textContent = s.wifi ? `${s.wifi.signal ?? "?"}%` : "—";
$("dWifi").textContent = s.wifi?.ssid ?? "Not on Wi-Fi";
$("sUp").textContent = s.uptime ? dur(s.uptime) : "—";
$("dUp").textContent = "since boot";
$("sOs").textContent = s.os.version ?? "—";
$("dOs").textContent = s.os.build ? `build ${s.os.build}` : "";
const svc = [["SteamVR", s.services.steamvr], ["Desktop", s.services.desktop],
["Lepton", s.services.lepton], ["RDP", s.services.rdp]];
$("services").innerHTML = svc.map(([n, on]) => `<span class="chip"><span class="dot ${on ? "on" : ""}"></span>${esc(n)}</span>`).join("");
if (s.volume && !volTimer) setVolume(Math.round(s.volume.level * 100), s.volume.muted);
const games = s.games.filter(g => /^\d+$/.test(g.appid)); // appid goes into image URLs
$("gameCount").textContent = `(${games.length})`;
$("games").innerHTML = games.length ? games.map(g => `
<div class="capsule" data-appid="${g.appid}">
<div class="fallback">${esc(g.name)}</div>
<div class="over">
<div class="name">${esc(g.name)}</div>
<div class="size">${gb(g.size)}</div>
<button class="play small" data-launch="${g.appid}" data-name="${esc(g.name)}">▶ PLAY</button>
</div>
</div>`).join("") : `<div class="sub">No games installed.</div>`;
// Tall library art, falling back to the wide header, then to the name card.
$("games").querySelectorAll(".capsule").forEach(c => {
const id = c.dataset.appid;
const tryArt = (urls) => {
if (!urls.length) return;
const img = new Image();
img.onload = () => { c.style.backgroundImage = `url("${urls[0]}")`; c.querySelector(".fallback").hidden = true; };
img.onerror = () => tryArt(urls.slice(1));
img.src = urls[0];
};
tryArt([`${CDN}/${id}/library_600x900.jpg`, `${CDN}/${id}/header.jpg`]);
});
$("flatpaks").innerHTML = s.flatpaks.length ? s.flatpaks.map(f => `
<div class="item">
<div class="grow"><div class="t">${esc(f.name)}</div><div class="s">${esc(f.id)} · ${esc(f.version || "")}</div></div>
<span class="tag">${esc(f.installation)}</span>
${f.installation === "user" ? `<button class="small danger" data-uninstall="${esc(f.id)}" data-name="${esc(f.name)}">Remove</button>` : ""}
</div>`).join("") : `<div class="sub">No Flatpak apps.</div>`;
const have = new Set(s.flatpaks.map(f => f.id));
$("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id))
.map(([n, id]) => installing.has(id) ? `<button class="small" disabled>Installing ${esc(n)}…</button>`
: `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join("");
}
// ---- view ----
function setView(v) {
const changed = v !== view;
view = v;
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
$("eyeSeg").style.display = v === "flat" ? "none" : ""; // the Desktop view has no eyes to pick
$("viewHint").textContent = HINTS[v];
$("panelPick").hidden = v !== "flat";
if (v === "flat") watchPanels(); else clearTimeout(desk.timer);
if (live && changed) { toggleLive(false); toggleLive(true); }
ctrlShow();
}
function setEye(e) {
eye = e;
document.querySelectorAll("[data-eye]").forEach(b => b.classList.toggle("on", b.dataset.eye === e));
if (lastImg) draw();
}
// ---- zoom and pan (1 = fit the whole frame in the viewer) ----
let zoom = 1, panX = 0, panY = 0;
function setZoom(z, cx, cy) {
const v = $("viewer").getBoundingClientRect();
z = Math.min(8, Math.max(1, z));
// Keep the point under the cursor (or the centre) fixed while zooming.
cx ??= v.width / 2; cy ??= v.height / 2;
panX = cx - (cx - panX) * z / zoom; panY = cy - (cy - panY) * z / zoom;
zoom = z;
if (zoom === 1) panX = panY = 0;
// Don't let the image slide out of view.
panX = Math.min(0, Math.max(v.width - v.width * zoom, panX));
panY = Math.min(0, Math.max(v.height - v.height * zoom, panY));
$("canvas").style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
$("zoomFit").textContent = zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`;
$("viewer").classList.toggle("zoomed", zoom > 1);
}
$("zoomIn").onclick = () => setZoom(zoom * 1.5);
$("zoomOut").onclick = () => setZoom(zoom / 1.5);
$("zoomFit").onclick = () => setZoom(1);
// Full screen, or on phones (no element full screen there) the viewer filling the window.
$("fullBtn").onclick = () => {
if (document.fullscreenElement) return document.exitFullscreen();
if ($("viewer").requestFullscreen && !TOUCH) return $("viewer").requestFullscreen().catch(() => $("viewer").classList.toggle("immersive"));
$("viewer").classList.toggle("immersive");
setZoom(1);
};
document.addEventListener("fullscreenchange", () => setZoom(1));
$("viewer").addEventListener("wheel", e => {
if ($("canvas").hidden || ctrl.on) return;
e.preventDefault();
const v = $("viewer").getBoundingClientRect();
setZoom(zoom * Math.exp(-e.deltaY * 0.002), e.clientX - v.left, e.clientY - v.top);
}, { passive: false });
$("canvas").addEventListener("dblclick", e => {
if (ctrl.on) return;
const v = $("viewer").getBoundingClientRect();
setZoom(zoom > 1 ? 1 : 2.5, e.clientX - v.left, e.clientY - v.top);
});
$("canvas").addEventListener("pointerdown", e => {
if (zoom === 1 || ctrl.on) return;
const start = { x: e.clientX - panX, y: e.clientY - panY };
$("viewer").classList.add("panning");
$("canvas").setPointerCapture(e.pointerId);
const move = ev => { panX = ev.clientX - start.x; panY = ev.clientY - start.y; setZoom(zoom); };
const up = () => { $("viewer").classList.remove("panning"); $("canvas").removeEventListener("pointermove", move); };
$("canvas").addEventListener("pointermove", move);
$("canvas").addEventListener("pointerup", up, { once: true });
});
document.addEventListener("keydown", e => {
if (page !== "home" || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
if (ctrl.on && document.activeElement === $("viewer")) return; // keys are going to the Frame
if (e.key === "c") return $("ctrlBtn").click();
if ($("canvas").hidden) return;
if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5);
else if (e.key === "-") setZoom(zoom / 1.5);
else if (e.key === "0") setZoom(1);
else if (e.key === "f") $("fullBtn").click();
});
function draw() {
const img = lastImg, c = $("canvas");
// Side-by-side stereo captures: crop to the left half for "one eye".
const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
const crop = sbs && eye === "left";
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
if (c.width !== sw || c.height !== sh) {
c.width = sw; c.height = sh;
$("viewer").style.aspectRatio = `${sw} / ${sh}`; // one eye is taller than wide
setZoom(1);
}
const ctx = c.getContext("2d");
ctx.drawImage(img, 0, 0, sw, sh, 0, 0, sw, sh);
$("asleep").hidden = lastSource !== "steamvr" || !isBlank(ctx, sw, sh);
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false;
$("srcBadge").hidden = false;
$("srcBadge").textContent = SOURCE_LABEL[lastSource] + (crop ? " · left eye" : "");
}
// A sleeping headset renders a flat near-black frame; sample a grid to spot it.
function isBlank(ctx, w, h) {
const px = ctx.getImageData(0, 0, w, h).data;
let min = 255, max = 0;
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
const i = (Math.floor((y + .5) * h / 16) * w + Math.floor((x + .5) * w / 16)) * 4;
const l = (px[i] + px[i + 1] + px[i + 2]) / 3;
min = Math.min(min, l); max = Math.max(max, l);
}
return max - min < 6;
}
async function capture() {
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
let url = null;
try {
let url_ = "/api/screenshot?view=headset";
var shotPanel = null;
if (view === "flat") {
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
if (!shotPanel) throw new Error("no app panel is open in the headset");
url_ = `/api/screenshot?view=panel&window=${shotPanel.window}&display=${encodeURIComponent(shotPanel.display)}`;
}
const r = await fetch(url_, { headers: {"X-Frame-UI": UI_KEY} });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const source = r.headers.get("X-Capture-Source") || "gamescope";
url = URL.createObjectURL(await r.blob());
const img = new Image();
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
if (gen !== viewGen) return true;
lastImg = img; lastSource = source; lastShot = null;
draw();
desk.shown = source === "panel" ? panelKey(shotPanel) : null;
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
ctrlShow();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = $("copyBtn").disabled = false;
return true;
} catch (e) {
log("Capture failed: " + e.message, "e");
if (!live) toast("Capture failed: " + e.message, true);
return false;
} finally {
if (url) URL.revokeObjectURL(url);
$("viewer").classList.remove("busy");
}
}
let liveFailures = 0, liveGen = 0;
async function liveLoop(gen = liveGen) {
if (!live || gen !== liveGen) return;
const ok = await capture();
if (gen !== liveGen) return; // Live was restarted (say, as video) while this capture ran
liveFailures = ok ? 0 : liveFailures + 1;
if (liveFailures >= 5) {
toggleLive(false);
toast(view === "flat" ? "Live stopped: couldn't capture the panel." : "Live stopped after 5 failed captures. Is the headset on?", true);
return;
}
if (live) liveTimer = setTimeout(() => liveLoop(gen), view === "headset" ? 300 : 700);
}
function toggleLive(on) {
live = on;
liveGen++;
liveFailures = 0;
$("liveBtn").classList.toggle("on", live);
$("liveBadge").hidden = !live;
clearTimeout(liveTimer);
stopVideo();
if (!live) return;
if ("VideoDecoder" in window && (view === "headset" || deskPanel())) {
const started = startVideo();
const ctl = video.ctl;
started.catch(e => {
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
log("Live video failed: " + e.message, "e");
toast("Live video failed, using captures instead: " + e.message, true);
stopVideo();
liveLoop();
});
} else liveLoop();
}
// ---- live video of the headset view ----
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
// Every frame starts with an access unit delimiter (NAL type 9), which is how
// the stream is cut into frames for WebCodecs.
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
const video = { ctl: null, dec: null, gen: 0 };
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
function nalTypes(au) {
const types = [];
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
return types;
}
async function startVideo() {
const gen = ++video.gen, ctl = new AbortController();
video.ctl = ctl;
$("eyeSeg").style.visibility = "hidden";
if (!lastImg) $("viewer").classList.add("busy");
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
const c = $("canvas"), ctx = c.getContext("2d");
const dec = video.dec = new VideoDecoder({
output: frame => {
if (gen !== video.gen) return frame.close();
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
c.width = frame.displayWidth; c.height = frame.displayHeight;
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
setZoom(1);
}
ctx.drawImage(frame, 0, 0);
frame.close();
if (!shown++) {
viewGen++; // a capture still in flight mustn't replace the video
lastImg = null; lastSource = "video"; lastShot = null;
desk.shown = panelKey(panel); ctrlShow();
$("viewer").classList.remove("busy");
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
$("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false;
}
frames++;
const now = performance.now();
if (now - second >= 1000) {
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
frames = 0; second = now;
}
},
error: e => log("Video decoder: " + e.message, "e"),
});
const panel = view === "flat" ? deskPanel() : null;
desk.shown = null; // until this stream's first picture
video.label = panel ? `Desktop · ${panel.name || "panel"}` : "Headset view";
const query = panel ? `src=panel&window=${panel.window}&display=${encodeURIComponent(panel.display)}&${STREAM_QUERY}` : STREAM_QUERY;
const r = await fetch(`/api/stream?${query}`, { headers: {"X-Frame-UI": UI_KEY}, signal: ctl.signal });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
const reader = r.body.getReader();
let buf = new Uint8Array(0), scan = 0, auStart = -1;
const onAU = au => {
const types = nalTypes(au), key = types.includes(5);
if (dec.state === "unconfigured") {
const sps = types.indexOf(7);
if (sps < 0) return;
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
if (i + 6 >= au.length) return;
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
}
// If decoding falls behind, drop frames until the next keyframe rather than lag.
if (dec.decodeQueueSize > 3) needKey = true;
if (needKey && !key) return;
needKey = false;
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
ts += 1e6 / STREAM_FPS;
};
for (;;) {
const { value, done } = await reader.read();
if (done || gen !== video.gen) break;
const next = new Uint8Array(buf.length + value.length);
next.set(buf); next.set(value, buf.length);
buf = next;
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
for (; scan + 3 < buf.length; scan++) {
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
auStart = scan;
scan += 3;
}
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
}
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
}
function stopVideo() {
video.gen++;
if (video.ctl) video.ctl.abort();
if (video.dec && video.dec.state !== "closed") video.dec.close();
video.ctl = video.dec = null;
$("viewer").classList.remove("busy");
if (lastSource === "video") { $("srcBadge").textContent = `${video.label || "Headset view"} · video (stopped)`; $("stamp").hidden = true; }
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
}
$("shotBtn").onclick = () => capture();
$("liveBtn").onclick = () => toggleLive(!live);
$("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : $("canvas").toBlob(b => {
if (!b) return toast("Couldn't encode the image", true);
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
}, "image/png");
const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob
: new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"))));
$("copyBtn").onclick = copyViewer;
// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame).
$("canvas").oncontextmenu = e => {
if (ctrl.on || $("saveBtn").disabled) return;
e.preventDefault();
showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]);
};
// An image onto the clipboard. The app does it natively (JPEG too); a browser
// takes PNG only, and the blob is handed over as a promise so the click still counts.
async function copyImage(blob) {
if (window.frameApp?.writeImage) {
if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image");
return { message: "Copied the image" };
}
if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here");
await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]);
return { message: "Copied the image" };
}
async function pngBlob(blob) {
if (blob.type === "image/png") return blob;
const bmp = await createImageBitmap(blob);
const c = document.createElement("canvas");
c.width = bmp.width; c.height = bmp.height;
c.getContext("2d").drawImage(bmp, 0, 0);
bmp.close();
return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"));
}
function download(blob, name) {
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
// ---- Desktop view: the app panel in use in the headset (or one picked to watch) ----
// gamescope says which panel has focus: the one the wearer last used. That's the one
// Control reaches, so the view follows it unless you pick another to watch.
// Panels are "display/window": window ids are per X display, and the Frame has two.
// desk.shown is the panel whose picture is on screen now; Control only acts on that one.
const desk = { panels: [], focus: null, pick: "", timer: null, loaded: false, shown: null };
const panelKey = p => p ? `${p.display}/${p.window}` : null;
function deskPanel() {
const want = desk.pick || desk.focus;
// Nothing in use and nothing picked: show the first panel rather than nothing.
return desk.panels.find(p => panelKey(p) === want) || (!desk.pick && !desk.focus && desk.panels[0]) || null;
}
async function loadPanels() {
try {
const before = panelKey(deskPanel());
const r = await api("/api/panels");
desk.panels = r.panels || []; desk.loaded = true;
desk.focus = r.focus ? `${r.focus_display}/${r.focus}` : null;
if (desk.pick && !desk.panels.some(p => panelKey(p) === desk.pick)) desk.pick = "";
renderPanelPick();
const now = panelKey(deskPanel());
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
ctrlShow();
} catch (e) {
log("Couldn't list the headset's panels: " + e.message, "e");
}
}
function watchPanels() {
clearTimeout(desk.timer);
if (view !== "flat") return;
loadPanels().finally(() => { if (view === "flat") desk.timer = setTimeout(watchPanels, document.hidden ? 10000 : 3000); });
}
function renderPanelPick() {
const sel = $("panelPick");
sel.innerHTML = `<option value="">${desk.focus ? "Panel in use" : "Panel in use (none yet)"}</option>` +
desk.panels.map(p => `<option value="${esc(panelKey(p))}">${esc(p.name || "Untitled")}${p.focused ? " (in use)" : ""}</option>`).join("");
sel.value = desk.pick;
}
$("panelPick").onchange = () => {
desk.pick = $("panelPick").value;
ctrlShow();
if (live) { toggleLive(false); toggleLive(true); } else if (lastImg || lastSource) capture();
};
// ---- Control: tap or click on the view to use the Frame ----
// Through gamescope's own input (frame_touch.py). On the Desktop view a tap lands
// exactly where you tapped; on the headset view, which moves with the wearer's head,
// the view is a trackpad for the pointer instead.
const ctrl = { on: false, queue: [], sending: false, state: "off", message: "", move: null, rel: [0, 0], raf: 0,
held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
const EVDEV = { Escape: 1, Minus: 12, Equal: 13, Backspace: 14, Tab: 15, BracketLeft: 26, BracketRight: 27, Enter: 28,
ControlLeft: 29, Semicolon: 39, Quote: 40, Backquote: 41, ShiftLeft: 42, Backslash: 43, Comma: 51, Period: 52,
Slash: 53, ShiftRight: 54, NumpadMultiply: 55, AltLeft: 56, Space: 57, CapsLock: 58, F11: 87, F12: 88,
IntlBackslash: 86, NumpadEnter: 96, ControlRight: 97, NumpadDivide: 98, AltRight: 100, Home: 102, ArrowUp: 103,
PageUp: 104, ArrowLeft: 105, ArrowRight: 106, End: 107, ArrowDown: 108, PageDown: 109, Insert: 110, Delete: 111,
MetaLeft: 125, MetaRight: 126, ContextMenu: 127, NumpadSubtract: 74, NumpadAdd: 78, NumpadDecimal: 83 };
[..."qwertyuiop"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 16 + i);
[..."asdfghjkl"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 30 + i);
[..."zxcvbnm"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 44 + i);
[..."1234567890"].forEach((c, i) => EVDEV["Digit" + c] = 2 + i);
for (let i = 1; i <= 10; i++) EVDEV["F" + i] = 58 + i;
[[7, 71], [8, 72], [9, 73], [4, 75], [5, 76], [6, 77], [1, 79], [2, 80], [3, 81], [0, 82]].forEach(([n, k]) => EVDEV["Numpad" + n] = k);
const CTRL_BUTTONS = ["left", "middle", "right"]; // MouseEvent.button 0, 1, 2
function ctrlMode() { return view === "flat" ? "abs" : "rel"; }
// Where taps go on the Desktop view, or why they can't.
function ctrlTarget() {
if (ctrlMode() === "rel") return { ok: true };
const p = deskPanel();
if (!desk.loaded) return { why: "Finding the headset's panels…" };
if (!p) return { why: "No app panel is open in the headset." };
if (!desk.focus) return { why: "Nobody's using a panel in the headset yet. Taps work once one is in use." };
if (panelKey(p) !== desk.focus) return { why: "Only the panel in use takes taps. Pick “Panel in use” to control it." };
if (desk.shown !== desk.focus) return { why: live ? "Waiting for the picture of the panel in use…"
: "The panel in use has changed. Press Capture or Live to see it first." };
return { ok: true, panel: p };
}
function ctrlShow() {
const v = $("viewer"), b = $("ctrlBadge");
$("ctrlBtn").classList.toggle("on", ctrl.on);
v.classList.toggle("control", ctrl.on);
v.classList.toggle("rel", ctrl.on && ctrlMode() === "rel");
$("ctrlKeys").hidden = !ctrl.on;
b.hidden = !ctrl.on;
if (!ctrl.on) return;
const t = ctrlTarget();
let text = ctrlMode() === "abs" ? `Tap or click to use ${t.panel ? t.panel.name || "the panel" : "the panel"}`
: "Drag to move the pointer, tap to click, two fingers to scroll";
let warn = false;
if (!t.ok) { text = t.why; warn = true; }
else if (ctrl.state === "error") { text = ctrl.message || "Control isn't working"; warn = true; }
else if (ctrl.state === "starting") text = "Connecting…";
b.textContent = text;
b.classList.toggle("warn", warn);
}
function ctrlToggle(on) {
if (!on) ctrlRelease();
ctrl.on = on;
if (on) {
setZoom(1);
ctrlPoll(0);
if (view === "flat" && !desk.loaded) watchPanels();
if (!TOUCH) $("viewer").focus({ preventScroll: true });
}
if (window.frameApp && window.frameApp.captureKeys) window.frameApp.captureKeys(on && document.activeElement === $("viewer"));
ctrlShow();
}
$("ctrlBtn").onclick = () => ctrlToggle(!ctrl.on);
// Until it's ready, check on the Frame side every half second (for up to 15 s).
async function ctrlPoll(n) {
if (!ctrl.on) return;
try {
const r = await api(n ? "/api/touch" : "/api/touch?start=1");
ctrl.state = r.state; ctrl.message = r.message || "";
} catch (e) { ctrl.state = "error"; ctrl.message = e.message; }
if (!ctrl.on) return; // turned off while connecting
ctrlShow();
if (ctrl.state !== "ready" && ctrl.state !== "error" && n < 30) return setTimeout(() => ctrlPoll(n + 1), 500);
if (ctrl.state === "ready") return ctrlFlush();
if (ctrl.state !== "error") {
ctrl.state = "error"; ctrl.message = "The Frame didn't answer. Turn Control off and on to try again.";
ctrlShow();
}
}
$("viewer").tabIndex = 0;
$("viewer").addEventListener("focus", () => { if (ctrl.on && window.frameApp?.captureKeys) window.frameApp.captureKeys(true); });
$("viewer").addEventListener("blur", () => { ctrlRelease(); if (window.frameApp?.captureKeys) window.frameApp.captureKeys(false); });
document.addEventListener("visibilitychange", () => { if (document.hidden) ctrlRelease(); });
window.addEventListener("blur", () => ctrlRelease()); // another app took the keyboard
const isMoveEvent = e => ("fx" in e || "dx" in e) && !("button" in e) && !("key" in e);
const isRelease = e => ("button" in e || "key" in e) && e.down === false;
// While it connects, keep clicks and keys, each with the last position before it, so a
// tap still lands where it was made; lone moves are stale by then.
function ctrlKeepable(events) {
const out = [];
let move = null;
for (const e of events) {
if (isMoveEvent(e)) { if ("fx" in e) move = e; continue; }
if (move && "button" in e && e.down !== false) out.push(move);
move = null;
out.push(e);
}
if (move) out.push(move); // the last position: a click queued after it needs it
return out;
}
function ctrlSend(events) {
ctrl.queue.push(...events.map(e => isMoveEvent(e) ? e : ctrlAim(e)).filter(Boolean));
// Keep order; if the Frame is slow to answer, drop pointer moves first, then the oldest
// presses and scrolls, never a release.
if (ctrl.queue.length > 300) {
const q = ctrlKeepable(ctrl.queue), keep = new Set();
q.forEach((e, i) => { if (isRelease(e) || i >= q.length - 200) keep.add(i); });
// A kept press keeps the position before it, so it lands where it was made.
for (const i of [...keep]) if (i > 0 && "button" in q[i] && q[i].down !== false && "fx" in q[i - 1]) keep.add(i - 1);
ctrl.queue = q.filter((e, i) => keep.has(i));
}
ctrlFlush();
}
async function ctrlFlush() {
if (ctrl.sending || !ctrl.queue.length) return;
// Backing off after a failure: new input waits for the same retry.
const wait = (ctrl.retryAt || 0) - Date.now();
if (wait > 0) {
clearTimeout(ctrl.retry);
ctrl.retry = setTimeout(ctrlFlush, wait);
return;
}
ctrl.sending = true;
// Up to 200 events, and at most about 100 characters of text: the Frame types a batch
// key by key before reading the next, and a release mustn't wait behind a long paste.
let n = 0, chars = 0;
while (n < ctrl.queue.length && n < 200 && (n === 0 || chars + (ctrl.queue[n].text?.length || 0) <= 100)) {
chars += ctrl.queue[n].text?.length || 0;
n++;
}
const batch = ctrl.queue.splice(0, n);
let again = 0;
try {
const r = await api("/api/touch", { events: batch });
ctrl.state = r.state; ctrl.message = r.message || "";
if (r.stale && typeof loadPanels === "function") loadPanels(); // focus moved on: catch up now
if (!r.sent && r.state === "error") {
// Broken: presses would be stale by the time it's back; releases still matter.
ctrl.queue.unshift(...batch.filter(isRelease));
again = 2000;
} else if (!r.sent) {
ctrl.queue.unshift(...ctrlKeepable(batch)); // connecting: send them once it's ready
again = 300;
}
} catch (e) {
ctrl.state = "error"; ctrl.message = e.message;
ctrl.queue.unshift(...batch.filter(isRelease));
again = 1000; // try the releases again in a moment, not in a tight loop
} finally {
ctrl.sending = false;
ctrlShow();
clearTimeout(ctrl.retry);
ctrl.retryAt = again ? Date.now() + again : 0;
if (again) { if (ctrl.queue.length) ctrl.retry = setTimeout(ctrlFlush, again); }
else if (ctrl.queue.length) ctrlFlush();
}
}
// Pointer moves: at most one per animation frame.
function ctrlMoveTo(fx, fy) {
const p = ctrlTarget().panel;
if (!p) return;
ctrl.move = { fx, fy, window: p.window, display: p.display };
ctrlSchedule();
}
function ctrlMoveBy(dx, dy) { ctrl.rel[0] += dx; ctrl.rel[1] += dy; ctrlSchedule(); }
function ctrlSchedule() { if (!ctrl.raf) ctrl.raf = requestAnimationFrame(ctrlFlushMoves); }
function ctrlFlushMoves() {
ctrl.raf = 0;
const out = [];
if (ctrl.move) { out.push(ctrl.move); ctrl.move = null; }
if (ctrl.rel[0] || ctrl.rel[1]) { out.push({ dx: Math.round(ctrl.rel[0]), dy: Math.round(ctrl.rel[1]) }); ctrl.rel = [0, 0]; }
if (out.length) ctrlSend(out);
}
// On the Desktop view, presses name the panel they're for, so the Frame drops them if
// focus has moved on (releases always go).
// A press with no panel to aim at (the Desktop view lost its target) isn't sent at all.
function ctrlAim(e) {
if (ctrlMode() !== "abs" || e.down === false) return e;
const p = ctrlTarget().panel;
return p ? { ...e, window: p.window, display: p.display } : null;
}
function ctrlButton(name, down) {
ctrlFlushMoves(); // the click lands where the pointer was sent
if (down) ctrl.held.add(name); else ctrl.held.delete(name);
ctrlSend([ctrlAim({ button: name, down })]);
}
function ctrlKeyEvent(code, down) {
ctrlFlushMoves();
if (down) ctrl.keys.add(code); else ctrl.keys.delete(code);
ctrlSend([ctrlAim({ key: code, down })]);
}
function ctrlClick(name = "left") { ctrlButton(name, true); ctrlButton(name, false); }
function ctrlRelease() {
for (const b of [...ctrl.held]) ctrlButton(b, false);
for (const k of [...ctrl.keys]) ctrlKeyEvent(k, false);
if (ctrl.g && ctrl.g.timer) clearTimeout(ctrl.g.timer);
ctrl.pointers.clear(); ctrl.g = null;
}
// Where a pointer event falls on the panel, as fractions (the canvas box includes any letterboxing).
function ctrlFraction(e) {
const c = $("canvas"), r = c.getBoundingClientRect();
const scale = Math.min(r.width / c.width, r.height / c.height);
const w = c.width * scale, h = c.height * scale;
const x = (e.clientX - r.left - (r.width - w) / 2) / w, y = (e.clientY - r.top - (r.height - h) / 2) / h;
return x >= 0 && x <= 1 && y >= 0 && y <= 1 ? [x, y] : null;
}
// Mouse (and pen) on a computer: the view is the Frame's screen.
function ctrlMouse(e) {
if (ctrlMode() === "abs") {
const f = ctrlFraction(e);
if (f) ctrlMoveTo(...f);
} else if (e.type === "pointermove" && (e.movementX || e.movementY)) {
ctrlMoveBy(e.movementX * 1.5, e.movementY * 1.5);
}
}
// Touch: tap to click, press and hold to right-click, two fingers to scroll. Dragging is a
// mouse drag on the Desktop view (sliders, selecting text) and moves the pointer on the headset view.
const TAP_MOVE = 8, HOLD_MS = 550, TOUCH_SPEED = 1.6, SCROLL_SPEED = 2;
function ctrlTouchDown(e) {
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (ctrl.pointers.size === 1) {
const g = ctrl.g = { kind: "maybe", x: e.clientX, y: e.clientY, e, target: ctrlAimedAt() };
g.timer = setTimeout(() => {
if (ctrl.g !== g || g.kind !== "maybe" || !ctrlSameTarget(g)) return;
g.kind = "held";
if (ctrlMode() === "abs") {
const f = ctrlFraction(g.e);
if (!f) return; // on the bars around the picture: nothing to press
ctrlMoveTo(...f);
}
ctrlClick("right");
navigator.vibrate?.(10);
}, HOLD_MS);
} else if (ctrl.pointers.size === 2) {
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
if (ctrl.held.has("left")) ctrlButton("left", false);
ctrl.g = { kind: "scroll", ...centroid() };
}
}
// The panel a gesture started on; if that stops being the one in use, the gesture
// does nothing more (except let go of what it holds).
function ctrlAimedAt() { return ctrlMode() === "abs" ? panelKey(ctrlTarget().panel) : "headset"; }
function ctrlSameTarget(g) { return g.target && g.target === ctrlAimedAt(); }
function centroid() {
const ps = [...ctrl.pointers.values()];
return { x: ps.reduce((a, p) => a + p.x, 0) / ps.length, y: ps.reduce((a, p) => a + p.y, 0) / ps.length };
}
function ctrlTouchMove(e) {
const prev = ctrl.pointers.get(e.pointerId);
if (!prev || !ctrl.g) return;
const g = ctrl.g, dx = e.clientX - prev.x, dy = e.clientY - prev.y;
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (g.kind === "scroll") {
const c = centroid();
// Content follows the fingers, as on the phone itself.
ctrlSend([{ scroll: [-(c.x - g.x) * SCROLL_SPEED, -(c.y - g.y) * SCROLL_SPEED] }]);
g.x = c.x; g.y = c.y;
return;
}
if (g.kind === "maybe" && Math.hypot(e.clientX - g.x, e.clientY - g.y) > TAP_MOVE) {
clearTimeout(g.timer);
if (!ctrlSameTarget(g)) { g.kind = "done"; return; }
if (ctrlMode() === "abs") {
const f = ctrlFraction(g.e);
if (f) { ctrlMoveTo(...f); ctrlButton("left", true); }
g.kind = "drag";
} else g.kind = "move";
}
if (g.kind === "drag") { const f = ctrlFraction(e); if (f) ctrlMoveTo(...f); }
else if (g.kind === "move") ctrlMoveBy(dx * TOUCH_SPEED, dy * TOUCH_SPEED);
}
function ctrlTouchUp(e) {
const g = ctrl.g;
ctrl.pointers.delete(e.pointerId);
if (!g) return;
if (g.kind === "scroll") g.kind = "done"; // the finger left behind doesn't jump the scroll or drag
if (g.kind === "maybe" && !ctrlSameTarget(g)) g.kind = "done";
if (g.kind === "maybe" && ctrl.pointers.size === 0) {
clearTimeout(g.timer);
if (ctrlMode() === "abs") { const f = ctrlFraction(g.e); if (!f) { ctrl.g = null; return; } ctrlMoveTo(...f); }
ctrlClick("left");
} else if (g.kind === "drag") ctrlButton("left", false);
if (ctrl.pointers.size === 0) ctrl.g = null;
}
// The system took the touch (a swipe home, a call): no tap, and let go of anything held.
function ctrlTouchCancel(e) {
ctrl.pointers.delete(e.pointerId);
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
if (ctrl.held.has("left")) ctrlButton("left", false);
if (ctrl.g) ctrl.g.kind = "done";
if (ctrl.pointers.size === 0) ctrl.g = null;
}
const canvasEl = $("canvas");
canvasEl.addEventListener("pointerdown", e => {
if (!ctrl.on) return;
e.preventDefault();
$("viewer").focus({ preventScroll: true });
try { canvasEl.setPointerCapture(e.pointerId); } catch { /* a pointer that's already gone */ }
if (!ctrlTarget().ok) return ctrlShow();
if (e.pointerType === "touch") return ctrlTouchDown(e);
if (ctrlMode() === "abs" && !ctrlFraction(e)) return; // on the bars around the picture
ctrlMouse(e);
ctrlButton(CTRL_BUTTONS[e.button] || "left", true);
});
canvasEl.addEventListener("pointermove", e => {
if (!ctrl.on || !ctrlTarget().ok) return;
if (e.pointerType === "touch") return ctrlTouchMove(e);
ctrlMouse(e);
});
for (const type of ["pointerup", "pointercancel"]) canvasEl.addEventListener(type, e => {
if (!ctrl.on) return;
if (e.pointerType === "touch") return type === "pointercancel" ? ctrlTouchCancel(e) : ctrlTouchUp(e);
if (type === "pointercancel") return [...ctrl.held].forEach(b => ctrlButton(b, false)); // no button is named
const name = CTRL_BUTTONS[e.button] || "left";
if (ctrl.held.has(name)) ctrlButton(name, false);
});
canvasEl.addEventListener("contextmenu", e => { if (ctrl.on) e.preventDefault(); });
$("viewer").addEventListener("wheel", e => {
if (!ctrl.on || !ctrlTarget().ok) return;
e.preventDefault();
const unit = e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 800 : 1;
ctrlSend([{ scroll: [e.deltaX * unit, e.deltaY * unit] }]);
}, { passive: false });
// Keys while the view has focus go to the Frame (⌘ as Ctrl on a Mac); click elsewhere to stop.
function ctrlKey(e, down) {
if (!ctrl.on || document.activeElement !== $("viewer") || (down && !ctrlTarget().ok)) return;
let code = EVDEV[e.code];
if (IS_MAC && (e.code === "MetaLeft" || e.code === "MetaRight")) code = 29;
if (!code) return;
e.preventDefault();
e.stopPropagation(); // it's for the Frame, not Frame Control's own shortcuts
if (down && e.repeat && [29, 42, 54, 56, 97, 100, 125, 126].includes(code)) return;
if (!down && !ctrl.keys.has(code)) return;
ctrlKeyEvent(code, down);
}
$("viewer").addEventListener("keydown", e => ctrlKey(e, true));
$("viewer").addEventListener("keyup", e => ctrlKey(e, false));
// Phone keyboard: the field types plain text; the buttons send single keys and clicks.
function ctrlTap(code) { ctrlKeyEvent(code, true); ctrlKeyEvent(code, false); }
let ctrlWarned = false;
function ctrlText(text) {
const plain = text.replace(/[^\x20-\x7e\n\t]/g, "");
if (plain !== text && !ctrlWarned) {
ctrlWarned = true;
toast("Control types plain letters, numbers and symbols. For accents and emoji, use Keyboard and trackpad below.");
}
// Small pieces: the Frame types them key by key, and a release queued behind mustn't wait long.
for (const part of plain.match(/[\s\S]{1,100}/g) || []) ctrlSend([{ text: part }]);
}
$("ctrlType").addEventListener("beforeinput", e => {
if (e.isComposing) return;
e.preventDefault();
if (!ctrlTarget().ok) return ctrlShow();
if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") return ctrlTap(28);
if (e.inputType === "deleteContentBackward") return ctrlTap(14);
if (e.inputType === "deleteContentForward") return ctrlTap(111);
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
if (text) ctrlText(text);
});
$("ctrlType").addEventListener("compositionend", e => { if (e.data) ctrlText(e.data); $("ctrlType").value = ""; });
$("ctrlKeys").addEventListener("click", e => {
const k = e.target.closest("[data-ctrl-key]"), c = e.target.closest("[data-ctrl-click]");
if (!ctrlTarget().ok) return ctrlShow();
if (k) ctrlTap(+k.dataset.ctrlKey);
if (c) ctrlClick(c.dataset.ctrlClick);
});
// ---- controls ----
$("refreshAll").onclick = () => { refresh(); loadTitles(); }; // titles too: the Frame may have been asleep at start
document.addEventListener("keydown", e => {
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
});
document.body.addEventListener("click", async (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.launch) act(`Launch ${b.dataset.name}`, () => api("/api/launch", { appid: b.dataset.launch }), b);
else if (b.dataset.uninstall) {
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
} else if (b.dataset.quick) installFlatpak(b.dataset.quick);
else if (b.dataset.open) {
const what = b.dataset.open, label = b.textContent.trim();
// On the phone, power needs the password here, and the rest opens other apps.
if (HOST.mobile && POWER[what]) return askPower(what);
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
if (HOST.mobile && window.frameApp?.open) return act(label, () => window.frameApp.open(what), b);
act(label, () => api("/api/open", { what }), b);
}
});
// Installs run as server jobs: start one, then poll until it's done. The
// bottom bar counts what's running; `installing` keeps re-rendered lists from
// offering Install again for something already on its way.
const installing = new Set();
let jobsRunning = 0;
function showJobs() {
$("jobs").hidden = !jobsRunning;
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
}
async function runJob(label, key, start, onProgress = () => {}) {
installing.add(key); jobsRunning++; showJobs();
log(label + "…");
try {
const { job } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
onProgress(j);
if (!j.done) continue;
if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message);
return j.result || {};
}
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
} finally { installing.delete(key); jobsRunning--; showJobs(); }
}
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
poweroff: ["Shut the Frame down?", "Shut down"] };
function askPower(what) {
const [title, verb] = POWER[what], dlg = $("pwDlg");
$("pwTitle").textContent = title; $("pwGo").textContent = verb;
$("pwInput").value = ""; $("pwMsg").textContent = "";
$("pwCancel").onclick = () => dlg.close();
$("pwForm").onsubmit = async e => {
e.preventDefault();
const password = $("pwInput").value;
if (!password) return ($("pwMsg").textContent = "Enter the password");
$("pwGo").disabled = true; $("pwMsg").textContent = "";
try {
const res = await api("/api/open", { what, password });
dlg.close(); log(res.message, "ok"); toast(res.message);
} catch (err) { $("pwMsg").textContent = err.message; }
finally { $("pwGo").disabled = false; $("pwInput").value = ""; }
};
dlg.showModal();
$("pwInput").focus();
}
$("changeHeadset").onclick = () => window.frameApp?.setUpConnection && window.frameApp.setUpConnection();
function installFlatpak(id) {
if (installing.has(id)) return;
toast(`Installing ${id}. This can take a few minutes…`);
const done = runJob(`Install ${id}`, id, () => api("/api/flatpak", { action: "install", id })).then(refresh);
if (state) render(state); // show it as installing
return done;
}
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) { installFlatpak(id); $("fpId").value = ""; } };
$("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); });
function setVolume(pct, muted) {
$("vol").value = pct;
$("vol").style.setProperty("--fill", pct + "%");
$("volNum").textContent = pct + "%";
if (muted !== undefined) $("volWaves").style.display = muted ? "none" : "";
}
$("vol").oninput = () => {
setVolume(+$("vol").value);
clearTimeout(volTimer);
const dev = window.connDevice; // the headset whose slider this was, even 250 ms later
volTimer = setTimeout(async () => {
try { await api("/api/volume", { level: $("vol").value / 100 }, dev); } catch (e) { toast(e.message, true); }
volTimer = null;
}, 250);
};
$("muteBtn").onclick = () => {
const muted = !(state?.volume?.muted);
act(muted ? "Mute" : "Unmute", () => api("/api/volume", { muted }), $("muteBtn")).then(refresh);
};
$("clipSend").onclick = () => {
const text = $("clipText").value;
if (!text) return toast("Nothing to send", true);
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
};
// In the app, Electron reads the clipboard; in a browser, the server does.
async function sendComputerClipboard() {
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
const device = window.connDevice; // the headset it was sent to, not one switched to while reading
const text = await window.frameApp.readClipboard();
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
return api("/api/clipboard", { text }, device);
}
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
// ---- file drop ----
const drop = $("drop");
drop.onclick = () => $("fileInput").click();
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
// Files can be dropped anywhere in the window; an overlay says what happens.
// Dialogs keep their own drops (none take files today), so the overlay stays out of them.
let dragDepth = 0;
const carriesFiles = e => [...(e.dataTransfer?.types || [])].includes("Files");
window.addEventListener("dragenter", e => {
if (!carriesFiles(e) || document.querySelector("dialog[open]")) return;
e.preventDefault();
if (dragDepth++ === 0) { $("dropzone").hidden = false; drop.classList.add("over"); }
});
window.addEventListener("dragover", e => { if (carriesFiles(e)) e.preventDefault(); });
window.addEventListener("dragleave", e => {
if (!carriesFiles(e) || dragDepth === 0) return;
if (--dragDepth === 0) { $("dropzone").hidden = true; drop.classList.remove("over"); }
});
window.addEventListener("drop", e => {
if (!carriesFiles(e)) return;
e.preventDefault();
dragDepth = 0; $("dropzone").hidden = true; drop.classList.remove("over");
if (document.querySelector("dialog[open]")) return;
// Folders only show up as directory entries, and only during the drop event itself.
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
sendFiles([...e.dataTransfer.files], dirs);
});
function upload(file, mode, dev = window.connDevice) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
if (dev) xhr.setRequestHeader("X-Frame-Device", dev);
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
xhr.setRequestHeader("X-Mode", mode);
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
const bar = $("prog").firstElementChild;
$("prog").style.display = "block"; bar.style.width = "0";
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
const gen = devGen;
xhr.onload = () => {
$("prog").style.display = "none";
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
if (gen !== devGen) { // the app switched headsets meanwhile: nothing to offer about this one here
const err = new Error(data.error || "Switched headsets");
err.stale = true;
return xhr.status < 300 ? resolve(data) : reject(err);
}
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk, dev);
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
};
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
xhr.send(file);
});
}
async function refreshMedia(selectId) {
const data = await api("/api/media", { action: "list" });
const selected = selectId || $("mediaFile").value;
$("mediaFile").replaceChildren();
if (!data.files.length) $("mediaFile").add(new Option("Send media to begin", ""));
for (const file of data.files) $("mediaFile").add(new Option(file.name, file.id));
if (data.files.some(f => f.id === selected)) $("mediaFile").value = selected;
const p = data.player;
$("mediaState").textContent = p.error || `${p.state}${p.file ? ": " + p.file : ""}`;
return data;
}
$("mediaChoose").onclick = () => $("mediaInput").click();
$("mediaInput").onchange = () => act("Send media", async () => {
const file = $("mediaInput").files[0];
if (!file) return;
const result = await upload(file, "media");
await refreshMedia(result.id);
$("mediaInput").value = "";
return result;
}, $("mediaChoose"));
$("mediaRefresh").onclick = () => act("Refresh media", () => refreshMedia(), $("mediaRefresh"));
$("mediaPlay").onclick = () => act("Play media", async () => {
const id = $("mediaFile").value;
if (!id) throw new Error("Send or select a media file first");
const result = await api("/api/media", { action: "play", id,
layout: $("mediaLayout").value, theatre: $("mediaTheatre").checked });
$("mediaState").textContent = "Starting…";
setTimeout(() => refreshMedia().catch(e => { $("mediaState").textContent = e.message; }), 1500);
return result;
}, $("mediaPlay"));
$("mediaStop").onclick = () => act("Stop media", async () => {
const result = await api("/api/media", { action: "stop" });
await refreshMedia();
return result;
}, $("mediaStop"));
let apkLookup = 0;
async function checkApkAlternatives(apk, dev = window.connDevice) {
const lookup = ++apkLookup;
$("apkAltReason").textContent = apk.blocker;
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
$("apkAltVersions").onclick = null;
for (const id of ["apkAltNote", "apkAltErrors", "apkAltLinks"]) $(id).textContent = "";
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
const query = new URLSearchParams({package: apk.package});
if (apk.version_code != null) query.set("code", apk.version_code);
try {
const result = await api(`/api/apk-versions?${query}`);
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result, dev);
} catch (e) {
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
}
}
function showApkAlternatives(reason, result, dev = window.connDevice) {
$("apkAltReason").textContent = reason;
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
$("apkAltErrors").textContent = result.errors.join(" · ");
$("apkAltLinks").innerHTML = result.links.map(l => `<a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.source)}</a>`).join(" · ");
$("apkAltVersions").innerHTML = result.versions.length ? result.versions.map((v, i) =>
`<div class="item"><div class="grow"><div class="t">${esc(v.version || "?")} <span class="sub">code ${esc(v.version_code)}</span></div>
<div class="s">${esc(v.source)} · minimum API ${esc(v.min_sdk)} · ${esc(v.abis.join(", ") || "no native code")}</div></div>
<button class="small" data-version="${i}" ${v.sha256 ? "" : "disabled"}>Install</button></div>`).join("") :
`<p>No compatible version found in F-Droid. Try the searches below.</p>`;
$("apkAltVersions").onclick = async e => {
const b = e.target.closest("[data-version]"); if (!b) return;
const v = result.versions[+b.dataset.version];
if (installing.has(result.package)) return;
b.disabled = true; b.textContent = "Installing…";
// For the headset the APK was checked against: refused (409) if the app has switched since.
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
action: "install", package: result.package, url: v.url
}, dev));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
if (res && res.app) await offerTest(res.app);
};
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
}
$("apkAltClose").onclick = () => $("apkAltDlg").close();
// ---- about and licences ----
document.addEventListener("click", async e => {
if (!e.target.closest("[data-about]")) return;
e.preventDefault();
$("aboutDlg").showModal();
try {
const { notices } = await api("/api/licenses");
$("aboutText").innerHTML = notices.map((n, i) =>
`<details${i ? "" : " open"}><summary>${esc(n.title)}</summary><pre>${esc(n.text)}</pre></details>`).join("");
} catch (err) {
$("aboutText").textContent = `Couldn't load the licences: ${err.message}. They're also in THIRD_PARTY_NOTICES.md and frame/kdeconnect in Frame Control's source.`;
}
});
$("aboutClose").onclick = () => $("aboutDlg").close();
const TITLE_EXT = /\.(zip|exe)$/i;
async function sendFiles(files, dirs = new Set()) {
const dev = window.connDevice; // the whole batch is for the headset it was dropped on
for (const [i, f] of files.entries()) {
if (window.connDevice !== dev) {
toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true);
break;
}
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
if (dirs.has(i)) {
if (path) await sideload(f, path, true);
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
continue;
}
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev));
if (apk && res && res.app) await offerTest(res.app);
}
$("fileInput").value = "";
refresh();
}
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
const trim = x => x.replace(/^_+|_+$/g, "");
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9]+/g, "_")).slice(0, 64));
if (!s) return s;
if (/^[0-9]/.test(s)) s = "_" + s.slice(0, 63);
if (s.length < 2 || RESERVED_IDS.includes(s.toLowerCase())) s += "_game";
return s;
}
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
function candLabel(c) {
const kind = c.format === "pe" ? `Windows ${ARCH[c.arch] || c.arch}` : c.format === "elf" ? `Linux ${ARCH[c.arch] || c.arch}` : "script";
return `${c.path} · ${kind} · ${gb(c.size)}${c.skip ? " · helper?" : ""}`;
}
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
// One at a time, so a second drop doesn't take over the dialog of the first.
let sideloadQueue = Promise.resolve();
function sideload(file, path, isDir = false) {
const dev = window.connDevice; // for the headset it was dropped on, even if it waits its turn
const run = sideloadQueue.then(() => {
if (window.connDevice !== dev) return toast(`Switched headsets: didn't add ${file.name}`, true);
return sideloadOne(file, path, isDir, dev);
});
sideloadQueue = run.catch(() => {});
return run;
}
async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
const canPush = !isDir && file.size > 0;
log(`Reading ${file.name}…`);
let r;
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title", dev); }
catch (e) {
log(`${file.name}: ${e.message}`, "e");
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
return;
}
// Its own fresh list, so the dialog can say whether this replaces an installed title.
let installed = null;
try { installed = new Set((await api("/api/titles")).titles.map(t => String(t.id).toLowerCase())); } catch {}
const choice = await confirmTitle(r.plan, canPush, installed);
if (choice === "push") {
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
return;
}
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
await installTitle(r.token, choice);
}
function confirmTitle(plan, canPush, installed) { // installed: a Set of lower-case ids, or null if unknown
return new Promise(resolve => {
const dlg = $("titleDlg");
$("titlePush").hidden = !canPush;
$("titleSrc").textContent = `${plan.source} · ${gb(plan.size)}`;
$("titleName").value = plan.name;
const idNote = () => { const id = titleId($("titleName").value);
$("titleIdNote").textContent = !id ? "Needs some letters or digits"
: !installed ? `Shows in Steam as ${id}. Couldn't check whether it's installed already; if it is, this replaces it`
: installed.has(id.toLowerCase()) ? `Replaces the installed ${id}, and everything in its folder on the Frame`
: `Shows in Steam as ${id}`; };
$("titleName").oninput = idNote; idNote();
$("titleExe").innerHTML = plan.candidates.map(c =>
`<option value="${esc(c.path)}"${c.path === plan.target ? " selected" : ""}${c.blocked ? " disabled" : ""}>${esc(candLabel(c))}</option>`).join("");
const runtimes = () => {
const c = plan.candidates.find(x => x.path === $("titleExe").value) || {};
const list = c.runtimes || [];
$("titleRt").innerHTML = list.map(k => `<option value="${esc(k)}">${esc(plan.runtime_labels[k] || k)}</option>`).join("");
$("titleRt").disabled = list.length < 2;
if (c.path === plan.target) {
$("titleRt").value = plan.runtime;
$("titleNote").textContent = plan.note + " Device behaviour is inferred from Valve's devkit tools.";
} else $("titleNote").textContent = "Runtime follows the chosen program.";
$("titleWarn").textContent = c.skip ? "This looks like an installer or helper, not the game itself." : "";
};
$("titleExe").onchange = runtimes; runtimes();
$("titleMsg").textContent = "";
// Resolve on this dialog's own close event (it arrives a frame later), so a
// queued next title can't receive it. Escape closes with the choice left null.
let choice = null;
dlg.addEventListener("close", () => resolve(choice), { once: true });
const finish = v => { choice = v; dlg.close(); };
$("titleCancel").onclick = () => finish(null);
$("titlePush").onclick = () => finish("push");
$("titleForm").onsubmit = e => {
e.preventDefault();
if (!titleId($("titleName").value)) return ($("titleMsg").textContent = "Enter a name");
finish({ name: $("titleName").value.trim(), exe: $("titleExe").value, runtime: $("titleRt").value });
};
dlg.showModal();
$("titleName").focus();
});
}
async function installTitle(token, choice) {
const label = `Install ${titleId(choice.name)}`;
const start = await act(label, () => api("/api/titles", { action: "install", token, ...choice }));
if (!start) return;
const bar = $("prog").firstElementChild;
$("prog").style.display = "block"; bar.style.width = "0";
let stage = "";
try {
for (;;) {
await new Promise(ok => setTimeout(ok, 1000));
let j;
try { j = await api(`/api/titles/job?token=${encodeURIComponent(start.job)}`); }
catch (e) { log(`${label}: ${e.message}`, "e"); return; }
bar.style.width = (100 * (j.fraction || 0)) + "%";
if (j.stage && j.stage !== stage && !j.done) { stage = j.stage; log(`${stage}…`); $("lastLog").textContent = stage + "…"; }
if (j.done) {
if (j.error) { log(`${label} failed: ${j.error}`, "e"); toast(`${label} failed: ${j.error}`, true); }
else { log(j.message, "ok"); toast(j.message); }
break;
}
}
} finally { $("prog").style.display = "none"; loadTitles(); }
}
let titlesSeq = 0; // a slower, older request mustn't overwrite a newer list
async function loadTitles() {
const seq = ++titlesSeq;
let list, err;
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
if (seq !== titlesSeq) return;
if (err) return failed($("titleList"), err);
$("titleCount").textContent = list.length ? `${list.length}` : "";
$("titleList").innerHTML = list.length ? list.map(t => `
<div class="item">
<div class="grow"><div class="t">${esc(t.id)}</div>
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
${t.art_missing ? `<button class="small" data-title="refresh-art" data-id="${esc(t.id)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
</div>`).join("") : `<div class="sub">None yet.</div>`;
}
$("titleList").onclick = async e => {
const b = e.target.closest("[data-title]"); if (!b) return;
const id = b.dataset.id;
if (b.dataset.title === "remove") {
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
loadTitles();
} else if (b.dataset.title === "refresh-art") {
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
loadTitles();
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
};
loadTitles();
$("titleAdd").onclick = () => $("fileInput").click();
// ---- Steam games: install owned games, find and buy on the store ----
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
const FCLASS = { 3: "v-works", 2: "v-maybe", 1: "v-no", 0: "f-0" };
const gm = { src: "owned", owned: null, byId: new Map(), country: null, dl: null, q: "",
// Rating filters per view: most store results aren't rated yet, so show them all there.
filters: { owned: new Set(["3", "2"]), store: new Set(["3", "2", "0", "1"]) },
results: [], shown: 0, store: [], storeQ: null, poll: null, seq: 0 };
async function loadOwned() {
$("gmReload").disabled = true;
try {
const r = await api("/api/steam/owned");
gm.owned = r.games; gm.country = r.country || gm.country; gm.dl = r.download;
gm.byId = new Map(gm.owned.map(g => [g.id, g]));
} catch (e) {
if (!gm.owned) failed($("gmGrid"), e);
if (!e.offline) log("Steam library: " + e.message, "e");
// Keep trying while a download was in progress; one failed poll shouldn't freeze it.
clearTimeout(gm.poll);
if (gm.dl) gm.poll = setTimeout(loadOwned, 15000);
return;
} finally { $("gmReload").disabled = false; }
const d = gm.dl, name = d && gm.byId.get(d.appid)?.name;
$("gmDl").textContent = d ? `${d.paused ? "Paused" : "Downloading"} ${name || d.appid} · ${d.percent ?? 0}%` +
(d.eta > 0 && !d.paused ? ` · ${dur(d.eta)} left` : "") : "";
// Keep polling while something downloads so the progress and Installed tags update.
clearTimeout(gm.poll);
if (d) gm.poll = setTimeout(loadOwned, document.hidden ? 30000 : 5000);
renderChips(); filterGames(true);
}
function renderChips() {
const counts = {};
(gm.src === "owned" ? gm.owned || [] : gm.store).forEach(g => counts[g.frame] = (counts[g.frame] || 0) + 1);
$("gmChips").innerHTML = [3, 2, 0, 1].map(k => `<button class="chip vchip ${FCLASS[k]}" data-f="${k}"
aria-pressed="${gm.filters[gm.src].has(String(k))}">${FLABEL[k]} (${(counts[k] || 0).toLocaleString()})</button>`).join("");
}
function filterGames(keepScroll) {
const y = window.scrollY;
if (gm.src === "owned") {
if (!gm.owned) return;
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
gm.results = gm.owned.filter(g => gm.filters.owned.has(String(g.frame)) && terms.every(t => g.name.toLowerCase().includes(t)));
// Installed and downloading first, then recently played, then by name.
gm.results.sort((a, b) => (b.installed - a.installed) || (b.lastPlayed - a.lastPlayed) || a.sort.localeCompare(b.sort));
$("gmCount").textContent = `${gm.results.length.toLocaleString()} of ${gm.owned.length.toLocaleString()} owned`;
} else {
gm.results = gm.store.filter(g => gm.filters.store.has(String(g.frame)));
$("gmCount").textContent = gm.storeQ ? `${gm.results.length} of ${gm.store.length} store results` : "";
}
const n = Math.max(48, keepScroll ? gm.shown : 0);
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
if (!gm.results.length) $("gmGrid").innerHTML = `<div class="sub">${gm.src === "store" && !gm.storeQ
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}</div>`;
if (keepScroll) window.scrollTo(0, y);
}
function price(p) {
if (!p) return "Free";
const f = n => { try { return new Intl.NumberFormat(undefined, { style: "currency", currency: p.currency }).format(n / 100); }
catch { return `${(n / 100).toFixed(2)} ${esc(p.currency || "")}`; } };
const fin = p.final ?? p.initial ?? 0;
return p.initial > fin ? `<s>${f(p.initial)}</s>${f(fin)}` : f(fin);
}
function gameButtons(id, name, free) {
const g = gm.byId.get(id), d = gm.dl;
const storeUrl = `https://store.steampowered.com/app/${id}/`;
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
if (g && g.installed) return `<span class="tag">Installed</span>` +
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
}
function moreGames(count = 48) {
const next = gm.results.slice(gm.shown, gm.shown + count);
$("gmGrid").insertAdjacentHTML("beforeend", next.map(g => {
const own = gm.byId.get(g.id), d = gm.dl && gm.dl.appid === g.id ? gm.dl : null;
const sub = gm.src === "owned"
? [g.minutes ? `${(g.minutes / 60).toFixed(g.minutes < 600 ? 1 : 0)} h played` : "Never played", g.vrOnly ? "VR only" : g.vr ? "VR" : "",
g.installed && g.size ? gb(g.size) : ""].filter(Boolean).join(" · ")
: `<span class="price">${own ? "In your library" : price(g.price)}</span>${g.metascore ? ` · Metacritic ${esc(g.metascore)}` : ""}`;
return `<div class="cat-card game-card">
<img class="art" loading="lazy" src="${CDN}/${g.id}/capsule_231x87.jpg" alt="" onerror="this.style.visibility='hidden'">
<div class="top"><div style="min-width:0"><div class="t">${esc(g.name)}</div><div class="s">${sub}</div></div>
<span class="vbadge ${FCLASS[g.frame] ?? FCLASS[0]}">${FLABEL[g.frame] ?? FLABEL[0]}</span></div>
${d ? `<div class="bar"><i style="width:${d.percent ?? 0}%"></i></div>` : ""}
<div class="row">${gameButtons(g.id, g.name, gm.src === "store" && !g.price)}</div>
</div>`;
}).join(""));
gm.shown += next.length;
$("gmMore").hidden = gm.shown >= gm.results.length;
}
async function searchStore() {
const q = $("gmQ").value.trim();
if (!q) { gm.store = []; gm.storeQ = null; renderChips(); return filterGames(); }
if (!gm.country) await loadOwned(); // the store search needs the account's country
if (!gm.country) return toast("Couldn't read the Steam account's country from the Frame", true);
const seq = ++gm.seq;
$("gmGrid").innerHTML = `<div class="sub">Searching the Steam store…</div>`;
try {
const r = await api(`/api/steam/search?q=${encodeURIComponent(q)}&cc=${gm.country}`);
if (seq !== gm.seq) return;
gm.store = r.results; gm.storeQ = q;
} catch (e) { if (seq === gm.seq) $("gmGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
renderChips(); filterGames();
}
function setGameSource(src) {
gm.src = src;
document.querySelectorAll("[data-src]").forEach(b => b.classList.toggle("on", b.dataset.src === src));
$("gmQ").placeholder = src === "owned" ? "Filter your games…" : "Search the Steam store, then press Enter…";
$("gmQ").value = src === "owned" ? gm.q : gm.storeQ || "";
renderChips(); filterGames();
}
$("gmSrc").onclick = e => { const b = e.target.closest("[data-src]"); if (b) setGameSource(b.dataset.src); };
let gmTimer;
$("gmQ").oninput = e => {
if (gm.src !== "owned") return;
clearTimeout(gmTimer); gmTimer = setTimeout(() => { gm.q = e.target.value; filterGames(); }, 150);
};
$("gmQ").addEventListener("keydown", e => { if (e.key === "Enter" && gm.src === "store") searchStore(); });
$("gmReload").onclick = loadOwned;
$("gmMore").onclick = () => moreGames();
$("gmChips").onclick = e => {
const b = e.target.closest("[data-f]"); if (!b) return;
const f = gm.filters[gm.src];
f.has(b.dataset.f) ? f.delete(b.dataset.f) : f.add(b.dataset.f);
b.setAttribute("aria-pressed", f.has(b.dataset.f)); filterGames();
};
document.body.addEventListener("click", async e => {
const b = e.target.closest("[data-gm]"); if (!b) return;
const action = b.dataset.gm, appid = b.dataset.id, name = b.dataset.name;
const res = await act(action === "install" ? `Install ${name}` : `Open ${name} in the headset store`,
() => api("/api/steam", { action, appid }), b);
if (action === "install" && res) loadOwned();
});
loadOwned();
// ---- Discover apps ----
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const storeInitial = name => [...String(name || "?")][0].toUpperCase();
const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
}
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourceFilter").value = sourceState.source;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty() {
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
}
function renderStore() {
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true">&nbsp; ↗</span></span></span></button>` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
}
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
async function searchSources(quiet) {
const request=++sourceState.request;
$("searchGrid").setAttribute("aria-busy","true");
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
sourceState.apps=result.apps;
const unavailable=result.sources.filter(s=>s.status==='error'||s.status==='timed out').map(s=>storeName(s.name));
const loading=result.sources.filter(s=>s.status==='loading').map(s=>storeName(s.name));
const notes=[];
if(loading.length)notes.push(`Still fetching ${loading.join(" and ")}; more results will appear in a moment.`);
if(unavailable.length)notes.push(`${unavailable.join(" and ")} ${unavailable.length===1 ? "isn't" : "aren't"} responding right now. You can still explore the other sources.`);
result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`));
const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name));
if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`);
$("sourceStatus").innerHTML=notes.map(esc).join(" ")+(result.elsewhere||[]).map(x=>` <a href="${esc(x.url)}" target="_blank" rel="noopener">Browse ${esc(storeName(x.name))} ↗</a>`).join("");
$("sourceStatus").hidden=!notes.length && !(result.elsewhere||[]).length;
clearTimeout(sourceState.retry);
if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000);
renderStore();await loadSources();
} catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } }
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function renderDetail() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
function updateInstall() {
const o=currentOffer();if(!o || !$("detailInstall"))return;
const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall");
b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page);
b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗';
$("detailProgress").hidden=!job || job.done;
if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value");
$("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
$("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done);
}
async function openStoreApp(index) {
const app=sourceState.apps[index];if(!app)return;
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
await loadOfferDetails();
}
async function loadOfferDetails() {
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
try {
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
}
async function installStoreApp() {
const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key);
if(existing?.result?.game_data) {
let error="";existing.addingData=true;existing.dataError="";updateInstall();
const added=await runJob(`Add game data for ${o.name}`,`${key}:data`,()=>api('/api/sources',{action:'game-data',package:existing.result.package}),j=>{error=j.error||"";});
Object.assign(existing,{addingData:false,dataError:added?"":error||"We couldn't add the game data. Try again."});
if(added)existing.result={...existing.result,game_data:false};
updateInstall();return;
}
if(existing?.result) { await act(`Open ${o.name}`,()=>api('/api/android',{action:'launch',package:existing.result.package}),$("detailInstall"));return; }
if(!o.downloadable) { const url=sourcePage(o.page);if(url)window.open(url,'_blank','noopener,noreferrer');return; }
if(existing&&!existing.done)return;
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
if(result)loadAndroid();
}
async function changeSource(body) {
$("sourceError").textContent="Saving your sources…";
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;sourceState.source='';await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
let storeSearchTimer;
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
async function addSource(body) {
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
let error="";
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
}
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
loadSources().then(()=>searchSources());
// ---- Android apps (own Lepton instance each) + catalogue ----
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
let androidApps = [];
async function loadAndroid() {
try {
androidApps = (await api("/api/android")).apps;
} catch (e) { return failed($("andApps"), e); }
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
const c = cat.apps && cat.apps.find(x => x.p === a.package);
const icon = c && c.i ? `<img class="and-icon" src="${esc(c.i)}" alt="">` : `<div class="and-icon"></div>`;
return `<div class="item and-item">${icon}
<div class="grow"><div class="t">${esc(a.label)}</div>
<div class="s">${esc(a.package)} · v${esc(String(a.version || "").replace(/^v/i, ""))}</div></div>
${a.running ? `<span class="tag">Running</span>` : ""}
<div class="row and-btns">
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
</div></div>`;
}).join("") : `<div class="sub">No Android apps yet. Install one from the catalogue, or drop an .apk on Send to Frame.</div>`;
const installed = new Set(androidApps.map(a => a.package));
document.querySelectorAll('#catGrid [data-and="install"]').forEach(b => {
if (installed.has(b.dataset.pkg)) b.outerHTML = `<span class="tag">Installed</span>`;
});
}
async function loadCatalog() {
try { cat.apps = (await api("/api/android/catalog")).apps; }
catch (e) { $("catGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
cat.apps.forEach(a => a.hay = [a.n, a.s, a.p, ...a.c].join(" ").toLowerCase());
const cats = [...new Set(cat.apps.flatMap(a => a.c))].sort();
$("catCat").innerHTML = `<option value="">All categories</option>` + cats.map(c => `<option>${esc(c)}</option>`).join("");
$("catCat").value = cat.cat;
const counts = {}; cat.apps.forEach(a => counts[a.r] = (counts[a.r] || 0) + 1);
$("catChips").innerHTML = Object.keys(VLABEL).map(k =>
`<button class="chip vchip v-${k}" data-v="${k}" aria-pressed="${cat.v.has(k)}">${VLABEL[k]} (${(counts[k] || 0).toLocaleString()})</button>`).join("");
filterCatalog();
loadAndroid(); // icons for installed apps come from the catalogue
}
function filterCatalog() {
const terms = cat.q.toLowerCase().split(/\s+/).filter(Boolean);
cat.results = cat.apps.filter(a => cat.v.has(a.r) && (!cat.cat || a.c.includes(cat.cat)) && terms.every(t => a.hay.includes(t)));
if (terms.length) {
const t = terms.join(" "), score = a => { const n = a.n.toLowerCase(); return n === t ? 0 : n.startsWith(t) ? 1 : n.includes(t) ? 2 : 3; };
const order = Object.keys(VLABEL);
cat.results.sort((x, y) => order.indexOf(x.r) - order.indexOf(y.r) || score(x) - score(y));
}
$("catCount").textContent = `${cat.results.length.toLocaleString()} of ${cat.apps.length.toLocaleString()}`;
$("catGrid").innerHTML = ""; cat.shown = 0; moreCatalog();
}
function moreCatalog() {
const installed = new Set(androidApps.map(a => a.package));
const next = cat.results.slice(cat.shown, cat.shown + 48);
$("catGrid").insertAdjacentHTML("beforeend", next.map(a => `
<div class="cat-card">
<div class="top">
${a.i ? `<img class="and-icon" loading="lazy" src="${esc(a.i)}" alt="">` : `<div class="and-icon"></div>`}
<div style="min-width:0"><div class="t">${esc(a.n)}</div><div class="s">v${esc(String(a.v || "").replace(/^v/i, ""))} · ${gb(a.z || 0)}</div></div>
<span class="vbadge v-${a.r}">${VLABEL[a.r]}</span>
</div>
<div class="s">${esc(a.s)}</div>
<div class="why">${esc(a.why[0] || "")}${a.why.length > 1 ? `<span title="${esc(a.why.slice(1).join("\n"))}"> · more</span>` : ""}</div>
<div class="row">
${installed.has(a.p) ? `<span class="tag">Installed</span>`
: installing.has(a.p) ? `<span class="tag">Installing…</span>`
: a.r === "no" ? "" : `<button class="action small" data-and="install" data-pkg="${esc(a.p)}" data-name="${esc(a.n)}">Install</button>`}
<button class="small" data-and="rate" data-pkg="${esc(a.p)}" data-ver="${esc(a.v)}" data-name="${esc(a.n)}" data-src="F-Droid" title="Report how it worked">Report</button>
<a class="small" href="https://f-droid.org/packages/${esc(a.p)}/" target="_blank" rel="noopener">F-Droid</a>
</div>
</div>`).join(""));
cat.shown += next.length;
$("catMore").hidden = cat.shown >= cat.results.length;
}
$("catMore").onclick = moreCatalog;
let catTimer;
$("catQ").oninput = e => { clearTimeout(catTimer); catTimer = setTimeout(() => { cat.q = e.target.value; filterCatalog(); }, 150); };
$("catCat").onchange = e => { cat.cat = e.target.value; filterCatalog(); };
$("catChips").onclick = e => {
const b = e.target.closest("[data-v]"); if (!b) return;
cat.v.has(b.dataset.v) ? cat.v.delete(b.dataset.v) : cat.v.add(b.dataset.v);
b.setAttribute("aria-pressed", cat.v.has(b.dataset.v)); filterCatalog();
};
document.body.addEventListener("click", async e => {
const b = e.target.closest("[data-and]"); if (!b) return;
const pkg = b.dataset.pkg, action = b.dataset.and;
if (action === "install") {
if (installing.has(pkg)) return;
toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
const done = runJob(`Install ${b.dataset.name}`, pkg, () => api("/api/android", { action, package: pkg }));
b.outerHTML = `<span class="tag">Installing…</span>`;
const res = await done;
if (res && res.app) await offerTest(res.app);
} else if (action === "remove") {
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
} else if (action === "refresh-art") {
await runJob(`Add Steam artwork for ${b.dataset.name}`, "art-" + pkg, () => api("/api/android", { action, package: pkg }));
} else if (action === "rate") {
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
return;
} else {
if (action === "probe") toast(`Testing ${pkg}: launching it and watching for 20 s…`);
await act(`${action[0].toUpperCase() + action.slice(1)} ${pkg}`, () => api("/api/android", { action, package: pkg }), b);
if (action === "probe") { loadCatalog(); loadReports(); }
}
loadAndroid();
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
});
loadCatalog();
// ---- Android display (wm size / wm density / font_scale per Lepton instance) ----
const disp = { list: [], port: null, error: null, seq: 0 };
const DPI = [120, 640];
const dims = s => { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? [+m[1], +m[2]] : null; };
const clampDpi = d => Math.max(DPI[0], Math.min(DPI[1], Math.round(d)));
function dispLabel(i) {
const apps = (i.packages || []).map(p => i.labels && i.labels[p] ? `${i.labels[p]} (${p})` : p);
const dev = i.container === "lepton-dev";
const what = apps.length ? apps.join(", ") : "no apps installed";
return `${dev ? "Lepton Development: " : ""}${what} · port ${i.port}`;
}
function dispCur() { return disp.list.find(i => i.port === disp.port); }
// Derived values: the density that looks "default" at the current resolution,
// and how far the user has moved from it.
function dispCalc(i) {
const phys = dims(i.physicalSize), size = dims(i.overrideSize) || phys;
const physD = i.physicalDensity, curD = i.overrideDensity || physD;
const factor = phys && size ? size[0] / phys[0] : 1;
const baseD = physD ? physD * factor : null;
return { phys, size, physD, curD, factor, baseD, userScale: baseD ? curD / baseD : 1 };
}
async function loadDisplays() {
$("dispReload").disabled = true;
const seq = ++disp.seq; // an older, slower request mustn't overwrite a newer answer
let list = [], error = null;
try {
list = (await api("/api/android/displays")).instances;
} catch (e) {
error = e;
if (!e.offline) log("Android display: " + e.message, "e");
} finally { $("dispReload").disabled = false; }
if (seq !== disp.seq) return;
disp.list = list; disp.error = error;
if (!disp.list.some(i => i.port === disp.port)) disp.port = disp.list.length ? disp.list[0].port : null;
const sel = $("dispSel");
sel.innerHTML = disp.list.length ? disp.list.map(i => `<option value="${i.port}">${esc(dispLabel(i))}</option>`).join("")
: `<option value="">${disp.error ? (disp.error.offline ? "Waiting for the Frame" : "Couldn't read Android's display")
: "No Android app is open"}</option>`;
sel.disabled = !disp.list.length;
// Open-an-app advice only fits a successful, empty answer; otherwise say what went wrong.
$("dispEmpty").textContent = !disp.error ? "Open an Android app in the headset, then Refresh, to change its resolution, UI scale and text size here."
: disp.error.offline ? "This fills in when the Frame answers." : disp.error.message;
$("dispEmpty").hidden = !!disp.list.length;
$("dispCtl").hidden = !disp.list.length;
if (disp.port) sel.value = String(disp.port);
renderDisplay();
}
function renderDisplay() {
const i = dispCur();
const btns = document.querySelectorAll("#display .seg button, #dispDpiSet, #dispReset, #dispDpi");
btns.forEach(b => b.disabled = !i || !!i.error);
document.querySelectorAll("#display .seg button").forEach(b => b.classList.remove("on"));
const set = (id, v, d) => { $(id).textContent = v; $(id + "D").textContent = d || " "; };
if (!i || i.error) {
set("dispSize", "—", i ? i.error : ""); set("dispDens", "—"); set("dispFont", "—");
return;
}
const c = dispCalc(i);
set("dispSize", c.size ? `${c.size[0]}×${c.size[1]}` : "—", i.overrideSize ? `panel is ${i.physicalSize}` : "native");
set("dispDens", `${c.curD ?? "—"} dpi`, i.overrideDensity ? `default ${i.physicalDensity}` : "default");
const fs = i.fontScale ?? 1;
set("dispFont", `${fs === 1 ? "1.0" : fs}×`, i.fontScale == null ? "default" : "custom");
$("dispDpi").value = c.curD ?? "";
const res = c.factor === 1 ? "native" : c.size && c.size[0] === 2560 && c.size[1] === 1440 ? "sharp" : "";
document.querySelectorAll("[data-res]").forEach(b => b.classList.toggle("on", b.dataset.res === res));
document.querySelectorAll("[data-scale]").forEach(b => b.classList.toggle("on", c.baseD && clampDpi(c.baseD * +b.dataset.scale) === c.curD));
document.querySelectorAll("[data-font]").forEach(b => b.classList.toggle("on", Math.abs(+b.dataset.font - fs) < 0.001));
}
async function applyDisplay(label, change, btn) {
const i = dispCur(); if (!i) return;
const res = await act(`${label} (port ${i.port})`, () => api("/api/android/display", { port: i.port, ...change }), btn);
if (res && res.display) { Object.assign(i, res.display); renderDisplay(); }
}
$("dispReload").onclick = loadDisplays;
$("dispSel").onchange = e => { disp.port = +e.target.value || null; renderDisplay(); };
$("dispRes").onclick = e => {
const b = e.target.closest("[data-res]"), i = dispCur(); if (!b || !i) return;
const c = dispCalc(i); if (!c.phys || !c.physD) return toast("Unknown panel size", true);
// Keep the user's relative UI scale while changing how many pixels there are.
if (b.dataset.res === "native") {
const d = clampDpi(c.physD * c.userScale);
applyDisplay("Native resolution", { size: "reset", density: d === c.physD ? "reset" : d }, b);
} else {
applyDisplay("Sharp resolution", { size: "2560x1440", density: clampDpi(c.physD * (2560 / c.phys[0]) * c.userScale) }, b);
}
};
$("dispScale").onclick = e => {
const b = e.target.closest("[data-scale]"), i = dispCur(); if (!b || !i) return;
const c = dispCalc(i); if (!c.baseD) return toast("Unknown default density", true);
const d = clampDpi(c.baseD * +b.dataset.scale);
applyDisplay(`UI scale ${b.textContent}`, { density: c.factor === 1 && d === c.physD ? "reset" : d }, b);
};
$("dispDpiSet").onclick = () => {
const d = Number($("dispDpi").value);
if (!Number.isInteger(d) || d < DPI[0] || d > DPI[1]) return toast(`Density must be a whole number ${DPI[0]}–${DPI[1]}`, true);
applyDisplay(`Density ${d} dpi`, { density: d }, $("dispDpiSet"));
};
$("dispDpi").addEventListener("keydown", e => { if (e.key === "Enter") $("dispDpiSet").click(); });
$("dispText").onclick = e => {
const b = e.target.closest("[data-font]"); if (!b) return;
const f = +b.dataset.font;
applyDisplay(`Text size ${f}×`, { fontScale: f === 1 ? "reset" : f }, b);
};
$("dispReset").onclick = () => applyDisplay("Reset Android display", { size: "reset", density: "reset", fontScale: "reset" }, $("dispReset"));
loadDisplays();
// ---- compatibility reports: any APK, did it work ----
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
let repShared = false;
function setRepHint() {
$("repHint").textContent = repShared
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
: telemetry.compat && !telemetry.blocked
? "Reports change the verdicts you see and are shared with Frame Control's compatibility database (Privacy settings). Any APK can be reported, including ones not on F-Droid."
: "Reports are saved on this computer and change the verdicts you see. Turn on Share compatibility results in Privacy settings to add them to the shared database. Any APK can be reported, including ones not on F-Droid.";
}
const REPORTS_SHOWN = 5;
let repsAll = false;
$("repMore").onclick = () => { repsAll = true; loadReports(); };
async function loadReports() {
let reps, shared;
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
repShared = shared; setRepHint();
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
$("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
$("repMore").textContent = `Show all ${reps.length}`;
$("repList").innerHTML = reps.length ? (repsAll ? reps : reps.slice(0, REPORTS_SHOWN)).map(r => {
const k = r.rating || r.result;
return `<div class="item rep-item"><div class="grow">
<div class="t">${esc(r.name)} <span class="sub">v${esc(String(r.version || "?").replace(/^v/i, ""))}</span></div>
<div class="s">${esc(r.package)} · ${esc((r.date || "").slice(0, 10))} · ${esc(r.via === "user" ? "you" : r.via || "")}${r.runtime ? " · " + esc(r.runtime) : ""}${r.source ? " · " + esc(r.source) : ""}</div>
${r.notes ? `<div class="s">${esc(r.notes)}</div>` : ""}</div>
<span class="vbadge v-${RCLASS[k] || "no"}">${esc(RLABEL[k] || k || "")}</span></div>`;
}).join("") : `<div class="sub">No reports yet.</div>`;
}
function openReport(p = {}) {
const f = $("repForm");
f.reset();
$("repPkg").value = p.package || ""; $("repVer").value = p.version || "";
$("repLabel").value = p.label || ""; $("repSrc").value = p.source || "";
$("repMsg").textContent = ""; $("repFileNote").textContent = "";
const known = new Map();
androidApps.forEach(a => known.set(a.package, a.label));
(cat.apps || []).slice(0, 4000).forEach(a => known.has(a.p) || known.set(a.p, a.n));
$("repPkgs").innerHTML = [...known].map(([p, n]) => `<option value="${esc(p)}">${esc(n)}</option>`).join("");
$("repDlg").showModal();
(p.package ? document.querySelector('input[name="repRating"]') : $("repPkg")).focus();
}
$("repNew").onclick = () => openReport();
$("repCancel").onclick = () => $("repDlg").close();
$("repPkg").onchange = () => {
const pkg = $("repPkg").value.trim();
const a = androidApps.find(x => x.package === pkg), c = cat.apps && cat.apps.find(x => x.p === pkg);
if (!$("repLabel").value) $("repLabel").value = (a && a.label) || (c && c.n) || "";
if (!$("repVer").value) $("repVer").value = (a && a.version) || (c && c.v) || "";
};
$("repPick").onclick = () => $("repFile").click();
$("repFile").onchange = async () => {
const file = $("repFile").files[0]; if (!file) return;
$("repFileNote").textContent = "Reading…";
try {
const { apk } = await upload(file, "apkinfo");
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
if (!$("repSrc").value) $("repSrc").value = file.name;
if (apk.blocker) checkApkAlternatives(apk);
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
} catch (e) { $("repFileNote").textContent = e.message; }
$("repFile").value = "";
};
$("repForm").onsubmit = async e => {
e.preventDefault();
const rating = document.querySelector('input[name="repRating"]:checked');
if (!$("repPkg").checkValidity()) return ($("repMsg").textContent = "Enter the package name, e.g. org.example.app");
if (!rating) return ($("repMsg").textContent = "Choose whether it worked");
const body = { action: "report", package: $("repPkg").value.trim(), version: $("repVer").value.trim(),
label: $("repLabel").value.trim(), source: $("repSrc").value.trim(), notes: $("repNotes").value.trim(),
rating: rating.value, runtime: document.querySelector('input[name="repRuntime"]:checked').value };
$("repSave").disabled = true;
try {
const res = await api("/api/android", body);
$("repDlg").close(); toast(res.message); log(res.message, "ok");
loadReports(); loadCatalog();
} catch (err) { $("repMsg").textContent = err.message; }
finally { $("repSave").disabled = false; }
};
loadReports();
api("/api/host").then(applyHostWording).catch(() => {});
// ---- Steam screenshots from the headset ----
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
// that finds a new shot fetches only that one's thumbnail.
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
const STEAMVR_APPID = "250820";
const SHOTS_POLL_MS = 8000;
function shotApp(appid) {
if (appid === STEAMVR_APPID) return "SteamVR";
const g = state?.games?.find(x => x.appid === appid);
return g ? g.name : `App ${appid}`;
}
async function shotBlob(id, thumb) {
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
{ headers: {"X-Frame-UI": UI_KEY} });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
return r.blob();
}
function shotThumb(id) {
const thumbs = shots.thumbs; // this headset's: a late failure must not touch the next one's
if (!thumbs.has(id)) {
const p = shotBlob(id, true).then(b => URL.createObjectURL(b));
p.catch(() => { if (thumbs.get(id) === p) thumbs.delete(id); }); // try again next time
thumbs.set(id, p);
}
return thumbs.get(id);
}
// quiet: a background check. It keeps what's shown if the Frame can't be read,
// and redraws only when the shots (or whether they're saved here) changed.
function loadShots(quiet) {
if (shots.loading) {
// A check already on its way will do for another check; Refresh, or a save
// that just finished, reads again after it so the answer is a fresh one.
const again = () => loadShots();
return quiet === true ? shots.loading : shots.loading.then(again, again);
}
const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; });
return shots.loading = p;
}
// Forget the shots of a headset we've switched away from, and ignore its answers still on their way.
function resetShots() {
closeMenu(); // its items were about the other headset's shot
shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null;
for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {});
shots.thumbs = new Map();
}
async function readShots(quiet) {
const gen = shots.gen;
if (!quiet) $("shotsRefresh").disabled = true;
let got;
try {
got = await api("/api/shots");
} catch (e) {
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
return;
} finally { $("shotsRefresh").disabled = false; }
if (gen !== shots.gen) return;
({ shots: shots.list, folder: shots.folder } = got);
const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)]));
if (sig !== shots.sig) { shots.sig = sig; drawShots(); }
fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails
}
// Thumbnails one at a time over the shared SSH connection. One that failed is
// tried again on Refresh, not by every background check.
async function fillThumbs(quiet) {
const run = ++shots.fill;
// Refresh puts every failed one back in line first, so a background pass that takes over carries them all on.
if (!quiet) for (const img of document.querySelectorAll("#shotGrid img[data-failed]")) delete img.dataset.failed;
for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) {
const s = shots.list[+img.dataset.shot];
let url;
try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; }
if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over
if (url) img.src = url;
}
}
// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip).
function shotTime(t) {
return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function drawShots() {
const ids = new Set(shots.list.map(s => s.id));
for (const [id, p] of shots.thumbs) {
if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); }
}
const unsaved = shots.list.filter(s => !s.saved).length;
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card" data-card="${i}">
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer (right-click for more)">
<div class="row"><div class="grow">
<div class="t">${esc(shotApp(s.appid))}</div>
<div class="s" title="${esc(new Date(s.time * 1000).toLocaleString())}">${esc(shotTime(s.time))}</div></div>
<button class="small" data-shot-copy="${i}" title="Copy the image to the clipboard">Copy</button>
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
</div></div>`).join("")
: `<div class="sub">No screenshots on the Frame yet.</div>`;
}
async function openShot(s) {
if (live) toggleLive(false);
const gen = ++viewGen;
$("viewer").classList.add("busy");
let url = null;
try {
const blob = await shotBlob(s.id, false);
url = URL.createObjectURL(blob);
const img = new Image();
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
if (gen !== viewGen) return;
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
desk.shown = null; // a screenshot, not a panel: Control has nothing to act on here
draw();
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
$("saveBtn").disabled = $("copyBtn").disabled = false;
$("view").scrollIntoView({ behavior: "smooth" });
} catch (e) {
toast("Couldn't open the screenshot: " + e.message, true);
} finally {
if (url) URL.revokeObjectURL(url);
$("viewer").classList.remove("busy");
}
}
async function saveShots(list, btn) {
if (!list.length) return;
if (HOST.mobile && savesToDevice()) {
return act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
async () => saveToDevice(await Promise.all(list.map(async s => ({ blob: await shotBlob(s.id, false), name: s.file })))), btn);
}
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
if (res) loadShots();
}
const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn);
$("shotGrid").onclick = e => {
const img = e.target.closest("img[data-shot]");
if (img) return openShot(shots.list[+img.dataset.shot]);
const c = e.target.closest("[data-shot-copy]");
if (c) return copyShot(shots.list[+c.dataset.shotCopy], c);
const b = e.target.closest("[data-shot-save]");
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
};
// A small right-click menu. items: [label, fn] pairs, null for a divider.
// back: the keyboard closed it, so focus returns to where it was.
function closeMenu(back) {
const menu = document.querySelector(".ctx-menu");
if (!menu) return;
menu.remove();
if (back === true && menu.opener?.isConnected) menu.opener.focus();
}
function showMenu(e, items) {
closeMenu();
const menu = document.createElement("div");
menu.className = "ctx-menu";
menu.setAttribute("role", "menu");
menu.opener = document.activeElement;
for (const it of items) {
if (!it) { menu.append(document.createElement("hr")); continue; }
const b = document.createElement("button");
b.textContent = it[0];
b.setAttribute("role", "menuitem");
b.onclick = () => { closeMenu(); it[1](); };
menu.append(b);
}
(document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer
const r = menu.getBoundingClientRect();
menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px";
menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px";
menu.querySelector("button").focus();
}
document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true);
document.addEventListener("keydown", e => {
const menu = document.querySelector(".ctx-menu");
if (!menu) return;
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); return closeMenu(true); } // Tab doesn't wander off behind it
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault(); // arrows move through the menu, not the page
const items = [...menu.querySelectorAll("button")];
const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1;
items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus();
});
addEventListener("blur", closeMenu);
document.addEventListener("fullscreenchange", closeMenu);
addEventListener("scroll", closeMenu, true);
// Right-click a screenshot for everything it can do.
$("shotGrid").oncontextmenu = e => {
const card = e.target.closest("[data-card]");
if (!card) return;
e.preventDefault();
const s = shots.list[+card.dataset.card];
const items = [
["Open in viewer", () => openShot(s)],
["Copy image", () => copyShot(s)],
];
if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]);
else {
const sep = shots.folder.includes("\\") ? "\\" : "/";
items.push(null);
items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`,
() => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]);
items.push(["Copy file path", () => act("Copy file path", async () => {
await copyText(shots.folder + sep + s.file);
return { message: "Copied the file path" };
})]);
}
items.push(null, ["Copy file name", () => act("Copy file name", async () => {
await copyText(s.file);
return { message: "Copied " + s.file };
})]);
showMenu(e, items);
};
function copyText(text) {
if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here");
return navigator.clipboard.writeText(text);
}
$("shotsRefresh").onclick = () => loadShots();
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// Watch for new shots: a cheap listing over the shared SSH connection while the
// window is visible and the Frame is reachable, and again on coming back to it.
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
// ---- Panel switcher: our UI over SteamVR's panel API ----
let panelSeq = 0;
async function loadPanels() {
const seq = ++panelSeq;
try {
const s = await api("/api/panels", {action: "list"});
if (seq !== panelSeq) return;
$("panelList").innerHTML = s.panels.length ? s.panels.map(p =>
`<div class="item"><div class="grow"><div class="t">${esc(p.title)}</div></div>
<button class="small" data-panel="${esc(p.key)}">Show</button></div>`).join("")
: '<div class="sub">No open panels.</div>';
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
}
$("panelsRefresh").onclick = loadPanels;
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
() => api("/api/panels", {action: "open"}), e.currentTarget);
$("panelList").onclick = e => {
const b = e.target.closest("[data-panel]"); if (!b) return;
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
};
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
let mvSeq = 0, mvTimer = 0;
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
// How a live stream is doing: frames shown, Mac-to-headset delay, bitrate, and
// whether the network made it step down (fewer frames, then fewer pixels).
function mvHealth(st) {
if (!st) return "";
const x = st.stats || {}, c = st.controller || {}, bits = [];
if (x.fps) bits.push(`${Math.round(x.fps)} fps`);
if (x.total && x.total.p50 != null) bits.push(`${Math.round(x.total.p50)} ms`);
if (x.mbps) bits.push(`${x.mbps} Mbit/s`);
if (c.tier > 0) bits.push(`weak link: ${c.fps} fps${c.scale < 1 ? `, ${Math.round(c.scale * 100)}% size` : ""}`);
return bits.length ? " · " + esc(bits.join(" · ")) : "";
}
function mvRow(src, title, sub, w, h, streaming, st) {
return `<div class="item"><div class="grow"><div class="t">${esc(title)}</div>
<div class="s">${esc(sub)}${streaming ? ' · <span class="mv-live">in the headset</span>' + mvHealth(st) : ""}</div></div>
${streaming ? `<button class="small" data-mv="stop" data-src="${esc(streaming)}">Stop</button>`
: `<button class="small action" data-mv="show" data-src="${esc(src)}" data-title="${esc(title)}" data-w="${w}" data-h="${h}">Show</button>`}
</div>`;
}
async function loadMacView(restart) {
const seq = ++mvSeq;
let s, err;
try { s = await api("/api/macview" + (restart ? "?restart=1" : "")); } catch (e) { err = e; }
if (seq !== mvSeq) return;
const box = $("macview");
if (err) { box.hidden = false; return failed($("mvWindows"), err); }
if (!s.available) { box.hidden = true; return; }
box.hidden = false;
const live = new Set((s.streams || []).map(x => x.src));
const byStream = src => (s.streams || []).find(x => x.src === src);
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
const perm = [];
if (!s.screen) perm.push(`Frame Control needs <b>Screen Recording</b> permission to show your windows.`);
if (!s.accessibility) perm.push(`Allow <b>Accessibility</b> too, so clicks and keys from the headset reach the Mac.`);
$("mvPerm").hidden = !perm.length;
$("mvPerm").innerHTML = perm.join(" ") + `<div class="row"><button class="small action" data-mv="perm">Allow…</button>
<span class="sub">Then press Refresh. macOS may ask you to reopen Frame Control.</span></div>`;
const liveAs = src => live.has(src) ? src : src.startsWith("window:") && live.has("separate:" + src.slice(7)) ? "separate:" + src.slice(7) : "";
$("mvDisplays").innerHTML = (s.displays || []).map(d =>
mvRow(d.src, `Whole screen: ${d.name}`, `${d.w}×${d.h}${d.main ? " · main display" : ""}`, d.w, d.h, liveAs(d.src), byStream(liveAs(d.src)))).join("");
const wins = s.windows || [];
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
: `<div class="sub">No windows open on this Mac's current desktop.</div>`;
// Keep the numbers fresh while something is in the headset and the card is on screen.
clearTimeout(mvTimer);
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
if (live.size) mvTimer = setTimeout(tick, 3000);
}
$("mvRefresh").onclick = () => loadMacView(true);
$("mvSeparate").checked = localStorage.getItem("mvSeparate") !== "0";
$("mvSeparate").onchange = e => localStorage.setItem("mvSeparate", e.target.checked ? "1" : "0");
$("macview").onclick = async e => {
const b = e.target.closest("[data-mv]"); if (!b) return;
const src = b.dataset.src;
if (b.dataset.mv === "show") {
const as = $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
await act(`Show ${b.dataset.title} in the headset`, async () => {
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
return { message: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`, ...r };
}, b);
} else if (b.dataset.mv === "stop") {
await act("Stop showing it", () => api("/api/macview", { action: "stop", src }), b);
} else if (b.dataset.mv === "stopall") {
await act("Stop everything in the headset", () => api("/api/macview", { action: "stop" }), b);
} else if (b.dataset.mv === "perm") {
await act("Ask macOS for permission", () => api("/api/macview", { action: "permissions" }), b);
}
setTimeout(loadMacView, 800);
};
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
const PAGES = ["home", "games", "android", "tools"];
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
let page = "home";
function showPage() {
const id = location.hash.slice(1);
const next = PAGES.includes(id) ? id : SECTION_PAGE[id] || "home";
if (next !== page && page === "home" && live) toggleLive(false); // don't stream video nobody is watching
page = next;
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
if (page === "tools") { loadMacView(); loadPanels(); }
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
const PAD_STATES = {
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
pairing: "Pairing with KDE Connect…", ready: "On",
};
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
const PAD_SPECIAL = { Backspace: 1, Tab: 2, ArrowLeft: 4, ArrowUp: 5, ArrowRight: 6, ArrowDown: 7, PageUp: 8,
PageDown: 9, Home: 10, End: 11, Enter: 12, Delete: 13, Escape: 14,
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, 21 + i])) };
const IS_MAC = /^Mac/.test(navigator.platform);
const TOUCH = matchMedia("(hover: none)").matches;
function padShow(status) {
pad.state = status.state || "off";
const ready = pad.state === "ready";
const busy = !ready && pad.state !== "off" && pad.state !== "error";
$("padState").textContent = pad.state === "error" ? "" : PAD_STATES[pad.state] || "";
$("padOn").hidden = ready || busy;
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
$("padArea").classList.toggle("off", !ready);
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
: "Click here to use your mouse and keyboard on the Frame (Esc to stop), or drag like a trackpad.";
clearTimeout(pad.poll);
if (ready && pad.queue.length) padFlush();
if (busy) pad.poll = setTimeout(async () => padShow(await api("/api/input").catch(e => ({ state: "error", message: e.message }))), 1000);
}
async function padStart() {
localStorage.padUsed = "1"; // from now on it turns on with the page
padShow({ state: "starting" });
padShow(await api("/api/input?start=1").catch(e => ({ state: "error", message: e.message })));
}
$("padOn").onclick = padStart;
// Queue events and send them one request at a time, merging pointer moves made meanwhile.
const isMove = e => Object.keys(e).every(k => k === "dx" || k === "dy");
function padSend(event) {
// While it reconnects, keys and clicks wait in the queue; pointer moves would be stale.
// A button release is always kept, so a drag never leaves the button held on the Frame.
const room = pad.queue.length < 200 || event.singlerelease;
const reconnecting = ["starting", "pairing", "installing"].includes(pad.state) && room;
// Even after an error: a button pressed on the Frame must be let go once it's back.
const release = event.singlerelease && pad.state !== "off";
if (pad.state !== "ready" && !(reconnecting && !isMove(event)) && !release) return;
const last = pad.queue[pad.queue.length - 1];
if (last && isMove(last) && isMove(event)) { last.dx = (last.dx || 0) + (event.dx || 0); last.dy = (last.dy || 0) + (event.dy || 0); }
else pad.queue.push(event);
padFlush();
}
// Events that didn't reach the Frame (the link had dropped, and is reconnecting): keep
// the keys and clicks for when it's back, but not pointer moves, which would be stale.
// Button releases are kept whatever the queue's length.
function padKeep(events) {
pad.queue = [...events, ...pad.queue].filter(e => !isMove(e)).filter((e, i) => i < 200 || e.singlerelease);
}
async function padFlush() {
if (pad.sending || !pad.queue.length || pad.state !== "ready") return;
pad.sending = true;
const events = pad.queue.splice(0, 200);
try {
const status = await api("/api/input", { events });
if (!status.sent) padKeep(events);
if (status.state !== "ready") padShow(status);
} catch (e) {
padKeep(events);
padShow({ state: "error", message: e.message });
} finally {
pad.sending = false;
if (pad.queue.length) padFlush();
}
}
function padKey(e) {
const mods = { ...(e.ctrlKey || (IS_MAC && e.metaKey) ? { ctrl: true } : {}), ...(e.altKey ? { alt: true } : {}),
...(!IS_MAC && e.metaKey ? { super: true } : {}) };
if (PAD_SPECIAL[e.key]) return { specialKey: PAD_SPECIAL[e.key], ...mods, ...(e.shiftKey ? { shift: true } : {}) };
if (e.key.length === 1) return { key: e.key, ...mods }; // already shifted, so no shift flag
return null;
}
// Touch: one finger moves, a quick tap clicks; two fingers scroll, or right-click when tapped.
const PAD_SPEED = 1.6, PAD_SCROLL_STEP = 18;
const touches = new Map();
let gesture = null;
$("padArea").addEventListener("pointerdown", e => {
if (pad.state === "off" || pad.state === "error") { padStart(); return; }
if (pad.state !== "ready") return;
if (e.pointerType === "mouse") {
if (pad.captured) return; // mousedown and mouseup below send the clicks
// Lock the pointer to the pad and pass the whole mouse and keyboard through; where
// that isn't allowed, the mouse drags like a finger on a trackpad instead.
if (e.button === 0 && !pad.noLock && $("padArea").requestPointerLock) { $("padArea").requestPointerLock(); return; }
// Only the left button drags and taps like a finger; the others are clicks of their own.
if (e.button === 1 || e.button === 2) { padSend(e.button === 2 ? { rightclick: true } : { middleclick: true }); return; }
if (e.button !== 0) return;
}
try { $("padArea").setPointerCapture(e.pointerId); } catch {} // keeps the drag if the finger leaves the pad
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (touches.size === 1) gesture = { start: performance.now(), moved: 0, fingers: 1, scroll: 0 };
else if (gesture) gesture.fingers = Math.max(gesture.fingers, touches.size);
e.preventDefault();
});
$("padArea").addEventListener("pointermove", e => {
if (pad.captured) { padSend({ dx: e.movementX, dy: e.movementY }); return; }
const t = touches.get(e.pointerId);
if (!t || !gesture) return;
const dx = e.clientX - t.x, dy = e.clientY - t.y;
t.x = e.clientX; t.y = e.clientY;
gesture.moved += Math.abs(dx) + Math.abs(dy);
if (touches.size >= 2) {
// Scroll with the lead finger only, a notch per step, like a laptop trackpad (content follows the fingers).
if (e.pointerId !== touches.keys().next().value) return;
gesture.scroll += dy;
while (Math.abs(gesture.scroll) >= PAD_SCROLL_STEP) {
const up = gesture.scroll > 0;
padSend({ scroll: true, dy: up ? 1 : -1 });
gesture.scroll -= up ? PAD_SCROLL_STEP : -PAD_SCROLL_STEP;
}
} else if (gesture.fingers === 1) {
padSend({ dx: dx * PAD_SPEED, dy: dy * PAD_SPEED });
}
});
function padLift(e) {
if (!touches.delete(e.pointerId) || touches.size || !gesture) return;
const quick = performance.now() - gesture.start < 250 && gesture.moved < 10;
if (quick) padSend(gesture.fingers >= 2 ? { rightclick: true } : { singleclick: true });
gesture = null;
}
$("padArea").addEventListener("pointerup", padLift);
$("padArea").addEventListener("pointercancel", e => { touches.delete(e.pointerId); if (!touches.size) gesture = null; });
// A computer's mouse and keyboard: captured while the pointer is locked to the pad.
document.addEventListener("pointerlockerror", () => { pad.noLock = true; });
document.addEventListener("pointerlockchange", () => {
pad.captured = document.pointerLockElement === $("padArea");
window.frameApp?.captureKeys?.(pad.captured); // the desktop app's menu shortcuts go to the Frame too
// Esc or switching windows mid-drag: let go of the button on the Frame as well.
if (!pad.captured && pad.held) { padSend({ singlerelease: true }); pad.held = false; }
$("padArea").classList.toggle("captured", pad.captured);
padShow({ state: pad.state });
});
$("padArea").addEventListener("mousedown", e => {
if (!pad.captured) return;
if (e.button === 0) { padSend({ singlehold: true }); pad.held = true; }
else if (e.button === 1) padSend({ middleclick: true });
else if (e.button === 2) padSend({ rightclick: true });
});
$("padArea").addEventListener("mouseup", e => { if (pad.held && e.button === 0) { padSend({ singlerelease: true }); pad.held = false; } });
$("padArea").addEventListener("contextmenu", e => e.preventDefault());
$("padArea").addEventListener("wheel", e => {
if (pad.state !== "ready") return;
e.preventDefault();
if (e.deltaY) padSend({ scroll: true, dy: e.deltaY > 0 ? -1 : 1 });
}, { passive: false });
// Capture phase, so the page's own shortcuts (tabs 1-4, R, zoom) don't also fire.
window.addEventListener("keydown", e => {
if (!pad.captured) return;
e.preventDefault(); e.stopImmediatePropagation();
const event = padKey(e);
if (event) padSend(event);
}, true);
// The text field: each keystroke goes to the Frame. While focused it holds one space,
// so Backspace always has something to delete; empty otherwise, to show its prompt.
const padType = $("padType");
padType.addEventListener("beforeinput", e => {
if (e.inputType === "insertCompositionText") return; // IME composing; sent on compositionend
e.preventDefault();
if (pad.state === "off" || pad.state === "error") padStart(); // what's typed meanwhile waits in the queue
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
if (/^insert(Text|ReplacementText|FromPaste|FromDrop)$/.test(e.inputType) && text) padText(text);
else if (e.inputType === "deleteContentBackward") padSend({ specialKey: 1 });
else if (e.inputType === "deleteWordBackward") padSend({ specialKey: 1, ctrl: true }); // Ctrl+Backspace: a word, on Linux
else if (e.inputType === "deleteContentForward") padSend({ specialKey: 13 });
else if (e.inputType === "deleteWordForward") padSend({ specialKey: 13, ctrl: true });
else if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") padSend({ specialKey: 12 });
});
padType.addEventListener("compositionend", e => { if (e.data) padText(e.data); padType.value = " "; padCaret(); });
// Text in pieces the server takes (500 characters), with Enter for each line break.
function padText(text) {
text.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
if (i) padSend({ specialKey: 12 });
const chars = Array.from(line); // whole characters, so an emoji isn't cut in two
for (let at = 0; at < chars.length; at += 500) padSend({ key: chars.slice(at, at + 500).join("") });
});
}
padType.addEventListener("keydown", e => {
const special = PAD_SPECIAL[e.key];
// Text and deletions arrive as beforeinput; this takes the keys that don't edit the field.
if (!special || e.key === "Backspace" || e.key === "Delete" || e.isComposing) return;
e.preventDefault();
padSend(padKey(e));
});
// The caret stays after the space, so Backspace always has something to delete.
const padCaret = () => requestAnimationFrame(() => padType.setSelectionRange(1, 1));
padType.addEventListener("focus", () => { padType.value = " "; padCaret(); if (pad.state === "off") padStart(); });
padType.addEventListener("blur", () => { padType.value = ""; });
padType.addEventListener("input", e => {
if (e.isComposing) return; // an input method is still composing; compositionend sends it
if (padType.value !== " ") padType.value = " ";
padCaret();
});
padType.addEventListener("click", padCaret);
document.querySelector("#pad .pad-keys").addEventListener("click", e => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.padClick) padSend({ [b.dataset.padClick]: true });
else if (b.dataset.padKey) padSend({ specialKey: +b.dataset.padKey });
});
api("/api/input").then(s => s.state === "off" && localStorage.padUsed ? padStart() : padShow(s), () => padShow({ state: "off" }));
showPage();
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
const n = PAGES[+e.key - 1];
if (n) location.hash = n;
});
// ---- after an APK install: test it, so the compatibility database learns whether it runs ----
async function offerTest(m) {
if (!m.package || !confirm(`${m.label || m.package} is installed. Test it now?\n\nIt opens in the headset for about 20 seconds `
+ "and records whether it stays up.")) return;
toast(`Testing ${m.label || m.package}: launching it and watching for 20 s…`);
await act(`Test ${m.label || m.package}`, () => api("/api/android", { action: "probe", package: m.package }));
loadReports();
}
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
function renderTelemetry(s) {
Object.assign(telemetry, s);
setRepHint();
$("tUsage").checked = s.usage; $("tCompat").checked = s.compat; $("tDiag").checked = s.diagnostics;
$("tStatus").textContent = s.blocked ? `Nothing is being sent: ${s.blocked}.`
: `${s.queued ? s.queued + " waiting to send. " : ""}Your anonymous id is ${s.id.slice(0, 8)}…; it isn't linked to you or this computer.`;
$("tSent").textContent = s.sent.length ? s.sent.map(e => JSON.stringify({ event: e.event, time: e.timestamp, ...e.properties })).join("\n\n")
: "Nothing sent yet.";
const showNotice = !s.blocked && !s.noticeShown && s.usage;
$("privacyNotice").hidden = !showNotice;
if (showNotice) privacyNoticeShown = true;
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
}
async function loadTelemetry() {
try { renderTelemetry(await api("/api/telemetry")); } catch {}
}
async function setTelemetry(change) {
try { renderTelemetry(await api("/api/telemetry", change)); }
catch (e) { toast(`Couldn't save: ${e.message}`, true); loadTelemetry(); }
}
$("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
$("noticeMore").onclick = async () => {
$("privacyNotice").hidden = true;
await setTelemetry({ compat: true, diagnostics: true });
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
};
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
const telemetryLoaded = loadTelemetry();
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
const contact = { email: "", updates: false, followup: false };
function renderContact(s) {
Object.assign(contact, s);
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
$("cRemove").hidden = !s.email;
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
: "Sent privately to the Frame Control maintainer, never shared or published. Updates are occasional release notices; follow-up questions are mainly about problem reports you send.";
}
async function saveContact(change) {
const s = await api("/api/contact", change);
renderContact(s);
return s;
}
async function loadContact() {
await telemetryLoaded;
try { renderContact(await api("/api/contact")); } catch { return; }
checkContactPrompt();
}
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
async function checkContactPrompt() {
await telemetryLoaded;
if (privacyNoticeShown || !$("contactNotice").hidden) return;
let s;
try { s = await api("/api/contact"); } catch { return; }
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
$("contactNotice").hidden = false;
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
}
$("contactNotice").onsubmit = async e => {
e.preventDefault();
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
$("cpSave").disabled = true;
try {
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
$("contactNotice").hidden = true;
toast("Thanks! Change or remove it any time in Privacy & updates.");
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
finally { $("cpSave").disabled = false; }
};
$("cpNo").onclick = async () => {
try { await api("/api/contact/prompt", { prompt: "dismissed" }); $("contactNotice").hidden = true; }
catch (err) { $("cpMsg").textContent = `Couldn't save that: ${err.message}. Try again.`; }
};
$("contactForm").onsubmit = async e => {
e.preventDefault();
const email = $("cEmail").value.trim();
if (email && !$("cUpdates").checked && !$("cFollowup").checked) {
$("cStatus").textContent = "Tick what your email may be used for, or use Remove my email."; return;
}
try { await saveContact({ email, updates: $("cUpdates").checked, followup: $("cFollowup").checked });
toast(email ? "Contact email saved." : "Contact email removed."); }
catch (err) { toast(`Couldn't save: ${err.message}`, true); }
};
$("cRemove").onclick = async () => {
try { await saveContact({ email: "", updates: false, followup: false }); toast("Contact email removed. Neither choice applies any more."); }
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
};
loadContact();
function pageEvent(event, properties) {
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
}
// Which tabs get used: once per tab per session.
const tabsSeen = new Set();
document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => {
const tab = a.getAttribute("href").slice(1);
if (!tabsSeen.has(tab)) { tabsSeen.add(tab); pageEvent("tab_viewed", { tab }); }
}));
// ---- report a problem (ui/frame_report.py): sent privately to PostHog, with diagnostics ----
const bug = { preview: "" };
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
function bugReportText() {
const email = $("bugFollowup").checked ? $("bugContact").value.trim() : "";
const body = `Kind: ${$("bugKind").value}${email ? `\nFollow-up questions welcome: ${email}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
return { title: $("bugTitleIn").value.trim(), body };
}
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
async function loadBugPreview() {
if (!$("bugDiag").checked) { bug.preview = ""; $("bugDiagText").textContent = "Nothing: diagnostics are off."; return; }
$("bugDiagText").textContent = "Loading…";
try { bug.preview = (await api("/api/report/preview", { activity: activityLines(), includeLogs: $("bugLogs").checked })).text; }
catch (e) { bug.preview = ""; $("bugDiagText").textContent = `Couldn't collect diagnostics: ${e.message}`; return; }
$("bugDiagText").textContent = bug.preview;
}
function openBugReport() {
$("bugForm").reset();
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
$("bugCancel").textContent = "Cancel";
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked.
$("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
bugReplaces();
$("bugDlg").showModal();
loadBugPreview();
}
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
$("bugLogs").onchange = loadBugPreview;
$("bugFollowup").onchange = () => {
const on = $("bugFollowup").checked;
$("bugContact").disabled = !on; $("bugContact").required = on;
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); }
bugReplaces();
};
// Sending with another address replaces the saved one (ui/frame_contact.py from_report): say so first.
function bugReplaces() {
const email = $("bugContact").value.trim(), old = contact.email;
const replaces = $("bugFollowup").checked && email && old && email.toLowerCase() !== old.toLowerCase();
$("bugReplaces").hidden = !replaces;
$("bugReplaces").textContent = !replaces ? "" : `Sending replaces ${old} as your contact email${contact.updates
? ", and update notices stop until you turn them on again in Privacy & updates" : ""}.`;
}
$("bugContact").oninput = bugReplaces;
$("bugCancel").onclick = () => $("bugDlg").close();
$("bugCopy").onclick = async () => {
const { title, body } = bugReportText();
try { await navigator.clipboard.writeText(`${title}\n\n${body}`); $("bugMsg").textContent = "Copied."; }
catch { $("bugMsg").textContent = "Couldn't copy; select the text under Show exactly what's included."; }
};
$("bugForm").onsubmit = async e => {
e.preventDefault();
if (!$("bugForm").reportValidity()) return;
$("bugSend").disabled = true; $("bugMsg").textContent = "Sending…";
try {
const res = await api("/api/report", {
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
contactFollowup: $("bugFollowup").checked, contact: $("bugFollowup").checked ? $("bugContact").value : "",
diagnostics: $("bugDiag").checked ? bug.preview : "" });
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
$("bugCancel").textContent = "Close";
log(res.message, "ok");
} catch (err) {
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
$("bugSend").disabled = false;
}
api("/api/contact").then(renderContact).catch(() => {}); // the report may have saved the address
};
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
// ---- updates (the desktop app only: app/updater.js) ----
const upd = { dismissed: false, offered: null };
function renderUpdate(s) {
if (!s) return;
$("appVersion").textContent = `Version ${s.current}`;
const r = s.latest;
const show = r && ["available", "downloading", "ready", "error"].includes(s.status) && !upd.dismissed
&& !(s.status === "error" && !r);
$("updateBar").hidden = !show;
if (!show) return;
if (s.status === "available" && upd.offered !== r.version) { upd.offered = r.version; pageEvent("update_offered", { to_version: r.version }); }
const busy = s.status === "downloading" || s.status === "ready";
$("updateText").innerHTML = s.status === "error"
? `Updating to ${esc(r.version)} didn't work: ${esc(s.error || "unknown error")}`
: busy ? `Downloading Frame Control ${esc(r.version)}… It restarts when it's ready.`
: `<b>Frame Control ${esc(r.version)} is available.</b> You have ${esc(s.current)}.`
+ (s.canInstall ? "" : ` ${esc(s.why ? "It can't update itself here (" + s.why + ")," : "")} download it from the release page.`);
$("updateProg").hidden = !busy;
$("updateProg").firstElementChild.style.width = Math.round((s.progress || 0) * 100) + "%";
$("updateGo").textContent = s.canInstall ? (s.status === "error" ? "Try again" : "Update and restart") : "Open release page";
$("updateGo").disabled = busy; $("updateLater").hidden = busy;
}
if (window.frameApp && window.frameApp.update) {
window.frameApp.update.onState(renderUpdate);
window.frameApp.update.get().then(renderUpdate);
$("updateCheck").hidden = false;
$("updateCheck").onclick = async () => {
const btn = $("updateCheck");
btn.disabled = true;
let s;
try { s = await window.frameApp.update.check(); } finally { btn.disabled = false; }
upd.dismissed = false; renderUpdate(s);
if (s && s.status === "none") toast(`You have the newest version (${s.current})`);
if (s && s.status === "check-failed") toast(`Couldn't check for updates: ${s.error}`, true);
};
$("updateGo").onclick = () => {
pageEvent("update_started", { to_version: upd.offered || "" });
window.frameApp.update.install();
};
$("updateLater").onclick = () => { upd.dismissed = true; $("updateBar").hidden = true; };
$("updateNotes").onclick = async () => {
const s = await window.frameApp.update.get();
if (s && s.latest) window.open(s.latest.page, "_blank");
};
}
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
// The app passes each link here. The server checks it and reads the manifest;
// nothing downloads until the user clicks Install in this dialog.
const wi = { queue: [], open: false, gen: 0, plan: null, job: null, starting: false };
function wiSize(n) {
if (n == null) return "Not given";
return n >= 1e9 ? gb(n) : n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
}
function wiFacts(rows) { $("wiFacts").innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join(""); }
function wiButtons(cancel, go) {
$("wiCancel").textContent = cancel[0]; $("wiCancel").disabled = !cancel[1];
$("wiGo").hidden = !go; $("wiGo").disabled = go !== "on";
}
async function wiNext() {
if (wi.open || !wi.queue.length) return;
const req = wi.queue.shift(), gen = ++wi.gen;
wi.open = true; wi.plan = null; wi.job = null;
let host = "";
try { host = new URL(req.target).hostname; } catch {}
wiFacts([["From", host], [req.kind === "manifest" ? "Manifest" : "File", req.target]]);
$("wiProg").hidden = true; $("wiMsg").textContent = "Checking the link…";
wiButtons(["Cancel", true], "off");
$("wiDlg").showModal();
log(`Install link from ${host}`);
try {
const p = await api("/api/webinstall/check", { [req.kind]: req.target });
if (gen !== wi.gen) return;
wi.plan = p;
const from = p.host === p.linkHost ? p.host : `${p.linkHost} (file on ${p.host})`;
wiFacts([["Title", p.name], ["From", from], ["File", p.file], ["Type", p.kindLabel], ["Size", wiSize(p.size)],
["SHA-256", p.sha256 ? "Given; checked after downloading" : "Not given; the download can't be checked"]]);
$("wiMsg").textContent = "";
wiButtons(["Cancel", true], "on");
$("wiGo").focus();
} catch (e) {
if (gen !== wi.gen) return;
$("wiMsg").textContent = e.message;
wiButtons(["Close", true], null);
log(`Install link refused: ${e.message}`, "e");
}
}
async function wiPoll(gen) {
if (gen !== wi.gen || !wi.job) return;
let j;
try {
j = await api(`/api/webinstall/job?id=${encodeURIComponent(wi.job)}`);
} catch (e) {
$("wiMsg").textContent = e.message;
return setTimeout(() => wiPoll(gen), 1500);
}
if (gen !== wi.gen) return;
const bar = $("wiProg").firstElementChild;
$("wiProg").hidden = false;
if (j.phase === "download") {
bar.style.width = j.total ? (100 * j.done / j.total) + "%" : "0";
$("wiMsg").textContent = `Downloading ${wiSize(j.done)}` + (j.total ? ` of ${wiSize(j.total)}` : "");
wiButtons(["Stop download", true], "off");
} else if (j.phase === "install") {
bar.style.width = "100%";
$("wiMsg").textContent = j.detail || "Installing on the Frame…";
wiButtons(["Stop download", false], "off");
} else {
wi.job = null;
$("wiProg").hidden = true;
if (j.phase === "done") {
log(j.message, "ok"); toast(j.message);
$("wiDlg").close();
loadAndroid(); loadTitles(); refresh();
} else {
$("wiMsg").textContent = j.error;
log(`Install from link failed: ${j.error}`, "e"); toast(`Install failed: ${j.error}`, true);
wiButtons(["Close", true], null);
}
return;
}
setTimeout(() => wiPoll(gen), 500);
}
$("wiGo").onclick = async () => {
const p = wi.plan, gen = wi.gen;
if (!p) return;
wi.plan = null; // one click, one install
wiButtons(["Cancel", false], "off");
$("wiMsg").textContent = "Starting…";
wi.starting = true; // the dialog stays open until the job is known
try {
wi.job = (await api("/api/webinstall/start", { id: p.id })).job;
log(`Installing ${p.name} from ${p.host}…`);
wiPoll(gen);
} catch (e) {
$("wiMsg").textContent = e.message;
wiButtons(["Close", true], null);
} finally { wi.starting = false; }
};
$("wiCancel").onclick = async () => {
if (!wi.job) return $("wiDlg").close();
try { await api("/api/webinstall/cancel", { job: wi.job }); } catch (e) { $("wiMsg").textContent = e.message; }
};
// Escape doesn't close the dialog while an install runs; it keeps showing progress.
$("wiDlg").addEventListener("cancel", e => { if (wi.job || wi.starting) e.preventDefault(); });
$("wiDlg").addEventListener("close", () => { wi.gen++; wi.open = false; wi.plan = null; wi.job = null; setTimeout(wiNext); });
if (window.frameApp && window.frameApp.onInstallLink) {
window.frameApp.onInstallLink(req => { if (wi.queue.length < 5) wi.queue.push(req); wiNext(); });
}
setView("headset");
refresh().then(() => loadShots()); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
// ---- the connection and the Devices tab (ui/frame_link.py, ui/frame_devices.py, docs/devices.md) ----
// The server streams the connection's state (server-sent events, read with fetch so the
// X-Frame-UI header goes along) every time it changes: which headset, this computer's
// network, each stage of connecting, and what every address answered.
const link = { s: null, offset: 0, live: false, phase: null, device: null, rev: -1, lastData: 0, reload: false };
const dv = { list: [], sel: null, data: null, found: null, editing: null };
const STAGE_ICON = { done: "✓", failed: "✕", pending: "", active: "", skipped: "–" };
const KIND_TAG = { lan: "LAN", mdns: "mDNS", tailscale: "Tailscale", manual: "Other" };
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const serverNow = () => Date.now() / 1000 - link.offset;
function ago(t) {
if (!t) return "never";
const s = Math.max(0, serverNow() - t);
return s < 60 ? "just now" : s < 3600 ? `${Math.round(s / 60)} min ago` : s < 86400 ? `${Math.round(s / 3600)} h ago`
: `${Math.round(s / 86400)} d ago`;
}
function secs(a, b) { return a && b ? `${Math.max(0, b - a).toFixed(1)} s` : a ? `${Math.max(0, serverNow() - a).toFixed(0)} s` : ""; }
const activeStage = s => (s.stages || []).find(x => x.state === "active") || (s.stages || []).filter(x => x.state === "failed").pop();
function netLabel(s) {
const n = s.network;
if (s.via && s.via.kind === "tailscale") return "Tailscale";
return n ? n.name : "Checking the network";
}
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
// The pill in the header: always there, and says what's happening right now.
function renderPill() {
const s = link.s;
if (!link.live || !s) return false;
const dev = s.device ? s.device.name : "Frame";
let dot = "wait", line;
if (s.phase === "connected") {
dot = "on";
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
} else if (s.phase === "failed") {
dot = "off";
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
} else {
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…")
: "Connecting…";
}
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
$("conn").title = `${dev}: ${line}. Click for details.`;
return true;
}
window.connPill = renderPill;
// The banner (when the headset can't be reached) repeats the reason and the countdown.
function renderBanner() {
const s = link.s;
if (!link.live || !s) return;
$("offDetails").hidden = false;
if (s.phase === "failed" && s.error) {
$("offline").hidden = false;
$("offMsg").textContent = s.error.message;
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
(left != null ? `Trying again in ${left} s.` : "");
} else if (s.phase === "connecting" && online === false) {
const st = activeStage(s);
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
}
}
window.connBanner = renderBanner;
function renderConnDlg() {
const s = link.s;
if (!s || !$("connDlg").open) return;
$("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection";
$("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}`
: s.phase === "failed" ? (s.error ? s.error.message : "Not connected")
: `${s.reason || "Connecting"}…`;
const n = s.network || {}, ts = n.tailscale || {};
const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")],
["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"],
["This computer", n.local_ip || "—"],
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
<span class="lb">${esc(st.id === "login" && s.device && s.device.user ? "Logging in as " + s.device.user : st.label)}</span>
<span class="tm">${esc(st.state === "pending" ? "" : secs(st.started, st.ended))}</span>
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
$("connProbes").innerHTML = (s.probes || []).map(p => `<tr><td>${esc(p.host)}${p.label ? `<div class="sub">${esc(p.label)}</div>` : ""}</td>
<td><span class="tag">${esc(KIND_TAG[p.kind] || p.kind)}</span></td><td class="sub">${esc(p.why || "")}</td>
<td class="res-${esc(p.state)}">${esc(p.detail)}${p.ip && p.ip !== p.host ? ` <span class="sub">${esc(p.ip)}</span>` : ""}</td></tr>`).join("")
|| `<tr><td class="sub">No addresses tried yet.</td></tr>`;
const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
}
function onConnection(s) {
const prev = link.s;
link.s = s; link.live = !s.local; link.lastData = Date.now();
link.offset = Date.now() / 1000 - s.now;
if (!link.live) return;
renderPill(); renderConnDlg();
const devId = s.device && s.device.id;
window.connDevice = devId || null;
if (link.device !== null && devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
online = null;
devGen++; // answers still on their way are about the other headset
refreshing = null; // and the next status check must be a new one
if (live) toggleLive(false); // the other headset's video or captures
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
viewGen++; // a capture still on its way is the other headset's too
lastImg = null; lastShot = null;
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
$("saveBtn").disabled = $("copyBtn").disabled = true;
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
// before anyone clicks one, until the new headset's arrive.
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
if ($(id)) $(id).innerHTML = `<div class="wait">Waiting for ${esc(s.device.name)}</div>`;
});
disp.list = []; disp.port = null;
// The lists behind those panels too, so filters can't bring the old ones back.
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
androidApps = []; resetShots();
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
// Confirmations still open were checked against the other headset.
["titleDlg", "apkAltDlg", "pwDlg", "repDlg"].forEach(id => { if ($(id) && $(id).open) $(id).close(); });
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true;
}
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase === "connected") {
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
$("offline").hidden = true;
const reload = link.reload;
link.reload = false;
refresh().then(() => { if (reload) reloadAll(); checkContactPrompt(); });
} else if (s.phase === "failed" && s.error) {
setOnline(false, s.error.message);
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
log(`${s.reason || "Reconnecting"}; reconnecting…`);
}
}
renderBanner();
const netId = s.network ? s.network.id || s.network.gateway || "" : null;
if (s.devices_rev !== link.rev || devId !== link.device || (netId !== null && netId !== link.net)) {
link.rev = s.devices_rev; link.net = netId; // also when only the network changed: the Devices tab shows it
loadDevices();
}
link.phase = s.phase; link.device = devId;
if (page === "devices") renderTests();
}
// Read the event stream; fall back to polling if it can't be streamed.
async function watchConnection() {
for (;;) {
const ctl = new AbortController();
const watchdog = setInterval(() => { if (Date.now() - link.lastData > 40000) ctl.abort(); }, 5000);
try {
link.lastData = Date.now();
const r = await fetch("/api/connection/events", { headers: { "X-Frame-UI": UI_KEY }, signal: ctl.signal });
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
const reader = r.body.getReader(), dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
link.lastData = Date.now();
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
if (chunk.startsWith("data: ")) onConnection(JSON.parse(chunk.slice(6)));
}
}
} catch {
try { onConnection(await api("/api/connection")); } catch {}
} finally { clearInterval(watchdog); }
if (link.s && link.s.local) return;
await sleep(2000);
}
}
setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { renderPill(); renderBanner(); renderConnDlg(); } }, 1000);
$("conn").onclick = () => {
if (!link.live) { if (online === false) refresh(); return; }
$("connDlg").showModal(); renderConnDlg();
};
$("offDetails").onclick = () => $("conn").click();
$("connClose").onclick = () => $("connDlg").close();
$("connDevices").onclick = () => { $("connDlg").close(); location.hash = "devices"; };
async function retryNow() {
try { await api("/api/devices", { action: "retry" }); } catch (e) { toast(e.message, true); }
}
$("connRetryBtn").onclick = retryNow;
$("offRetry").onclick = () => { if (link.live) retryNow(); else refresh(); };
async function setUpHeadset(alias, host) {
return act(`Set Up Connection for ${alias}`, () => api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) }));
}
function setUpActive() {
const s = link.s;
if (HOST.mobile && window.frameApp && window.frameApp.setUpConnection) return window.frameApp.setUpConnection();
setUpHeadset(s && s.device ? s.device.alias : "frame");
}
$("connSetup").onclick = setUpActive;
// ---- Devices tab ----
PAGES.push("devices");
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
async function loadDevices() {
if (!link.live) return;
const seq = ++devicesSeq;
let data;
try { data = await api("/api/devices"); } catch (e) { if (seq === devicesSeq) failed($("devList"), e); return; }
if (seq !== devicesSeq) return;
dv.data = data;
dv.list = dv.data.devices;
if (!dv.list.some(d => d.id === dv.sel)) dv.sel = dv.data.active;
const sel = $("devSel");
sel.hidden = dv.list.length < 2;
sel.innerHTML = dv.list.map(d => `<option value="${esc(d.id)}"${d.active ? " selected" : ""}>${esc(d.name)}</option>`).join("");
if (window.frameApp && window.frameApp.devicesChanged) {
window.frameApp.devicesChanged(dv.list.map(d => ({ id: d.id, name: d.name, alias: d.alias, active: !!d.active })));
}
renderDevices();
}
$("devSel").onchange = e => useDevice(e.target.value);
async function useDevice(id) {
const d = dv.list.find(x => x.id === id);
if (!d || d.active) return;
const res = await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id }));
if (!res) { // refused (an install is running): show the headset still in use
const current = dv.list.find(x => x.active);
if (current) $("devSel").value = current.id;
}
}
async function devAction(label, body, btn) {
const res = await act(label, () => api("/api/devices", body), btn);
if (res) loadDevices(); // in order with every other load, so a late answer can't undo a newer one
return res;
}
function deviceStatus(d) {
const s = link.s;
if (d.active && s) {
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
}
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
}
function renderDevices() {
if (!dv.data) return;
$("devCount").textContent = dv.list.length;
$("devList").innerHTML = dv.list.map(d => {
const [dot, text] = deviceStatus(d);
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
if (e.target.closest("[data-use]")) return useDevice(e.target.closest("[data-use]").dataset.use);
dv.sel = el.dataset.dev; dv.found = null; dv.editing = null; renderDevices();
});
const n = dv.data.network;
$("netNow").textContent = n ? `${n.name}${n.gateway && !n.name.includes(n.gateway) ? ` (router ${n.gateway})` : ""}${n.tailscale && n.tailscale.up ? " · Tailscale on" : ""}` : "Checking…";
if (document.activeElement !== $("netName")) {
const known = n && dv.data.networks.find(x => x.id === n.id);
$("netName").value = known ? known.name || "" : "";
$("netName").disabled = $("netSave").disabled = !(n && n.id);
}
renderDetail();
}
$("netSave").onclick = e => {
const n = dv.data && dv.data.network;
if (n && n.id) devAction("Name this network", { action: "name-network", network: n.id, name: $("netName").value }, e.currentTarget);
};
function renderDetail() {
const d = dv.list.find(x => x.id === dv.sel), el = $("devDetail");
if (!d) { el.innerHTML = `<div class="sub">Pick a headset.</div>`; return; }
if (el.contains(document.activeElement) && document.activeElement.matches("input, select") && el.dataset.dev === d.id) {
renderTests(); return; // don't rebuild the form under someone typing
}
el.dataset.dev = d.id;
if (d.transient) {
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2></div>
<div class="sub">This is the <code>${esc(d.alias)}</code> SSH alias, which Set Up Connection hasn't configured, so
ssh's own settings decide where it goes. Run Set Up Connection to manage its addresses here.</div>
<div class="row" style="margin-top:14px"><button class="action small" id="dvSetup">Set Up Connection…</button></div>`;
$("dvSetup").onclick = () => setUpHeadset(d.alias);
return;
}
const kinds = Object.entries(dv.data.kinds);
const kindSel = (id, v) => `<select id="${id}">${kinds.map(([k, label]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(label)}</option>`).join("")}</select>`;
const rows = d.addresses.map((a, i) => dv.editing === a.host ? `<div class="item addr"><div class="addr-edit">
<input type="text" id="edHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">${kindSel("edKind", a.kind)}
<input type="text" id="edLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
<button class="small action" id="edSave">Save</button><button class="small" id="edCancel">Cancel</button></div></div>`
: `<div class="item addr" data-host="${esc(a.host)}">
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
<div class="s">${a.network_names.length ? "Worked on " + esc(a.network_names.join(", ")) : "Hasn't worked on any network yet"}
· last OK ${esc(ago(a.last_ok))}${a.last_rtt_ms != null ? ` (${esc(a.last_rtt_ms)} ms)` : ""}</div>
<div class="s test" data-test="${esc(a.host)}"></div></div>
<button class="small" data-mv="-1" title="Try earlier"${i ? "" : " disabled"}>↑</button>
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
<form class="dev-form" id="dvForm">
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
</form>
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
<form class="addr-add" id="dvAdd">
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
${kindSel("adKind", "")}
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
<button class="small" type="submit">Add</button></form>
<div class="row" style="margin-top:14px">
<button class="small action" id="dvTest"${d.addresses.length ? "" : " disabled"}>Test now</button>
<button class="small" id="dvTs">Find on Tailscale</button>
<button class="small" id="dvMdns">Find on this network</button>
<span class="spacer"></span>
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
<button class="small danger" id="dvRemove">Remove…</button></div>
<div class="found" id="dvFound"></div>
<div class="hint">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
$("adKind").value = "";
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
$("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget);
$("dvAdd").onsubmit = e => {
e.preventDefault();
const host = $("adHost").value.trim();
if (!host) return $("adHost").focus();
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value });
};
el.querySelectorAll("[data-host]").forEach(row => {
const host = row.dataset.host;
row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () =>
devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b));
row.querySelector("[data-ed]").onclick = () => { dv.editing = host; el.dataset.dev = ""; renderDetail(); $("edHost").focus(); };
row.querySelector("[data-rm]").onclick = e => devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
});
if ($("edSave")) {
const host = dv.editing;
$("edSave").onclick = async e => {
dv.editing = null;
await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost: $("edHost").value.trim(),
kind: $("edKind").value, label: $("edLabel").value }, e.currentTarget);
};
$("edCancel").onclick = () => { dv.editing = null; el.dataset.dev = ""; renderDetail(); };
}
$("dvTest").onclick = e => devAction(`Test ${d.name}'s addresses`, { action: "test", id: d.id }, e.currentTarget);
$("dvTs").onclick = e => findOn("tailscale", d, e.currentTarget);
$("dvMdns").onclick = e => findOn("mdns", d, e.currentTarget);
$("dvForget").onclick = e => devAction(`Forget ${d.name}'s identity`, { action: "forget-identity", id: d.id }, e.currentTarget);
$("dvRemove").onclick = () => askRemove(d);
renderFound(d);
renderTests();
}
function renderTests() {
const d = dv.list.find(x => x.id === dv.sel), t = d && link.s && (link.s.tests || {})[d.id];
document.querySelectorAll("#dvAddrs [data-test]").forEach(el => {
const row = t && t.rows.find(r => r.host === el.dataset.test);
el.className = "s test" + (row ? ` res-${row.ssh || row.state}` : "");
el.textContent = row ? `Test: ${row.detail}${row.ssh === "checking" ? " · checking SSH…" : ""}` : "";
});
}
async function findOn(where, d, btn) {
btn.disabled = true;
$("dvFound").innerHTML = `<div class="wait">${where === "tailscale" ? "Asking Tailscale for its devices…" : "Looking for headsets on this network…"}</div>`;
try {
dv.found = { where, dev: d.id, data: await api(`/api/devices/${where}?id=${encodeURIComponent(d.id)}`) };
} catch (e) { dv.found = { where, dev: d.id, error: e.message }; }
finally { btn.disabled = false; }
if (dv.sel === d.id) renderFound(d); // another headset may be showing by now
}
function renderFound(d) {
const f = dv.found, el = $("dvFound");
if (!f || f.dev !== d.id) { el.innerHTML = ""; return; }
if (f.error) { el.innerHTML = `<div class="sub">${esc(f.error)}</div>`; return; }
const add = (host, kind, label, done) => done ? `<span class="sub">Added</span>`
: `<button class="small" data-add="${esc(host)}" data-kind="${kind}" data-label="${esc(label)}">Add ${esc(host)}</button>`;
let rows;
if (f.where === "tailscale") {
if (!f.data.up) { el.innerHTML = `<div class="sub">${esc(f.data.message)}</div>`; return; }
rows = f.data.peers.slice(0, 20).map(p => `<div class="item"><span class="dot ${p.online ? "on" : ""}"></span>
<div class="grow"><div class="t">${esc(p.name)} ${p.likely ? '<span class="tag">Likely</span>' : ""}</div>
<div class="s">${esc(p.dns)} · ${esc(p.ip || "")} · ${esc(p.os || "?")} · ${p.online ? "online" : "offline"}</div></div>
${p.added ? '<span class="sub">Added</span>' : add(p.dns || p.ip, "tailscale", "Tailscale", false) + (p.ip && p.dns ? add(p.ip, "tailscale", "Tailscale IP", false) : "")}</div>`);
} else {
rows = f.data.hosts.map(h => `<div class="item"><span class="dot ${h.state === "answered" ? "on" : ""}"></span>
<div class="grow"><div class="t">${esc(h.host)} ${h.advertised ? '<span class="tag">SteamOS devkit</span>' : ""}</div>
<div class="s res-${esc(h.state)}">${esc(h.detail)}${h.ip ? " · " + esc(h.ip) : ""}</div></div>
${add(h.host, "mdns", "mDNS", h.added)}${h.ip && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
if (!f.data.tool) rows.push(`<div class="sub">Install Bonjour (Windows) or avahi (Linux) to search for SteamOS devkit services.</div>`);
}
el.innerHTML = `<h3>${f.where === "tailscale" ? "On your tailnet" : "On this network"}</h3>` +
(rows.length ? `<div class="list">${rows.join("")}</div>` : `<div class="sub">Nothing found.</div>`);
el.querySelectorAll("[data-add]").forEach(b => b.onclick = async () => {
const res = await devAction(`Add ${b.dataset.add}`, { action: "address-add", id: d.id, host: b.dataset.add,
kind: b.dataset.kind, label: b.dataset.label }, b);
if (res) { b.replaceWith(Object.assign(document.createElement("span"), { className: "sub", textContent: "Added" })); }
});
}
function askRemove(d) {
$("rmDevTitle").textContent = `Remove ${d.name}?`;
$("rmDevText").textContent = `Frame Control forgets this headset, its ${d.addresses.length} address${d.addresses.length === 1 ? "" : "es"} and its saved identity. Its SSH keys stay.`;
$("rmDevConfigText").textContent = `Also remove the '${d.alias}' entry from ~/.ssh/config (Terminal's ssh ${d.alias} stops working)`;
// The only headset: its entry has to go too, or the app would go on using that alias.
$("rmDevConfig").checked = dv.list.filter(x => !x.transient).length === 1; $("rmMsg").textContent = "";
$("rmDevConfig").parentElement.hidden = !d.managed;
$("rmCancel").onclick = () => $("rmDevDlg").close();
$("rmDevForm").onsubmit = async e => {
e.preventDefault();
const res = await devAction(`Remove ${d.name}`, { action: "remove", id: d.id, config: $("rmDevConfig").checked }, $("rmGo"));
if (res) $("rmDevDlg").close();
};
$("rmDevDlg").showModal();
}
$("devAdd").onclick = () => {
$("addAlias").value = (dv.data && dv.data.nextAlias) || "frame-2";
$("addHost").value = ""; $("addMsg").textContent = "";
$("addCancel").onclick = () => $("addDevDlg").close();
$("addDevDlg").showModal();
$("addAlias").select();
};
$("addDevForm").onsubmit = async e => {
e.preventDefault();
const alias = $("addAlias").value.trim(), host = $("addHost").value.trim();
$("addGo").disabled = true;
try {
const res = await api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) });
log(res.message, "ok"); toast(res.message);
$("addDevDlg").close();
} catch (err) { $("addMsg").textContent = err.message; }
finally { $("addGo").disabled = false; }
};
// Electron's Frame menu can switch headsets and open this tab.
if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(id => useDevice(id));
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
showPage(); // #devices is a page now
api("/api/host").then(h => {
if (h.mobile) return;
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
watchConnection();
}).catch(() => watchConnection());
</script>
<script src="/artwork-settings.js"></script>
</body>
</html>