@@ -27,8 +27,10 @@
}
}
* { box-sizing: border-box; }
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* class display rules would otherwise win */
[hidden] { display: none !important; } /* class display rules would otherwise win */
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
/* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
between them, so neither covers anything. Phones scroll the window (see the phone rules below). */
body { margin: 0; color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden;
padding-bottom: calc(42px + env(safe-area-inset-bottom));
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
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%),
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; }
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
@@ -38,13 +40,14 @@
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-track { background: transparent; }
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; ga p: 30 px;
#scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-to p: 12 px; }
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px; flex: none; min-width: 0;
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);
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); }
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 { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; flex: none; }
.brand svg { width: 30px; height: 30px; }
.brand svg { width: 30px; height: 30px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
nav { display: flex; gap: 4px; height: 100%; }
nav { display: flex; gap: 4px; height: 100%; flex: none; }
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
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;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
border-bottom: 3px solid transparent; transition: color .15s; }
border-bottom: 3px solid transparent; transition: color .15s; }
@@ -58,10 +61,18 @@
.dot.off { background: var(--bad); }
.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; }
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-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 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; } }
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
@media (max-width: 820px) { .brand b { display: none; } }
connection pill drops its second line. It fits down to the app's 760 px minimum, with room for
the macOS window buttons the app adds on the left. */
header > button:not(.pill), header > .chip { flex: none; }
header > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
@media (max-width: 1340px) { .brand b { display: none; } header { gap: 22px; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 14px; 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: 960px) { header { gap: 10px; } nav { gap: 0; } nav a { padding: 0 8px; font-size: 13px; letter-spacing: .4px; }
.brand, #refreshAll { display: none; }
.pill .pt > b, .pill .via { display: none; } .pill .pt > span { font-size: 13px; color: var(--bright); } }
nav svg { display: none; }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
body { -webkit-tap-highlight-color: transparent; }
@@ -87,13 +98,35 @@
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
.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) ---- */
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 4 20px; min-width: 190 px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 3 20px; min-width: 124 px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.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; }
.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; } }
@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 { 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 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: hidd en; text-overflow: ellipsis; white-space: nowrap; }
/* "Connected" is never cut short: wh en the route doesn't fit beside it, the route wraps out of sight. */
.pill .pt > span { font-size: 11.5px; color: var(--muted); max-width: 100%; display: flex; flex-wrap: wrap; height: 1.2em; overflow: hidden; white-space: nowrap; }
.pill .via { white-space: pre; }
.conn-now { display: flex; gap: 10px; align-items: baseline; }
.conn-now .dot { position: relative; top: -1px; }
.conn-now b { color: var(--bright); font-weight: 500; font-size: 15px; }
.conn-offer { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding: 10px 12px; border-radius: 4px;
background: rgba(26,159,255,.1); border-left: 3px solid var(--blue); font-size: 13px; }
.conn-offer .grow { flex: 1 1 260px; }
.h3-note { text-transform: none; letter-spacing: 0; font-weight: 400; margin-left: 6px; }
#connAddrs .item { gap: 6px; }
#connAddrs .t { font-size: 13.5px; overflow-wrap: anywhere; }
.conn-add { grid-template-columns: 2fr 1.2fr auto; }
.conn-edit { grid-template-columns: 2fr 1.2fr auto auto; }
.addr-add button.small, .dev-name button.small { height: 40px; }
.dev-name { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; max-width: 520px; }
.adv { margin-top: 18px; border-top: 1px solid rgba(255,255,255,.06); padding-top: 4px; }
.adv .dev-form { grid-template-columns: 1.3fr 1fr .6fr; }
.conn-more { margin-top: 16px; }
.conn-more summary { margin-top: 0; }
/* The dialog scrolls when the steps are open in a short window; its buttons stay in reach. */
#connDlg .rep-actions { position: sticky; bottom: -22px; margin: 16px -22px -22px; padding: 12px 22px;
background: #1e2329; border-top: 1px solid rgba(255,255,255,.06); }
.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; }
.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;
.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); }
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
@@ -134,8 +167,8 @@
.addr .t { overflow-wrap: anywhere; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.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-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; }
.addr-add { display: grid; grid-template-columns: 2fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add, .adv .dev-form, .conn-add, .conn-edit { grid-template-columns: 1fr; } }
.found { margin-top: 10px; }
.found { margin-top: 10px; }
/* ---- drop anywhere ---- */
/* ---- drop anywhere ---- */
@@ -149,8 +182,10 @@
/* ---- panels and section headers ---- */
/* ---- panels and section headers ---- */
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
.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; }
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; }
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700 ; letter-spacing: 2px; text-transform: uppercase; color: var(--text) ; }
.shelf-head { display: flex; align-items: baseline ; f lex-wrap: wrap; gap: 8px 12px; margin: 0 0 14px ; }
.panel > .shelf-head:first-child h2 { line-height: 28px; } /* as tall as a button, so headings line up across panels */
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); white-space: nowrap; }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .count { color: var(--muted); font-size: 13px; }
.shelf-head .spacer { flex: 1; }
.shelf-head .spacer { flex: 1; }
.sub { color: var(--muted); font-size: 12.5px; }
.sub { color: var(--muted); font-size: 12.5px; }
@@ -179,6 +214,11 @@
border-radius: 3px; padding: 9px 11px; font: inherit; }
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
/* Every drop-down dark, like the fields; the panels' own rules below still size them. */
select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 7px 8px; font: inherit; font-size: 13px; max-width: 100%; min-width: 0; }
select:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
option { background: #1e2329; color: var(--text); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* ---- headset view ---- */
/* ---- headset view ---- */
@@ -223,15 +263,21 @@
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin { to { transform: rotate(360deg); } }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
/* ---- device ---- */
/* ---- the headset's details: a menu under the battery chip ---- */
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
.pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
.battery svg { width: 74px; height: 38px; flex: none; }
padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text);
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums ; }
box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag ; }
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top : 6 px; }
.pop .shelf-head { margin-bot tom : 10 px; }
.battery .state.charging { co lor : var(--green-hi); } .battery .state.full { color: var(--green) ; }
.pop .shelf-head h2 { min-width: 0; overf low : hidden; text-overflow: ellipsis ; }
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted) ; }
.pop .stats { margin-top: 12px ; }
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.pop .chips { margin-top: 1 2px; }
.batt .state { font-weight: 700; font-size: 12.5px; letter-spacing: 1.2px; text-transform: uppercase; }
.batt .state.charging { color: var(--green-hi); } .batt .state.full { color: var(--green); }
.batt .state.low { color: var(--bad); } .batt .state.draining { color: var(--text); }
.batt .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
button.chip { height: 32px; padding: 0 12px; font-size: 13px; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
.stats > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
.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 .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;
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
@@ -322,7 +368,7 @@
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
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: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); }
.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;
.toast { position: fixed; inset: auto; margin: 0; border: 0; overflow: visible; width: auto; height: auto; 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);
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; }
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
.toast.show { opacity: 1; transform: none; }
.toast.show { opacity: 1; transform: none; }
@@ -350,7 +396,7 @@
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#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;
#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; }
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm select:focus { border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
#titleList { margin-top: 8px; }
@@ -397,7 +443,6 @@
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
.f-0 { color: var(--muted); }
.f-0 { color: var(--muted); }
/* ---- Android display ---- */
/* ---- 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-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 { 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-perm .row { margin-top: 8px; }
@@ -406,7 +451,7 @@
.mv-opt .sub { display: block; margin-left: 22px; font-size: 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;
.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; }
padding: 8px 10px; font: inherit; font-size: 13px; }
.disp select:focus { outline: none; border-color: var(--blue); }
.disp select:focus { border-color: var(--blue); }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
.disp select:disabled { opacity: .6; }
.disp select:disabled { opacity: .6; }
.disp .stat .v { font-size: 15px; }
.disp .stat .v { font-size: 15px; }
@@ -433,11 +478,17 @@
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
/* ---- 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. ---- */
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) {
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
html { scroll-behavior: smooth; scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
body { height: auto; display: block; overflow: visible; }
#scroll { overflow: visible; }
/* No blur here: it would make the header the containing block of the fixed tab bar. */
/* 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;
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); }
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
.brand svg { width: 26px; height: 26px; }
.brand { display: flex; } #refreshAll { display: inline-flex; } /* the tabs are at the bottom here */
.pill .pt > b { display: block; } .pill .via { display: inline; } .pill .pt > span { font-size: 11.5px; color: var(--muted); }
.pill { min-width: 104px; }
@media (max-width: 374px) { .brand { display: none; } } /* the smallest phones: the pill needs the room */
header .chip { height: 30px; padding: 0 10px; }
header .chip { height: 30px; padding: 0 10px; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
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);
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);
@@ -623,7 +674,7 @@
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
.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; }
.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; } }
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
< / style >
< / style >
< / head >
< / head >
@@ -648,15 +699,29 @@
< div class = "spacer" > < / div >
< 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 >
< 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 >
< select class = "devsel desk-only" id = "devSel" aria-label = "Headset" title = "Switch headset" hidden > < / select >
< span class = "chip" id = "battChip" title = "Battery" > —< / span >
< button class = "chip" id = "battChip" title = "Battery and headset details" popovertarget = "devPop" aria-haspopup = "dialog" hidden > —< / button >
< button id = "reportBtn" data-report title = "Report a problem, with diagnostics " aria-label = "Report a problem" >
< button id = "reportBtn" data-report title = "Report a problem or send feedback " aria-label = "Report a problem or send feedback " >
< 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 18H2 z" / > < path d = "M12 10v5M12 18v.5 " / > < / svg >
< svg width = "16 " height = "16 " viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linejoin = "round " aria-hidden = "true" > < path d = "M4 5h16v11H9l-5 4 z" / > < path d = "M8 9.5h8M8 12.5h5" stroke-linecap = "round "/ > < / svg >
< / button >
< / button >
< button id = "refreshAll" title = "Refresh (R)" aria-label = "Refresh" >
< 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 >
< 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 >
< / button >
< / header >
< / header >
< div class = "pop" id = "devPop" popover role = "dialog" aria-labelledby = "devPopTitle" >
< div class = "shelf-head" > < h2 id = "devPopTitle" > Headset< / h2 > < span class = "count" id = "devName" > < / span > < span class = "spacer" > < / span > < span class = "sub" id = "updated" > < / span > < / div >
< div class = "batt" > < div class = "state draining" id = "battState" > < / div > < div class = "detail" id = "battDetail" > < / 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" > < / div > < / div >
< div class = "stat" > < div class = "k" > Wi-Fi< / div > < div class = "v" id = "sWifi" > —< / div > < div class = "d" id = "dWifi" > < / div > < / div >
< div class = "stat" > < div class = "k" > Uptime< / div > < div class = "v" id = "sUp" > —< / div > < div class = "d" id = "dUp" > < / div > < / div >
< div class = "stat" > < div class = "k" > SteamOS< / div > < div class = "v" id = "sOs" > —< / div > < div class = "d" id = "dOs" > < / div > < / div >
< / div >
< div class = "chips" id = "services" aria-label = "Running on the headset" > < / div >
< / div >
< div id = "scroll" >
< main >
< main >
< div class = "notice" id = "updateBar" hidden >
< div class = "notice" id = "updateBar" hidden >
< div class = "grow" id = "updateText" > < / div >
< div class = "grow" id = "updateText" > < / div >
@@ -747,29 +812,7 @@
< / section >
< / section >
< section class = "panel" id = "device" >
< 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 = "shelf-head" > < h2 > Volume < / h2 > < / 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" > < / div >
< div class = "detail" id = "battDetail" > < / 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" > < / div > < / div >
< div class = "stat" > < div class = "k" > Wi-Fi< / div > < div class = "v" id = "sWifi" > —< / div > < div class = "d" id = "dWifi" > < / div > < / div >
< div class = "stat" > < div class = "k" > Uptime< / div > < div class = "v" id = "sUp" > —< / div > < div class = "d" id = "dUp" > < / div > < / div >
< div class = "stat" > < div class = "k" > SteamOS< / div > < div class = "v" id = "sOs" > —< / div > < div class = "d" id = "dOs" > < / div > < / div >
< / div >
< div class = "chips" id = "services" > < / div >
< div class = "vol" >
< div class = "vol" >
< button class = "small" id = "muteBtn" aria-label = "Mute" >
< 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 >
< 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 >
@@ -783,7 +826,7 @@
< section class = "panel" id = "vrTools" >
< 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 = "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 = "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. T emperature is the ho ttest readable sensor .< / 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. Battery and t emperature are in the ba ttery menu at the top .< / div >
< div class = "chips" >
< div class = "chips" >
< button class = "small" id = "hudStart" > Open HUD in headset< / button >
< button class = "small" id = "hudStart" > Open HUD in headset< / button >
< button class = "small" id = "hudStop" > Close HUD< / button >
< button class = "small" id = "hudStop" > Close HUD< / button >
@@ -1087,22 +1130,23 @@
< section class = "panel" id = "panels" >
< section class = "panel" id = "panels" >
< div class = "shelf-head" > < h2 > Panel switcher< / h2 > < span class = "spacer" > < / span >
< div class = "shelf-head" > < h2 > Panel switcher< / h2 > < span class = "spacer" > < / span >
< button class = "small" id = "panelsRefresh" > Refresh< / button >
< button class = "small" id = "panelsRefresh" > Refresh< / button > < / div >
< 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 = "list" id = "panelList" > < div class = "sub" > Refresh to see open panels.< / div > < / div >
< div class = "row" style = "margin-top:12px" > < button class = "small action" id = "panelsOpen" > Open in headset< / button > < / div >
< div class = "hint" > Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
< 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 >
Saved spatial layouts are not available yet.< / div >
< / section >
< / section >
< section class = "panel" id = "macview" hidden >
< 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 >
< 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 >
< button class = "small" id = "mvRefresh" > Refresh< / button > < / div >
< label class = "mv-opt" > < input type = "checkbox" id = "mvSeparate" checked > Give each window its own display
< 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 >
< span class = "sub" > its menus and sheets come along, nothing covers it, and it goes back when you stop< / span > < / label >
< div class = "row" style = "margin:0 0 12px" > < label for = "mvQuality" > Picture quality< / label >
< select id = "mvQuality" title = "Sharper pictures use more of your network" >
< option value = "sharp" > Sharp< / option > < option value = "balanced" selected > Balanced< / option >
< option value = "light" > Light< / option > < option value = "compatible" > Compatible (JPEG)< / option >
< / select > < / div >
< div class = "mv-perm" id = "mvPerm" hidden > < / div >
< div class = "mv-perm" id = "mvPerm" hidden > < / div >
< div class = "list" id = "mvDisplays" > < / 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 = "shelf-head" style = "margin-top:16px" > < h2 > Windows< / h2 > < span class = "count" id = "mvWinCount" > < / span > < / div >
@@ -1154,6 +1198,7 @@
< / div >
< / div >
< / div >
< / div >
< / main >
< / main >
< / div >
< div class = "dropzone" id = "dropzone" hidden > < div > < b > Drop to send to the Frame< / b >
< 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;
< 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 >
a game's < code > .zip< / code > , folder or < code > .exe< / code > becomes a Steam library title.< / span > < / div > < / div >
@@ -1285,18 +1330,25 @@
< / dialog >
< / dialog >
< dialog id = "connDlg" class = "dlg" aria-labelledby = "connTitle" >
< dialog id = "connDlg" class = "dlg" aria-labelledby = "connTitle" >
< h2 id = "connTitle" > Connection< / h2 >
< h2 id = "connTitle" > Connection< / h2 >
< div class = "sub " id = "connWhy " > < / div >
< div class = "conn-now" > < span class = "dot " id = "connDot " > < / span > < div class = "grow" > < b id = "connState" > < / b >
< h3 > This computer < / h3 >
< div class = "sub" id = "connWhy" > < / div > < / div > < / div >
< dl class = "facts " id = "connNet" > < / dl >
< div class = "conn-offer " id = "connOffer" hidden > < / div >
< h3 > Steps < / h3 >
< h3 > Addresses < span class = "h3-note" > Frame Control tries them all at once; the first in the list wins a tie < / span > < / h3 >
< ol class = "stages " id = "connStage s" > < / ol >
< div class = "li st" id = "connAddr s" > < / div >
< h3 > Addresses < / h3 >
< form class = "addr-add conn-add" id = "connAdd" >
< table class = "probes" > < tbody id = "connProbes" > < / tbody > < / table >
< input type = "text" id = "caHost" maxlength = "253" placeholder = "Add an IP address or host name" aria-label = "New address" >
< input type = "text" id = "caLabel" maxlength = "60" placeholder = "Label, e.g. Home" aria-label = "Label for the new address" >
< button class = "small" type = "submit" > Add< / button > < / form >
< details class = "conn-more" id = "connMore" > < summary > Connection steps< / summary >
< ol class = "stages" id = "connStages" > < / ol >
< h3 > This computer< / h3 >
< dl class = "facts" id = "connNet" > < / dl >
< / details >
< div class = "row rep-actions" > < span class = "sub" id = "connRetry" > < / span > < span class = "spacer" > < / span >
< 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 = "connDevices" > All headsets… < / button >
< button type = "button" class = "small desk-only" id = "connSetup" > Set Up Connection…< / button >
< button type = "button" class = "small desk-only" id = "connSetup" hidden > Set Up Connection…< / button >
< button type = "button" class = "small action " id = "connRetryBtn" > Retry now< / button >
< button type = "button" class = "small" id = "connRetryBtn" hidden > Retry now< / button >
< button type = "button" class = "small" id = "connClose" > Clos e< / button > < / div >
< button type = "button" class = "small" id = "connClose" autofocus > Don e< / button > < / div >
< / dialog >
< / dialog >
< dialog id = "addDevDlg" class = "dlg" aria-labelledby = "addDevTitle" >
< dialog id = "addDevDlg" class = "dlg" aria-labelledby = "addDevTitle" >
< form method = "dialog" id = "addDevForm" >
< form method = "dialog" id = "addDevForm" >
@@ -1322,7 +1374,7 @@
< button type = "submit" class = "small danger" id = "rmGo" > Remove< / button > < / div >
< button type = "submit" class = "small danger" id = "rmGo" > Remove< / button > < / div >
< / form >
< / form >
< / dialog >
< / dialog >
< div class = "toast" id = "toast" > < / div >
< div class = "toast" id = "toast" popover = "manual" > < / div >
< script >
< script >
const $ = (id) => document.getElementById(id);
const $ = (id) => document.getElementById(id);
@@ -1344,6 +1396,9 @@ function esc(s) { return String(s ?? "").replace(/[&<>"']/g, 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 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);
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`; }
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
// What scrolls: #scroll on a computer, where the header and Activity bar stay put; the window on phones.
function scroller() { return getComputedStyle($("scroll")).overflowY === "visible" ? document.scrollingElement : $("scroll"); }
function scrollToY(y) { scroller().scrollTo({ top: y, behavior: "instant" }); }
function meter(el, pct, warnAt, badAt) {
function meter(el, pct, warnAt, badAt) {
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
@@ -1362,6 +1417,15 @@ let toastTimer;
function toast(msg, err = false) {
function toast(msg, err = false) {
const el = $("toast");
const el = $("toast");
el.textContent = msg.split("\n").slice(-1)[0] || msg;
el.textContent = msg.split("\n").slice(-1)[0] || msg;
// A popover, so it shows above an open dialog (a modal one covers everything else): opened
// again when a dialog has opened since, which puts it back on top. Opened hidden first, so
// it still fades in.
if (el.showPopover & & (!el.matches(":popover-open") || document.querySelector("dialog:modal"))) {
el.className = "toast" + (err ? " err" : "");
try { el.hidePopover(); } catch {}
el.showPopover();
void el.offsetWidth; // the hidden style is in place before .show changes it
}
el.className = "toast show" + (err ? " err" : "");
el.className = "toast show" + (err ? " err" : "");
clearTimeout(toastTimer);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
@@ -1464,7 +1528,7 @@ function setOnline(ok, why) {
if (!ok) {
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
setConn(false, "Offline");
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
if (window.connBanner) window.connBanner();
} else if (was === false) {
} else if (was === false) {
@@ -1512,7 +1576,7 @@ function renderPerformance(s) {
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
["GPU clock", p.gpuMHz, " MHz"], ["Hottest sensor", s?.temp, "°C"], ["Battery", s?.battery?.percent, "%"] ];
["GPU clock", p.gpuMHz, " MHz"]];
$("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("");
$("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";
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
}
}
@@ -1644,10 +1708,6 @@ function battery(b, power) {
const draining = !full & & !charging & & !idle & & (!power || b?.status === "Discharging" || b?.watts < 0 ) ;
const draining = !full & & !charging & & !idle & & (!power || b?.status === "Discharging" || b?.watts < 0 ) ;
const low = !charging & & !full & & pct != null & & pct < = 15;
const low = !charging & & !full & & pct != null & & pct < = 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null & & pct < = 30 ? "#d9a23a" : "#dcdedf";
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 = [];
let label, cls, detail = [];
if (!b) { label = "Battery unknown"; cls = "draining"; }
if (!b) { label = "Battery unknown"; cls = "draining"; }
@@ -1663,7 +1723,7 @@ function battery(b, power) {
if (b?.timeToEmpty > 0 & & b.timeToEmpty < 86400 ) detail . push ( ` About $ { dur ( b . timeToEmpty ) } left ` ) ;
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 (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`);
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
$("battState").textContent = label;
$("battState").textContent = label;
$("battState").className = "state " + cls;
$("battState").className = "state " + cls;
$("battDetail").textContent = detail.join(" · ") || " ";
$("battDetail").textContent = detail.join(" · ") || " ";
@@ -1672,13 +1732,25 @@ function battery(b, power) {
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
$("battChip").innerHTML = pct == null ? "—" :
$("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 > `;
`< 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").title = `${ label}. Click for the headset's details.` ;
$("battChip").hidden = pct == null ;
$("battChip").setAttribute("aria-label", pct == null ? "Headset details" : `Battery ${pct}%, ${label}. Headset details`) ;
$("battChip").hidden = false; // even without a battery reading: it's the way to the headset's details
}
}
// hidePopover throws where the menu isn't open (and doesn't exist in older browsers).
function closeDevPop() { try { $("devPop").hidePopover(); } catch {} }
// The battery chip's menu opens under the chip, right-aligned with it.
$("devPop").addEventListener("beforetoggle", e => {
if (e.newState !== "open") return;
const r = $("battChip").getBoundingClientRect();
$("devPop").style.top = `${Math.round(r.bottom + 8)}px`;
const width = Math.min(390, innerWidth - 24); // as .pop's CSS width
$("devPop").style.right = `${Math.max(12, Math.min(Math.round(innerWidth - r.right), innerWidth - width - 12))}px`;
});
function render(s) {
function render(s) {
renderPerformance(s);
renderPerformance(s);
battery(s.battery, s.power);
battery(s.battery, s.power);
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
const h = s.disk.home;
const h = s.disk.home;
@@ -2695,7 +2767,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
}, dev));
}, dev));
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
await loadAndroid();
await loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
if (res & & res.app) await offerTest(res.app);
if (res & & res.app) await offerTest(res.app);
};
};
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
@@ -2929,7 +3001,7 @@ function renderChips() {
}
}
function filterGames(keepScroll) {
function filterGames(keepScroll) {
const y = window .scrollY ;
const y = scroller() .scrollTop ;
if (gm.src === "owned") {
if (gm.src === "owned") {
if (!gm.owned) return;
if (!gm.owned) return;
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
@@ -2945,7 +3017,7 @@ function filterGames(keepScroll) {
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
if (!gm.results.length) $("gmGrid").innerHTML = `< div class = "sub" > ${gm.src === "store" & & !gm.storeQ
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 > `;
? "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);
if (keepScroll) scrollToY (y);
}
}
function price(p) {
function price(p) {
@@ -3300,7 +3372,7 @@ document.body.addEventListener("click", async e => {
if (action === "probe") { loadCatalog(); loadReports(); }
if (action === "probe") { loadCatalog(); loadReports(); }
}
}
loadAndroid();
loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
});
});
loadCatalog();
loadCatalog();
@@ -3695,7 +3767,7 @@ function showPage() {
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + 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`;
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) & & id & & $(id);
const section = !PAGES.includes(id) & & id & & $(id);
if (section) section.scrollIntoView(); else window. scrollTo(0, 0);
if (section) section.scrollIntoView(); else scrollToY (0);
if (page === "tools") { loadMacView(); loadPanels(); }
if (page === "tools") { loadMacView(); loadPanels(); }
}
}
window.addEventListener("hashchange", showPage);
window.addEventListener("hashchange", showPage);
@@ -4206,28 +4278,36 @@ function netLabel(s) {
}
}
function viaText(v) { return v ? v.host + (v.ip & & v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
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 .
// How the headset is reached, in a word: Tailscale, the network's own name, or this network .
function routeLabel(s) {
if (s.via & & s.via.kind === "tailscale") return "Tailscale";
const named = s.network & & dv.data & & dv.data.networks.find(x => x.id === s.network.id & & x.name);
return named ? named.name : "this network";
}
// The pill in the header: always there, and says plainly what's happening. Addresses are in its
// dialog, where there's room for them; the tooltip has the detail.
function renderPill() {
function renderPill() {
const s = link.s;
const s = link.s;
if (!link.live || !s) return false;
if (!link.live || !s) return false;
const dev = s.device ? s.device.name : "Frame";
const dev = s.device ? s.device.name : "Frame";
let dot = "wait", line ;
let dot = "wait", word, via, detail ;
if (s.phase === "connected") {
if (s.phase === "connected") {
dot = "on";
dot = "on"; word = "Connected"; via = routeLabel(s);
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via & & s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
detail = `connected via ${viaText(s.via)}` + (s.via & & s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : "");
} else if (s.phase === "failed") {
} else if (s.phase === "failed") {
dot = "off";
dot = "off"; word = "Offline";
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
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) ;
via = left != null ? `retrying in ${left} s` : "" ;
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "") ;
detail = s.error ? s.error.message : "not connected" ;
} else {
} else {
word = "Connecting…";
const st = activeStage(s);
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving") ;
via = st ? st.label.toLowerCase() : "" ;
line = st ? st.label + (st.id === "find" & & probing ? `: ${probing.host}` : st.detail & & st.id !== "network" ? `: ${st.detail}` : "…")
detail = via || "connecting";
: "Connecting…";
}
}
$("conn").innerHTML = `< span class = "dot ${dot}" > < / span > < span class = "pt" > < b > ${esc(dev)}< / b > < span > ${esc(line)} < / span > < / span > `;
$("conn").innerHTML = `< span class = "dot ${dot}" > < / span > < span class = "pt" > < b > ${esc(dev)}< / b > < span > ${esc(word)}` +
$("conn").title = `${dev}: ${line}. Click for details. `;
`${via ? ` < span class = "via" > · ${esc(via)} < / span > ` : ""} < / span > < / span > `;
$("conn").title = `${dev}: ${detail}. Click to see or change its addresses.`;
return true;
return true;
}
}
window.connPill = renderPill;
window.connPill = renderPill;
@@ -4251,17 +4331,31 @@ function renderBanner() {
}
}
window.connBanner = renderBanner;
window.connBanner = renderBanner;
// The pill's dialog: how it's connected, and the headset's addresses, which can be added,
// edited and reordered right here. The steps it took stay folded away while all is well.
const cd = { editing: null, editDev: null, session: 0, sig: "", phase: null };
function renderConnDlg() {
function renderConnDlg() {
const s = link.s;
const s = link.s;
if (!s || !$("connDlg").open) return;
if (!s || !$("connDlg").open) return;
$("connTitle").textContent = s.device ? `${ s.device.name} (ssh ${s.device.alias})` : "Connection";
$("connTitle").textContent = s.device ? s.device.name : "Connection";
$("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via & & s.via.why ? " (" + s.via.why + ")" : ""}`
const connected = s.phase === "connected", failed = s.phase === "failed";
: s.phase === "failed " ? (s.error ? s.error.message : "Not connected ")
$("connDot").className = "dot " + (connected ? "on" : failed ? "off" : "wait ");
$("connState").textContent = connected ? `Connected via ${routeLabel(s)}` : failed ? "Offline" : "Connecting…";
$("connWhy").textContent = connected ? viaText(s.via) + (s.via & & s.via.rtt_ms != null ? ` · answered in ${s.via.rtt_ms} ms` : "")
: failed ? (s.error ? s.error.message : "Not connected")
: `${s.reason || "Connecting"}…`;
: `${s.reason || "Connecting"}…`;
// Folded while connected; opened when something's wrong. Left as the user set it otherwise.
if (s.phase !== cd.phase) { $("connMore").open = !connected; cd.phase = s.phase; }
// Connected, it's Reconnect: the addresses are tried again in their current order, so an
// address just added or moved up gets its chance now rather than at the next connection.
$("connRetryBtn").hidden = !failed & & !connected;
$("connRetryBtn").classList.toggle("action", failed);
$("connRetryBtn").textContent = failed ? "Retry now" : "Reconnect";
$("connRetryBtn").title = failed ? "" : "Try the addresses again, in order";
$("connSetup").hidden = connected;
const n = s.network || {}, ts = n.tailscale || {};
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" : "—") ],
const facts = [["Network", n.ssid || n.name || "—"],
["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"],
["This computer", n.local_ip || "—"], ["Router", n.gateway || "No default route"],
["This computer", n.local_ip || "—"],
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
["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("");
$("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 >
$("connStages").innerHTML = (s.stages || []).map(st => `< li class = "${st.state}" > < span class = "ic" > ${STAGE_ICON[st.state] || ""}< / span >
@@ -4270,22 +4364,137 @@ function renderConnDlg() {
${st.detail ? `< span class = "dt" > ${esc(st.detail)}< / span > ` : ""}< / li > `).join("") +
${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 >
(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 > ` : "");
< 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 >
renderConnAddrs();
< 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;
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)}` : "";
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished & & s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
}
}
const PRIVATE_V4 = /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)\d{1,3}\.\d{1,3}(\.\d{1,3})?$/;
const sameSubnet = (a, b) => !!a & & !!b & & a.split(".").slice(0, 3).join(".") === b.split(".").slice(0, 3).join(".");
// The Frame's own LAN address, when this computer is on the same network and it isn't saved yet:
// saving it lets Frame Control connect directly at home rather than over Tailscale.
function lanOffer(s, d) {
const ip = state & & state.ip, n = s.network || {};
if (!d || d.transient || !ip || !PRIVATE_V4.test(ip) || ip === n.local_ip || !sameSubnet(ip, n.local_ip)) return null;
if (d.addresses.some(a => a.host === ip) || (s.probes || []).some(p => p.ip === ip & & p.kind !== "tailscale")) return null;
const named = dv.data & & dv.data.networks.find(x => x.id === n.id & & x.name);
return { ip, label: named ? named.name : n.ssid || "" };
}
function activeDevice() { const s = link.s; return s & & s.device ? dv.list.find(x => x.id === s.device.id) : null; }
function renderConnAddrs() {
const s = link.s, d = activeDevice(), list = $("connAddrs");
const offer = s & & lanOffer(s, d);
$("connOffer").hidden = !offer;
if (offer) {
const key = JSON.stringify([d.id, offer.ip, offer.label]);
if ($("connOffer").dataset.key !== key) {
$("connOffer").dataset.key = key;
$("connOffer").innerHTML = `< div class = "grow" > The Frame is at < b > ${esc(offer.ip)}< / b > on this network. Add it, and
Frame Control tries it first here, reaching the headset directly.< / div >
< button class = "small action" id = "connOfferAdd" > Add ${esc(offer.ip)}< / button > `;
// First in the list: on this network it then wins over the address in use now.
$("connOfferAdd").onclick = e => devAction(`Add ${offer.ip}`, { action: "address-add", id: d.id, host: offer.ip,
kind: "lan", label: offer.label, first: true }, e.currentTarget)
.then(res => res & & testQuietly(d.id));
}
} else $("connOffer").dataset.key = "";
$("connAdd").hidden = !d || !!d.transient;
if (!d) { list.innerHTML = `< div class = "sub" > Loading…< / div > `; cd.sig = ""; return; }
if (d.transient) {
list.innerHTML = `< div class = "sub" > This headset isn't set up in Frame Control yet, so ssh's own settings decide where it goes.
Set Up Connection adds its addresses here.< / div > `;
cd.sig = ""; return;
}
const probes = new Map((s.probes || []).map(p => [p.host, p]));
const tested = new Map((((s.tests || {})[d.id] || {}).rows || []).map(r => [r.host, r]));
const inUse = s.phase === "connected" & & s.via ? s.via.host : null;
const sig = JSON.stringify([d.id, d.addresses, [...probes.values(), ...tested.values()].map(p => [p.host, p.state, p.ssh, p.detail]), cd.editing, inUse]);
// Leave the rows alone when nothing has changed, and while an address is being edited: what's
// typed stays put until it's saved or cancelled, wherever the focus is.
if (sig === cd.sig || cd.editing & & $("ceHost") & & cd.editDev === d.id & & d.addresses.some(a => a.host === cd.editing)) return;
cd.sig = sig;
// Rebuilding the rows would drop the keyboard focus: put it back on the same button of the
// same address (after an edit, on that row's Edit button).
const held = list.contains(document.activeElement) & & document.activeElement.closest("[data-host]");
const refocus = cd.refocus ? [cd.refocus, "[data-ed]"]
: held ? [held.dataset.host, ["[data-mv='-1']", "[data-mv='1']", "[data-ed]", "[data-rm]"].find(q => document.activeElement.matches(q))] : null;
cd.refocus = null;
list.innerHTML = d.addresses.map((a, i) => {
if (cd.editing === a.host & & cd.editDev === d.id) return `< div class = "item addr" > < div class = "addr-edit conn-edit" >
< input type = "text" id = "ceHost" value = "${esc(a.host)}" maxlength = "253" aria-label = "Address" >
< input type = "text" id = "ceLabel" value = "${esc(a.label)}" maxlength = "60" placeholder = "Label" aria-label = "Label" >
< button class = "small action" id = "ceSave" > Save< / button > < button class = "small" id = "ceCancel" > Cancel< / button > < / div > < / div > `;
const p = probes.get(a.host), t = tested.get(a.host);
const result = a.host === inUse ? `< span class = "res-ok" > In use${p & & p.detail ? " · " + esc(p.detail.toLowerCase()) : ""}< / span > `
: t ? `< span class = "res-${esc(t.ssh || t.state)}" > ${esc(t.detail)}${t.ssh === "checking" ? " · checking SSH…" : ""}< / span > `
: p ? `< span class = "res-${esc(p.state)}" > ${esc(p.detail)}< / span > `
: a.last_ok ? `Worked ${esc(ago(a.last_ok))}` : "Not tried on this network";
return `< 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" > ${result}< / div > < / div >
< button class = "small" data-mv = "-1" title = "Try earlier" aria-label = "Move ${esc(a.host)} up" $ { i ? " " : " disabled " } > ↑< / button >
< button class = "small" data-mv = "1" title = "Try later" aria-label = "Move ${esc(a.host)} down" $ { i < d . addresses . length - 1 ? " " : " disabled " } > ↓< / button >
< button class = "small" data-ed > Edit< / button > < button class = "small danger" data-rm > Remove< / button > < / div > `;
}).join("") || `< div class = "sub" > No addresses yet. Add the Frame's IP address below.< / div > `;
if (refocus) {
const row = list.querySelector(`[data-host="${CSS.escape(refocus[0])}"]`);
const again = row & & refocus[1] & & row.querySelector(refocus[1]);
if (row) (again & & !again.disabled ? again : row.querySelector("[data-ed]")).focus();
}
list.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 = () => {
cd.editing = host; cd.editDev = d.id; cd.session++; renderConnAddrs(); $("ceHost").focus();
};
row.querySelector("[data-rm]").onclick = e => {
if (host === inUse & & !confirm(`${host} is how Frame Control is reaching the headset now. Remove it?`)) return;
devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
};
});
if ($("ceSave")) {
const host = cd.editing, a = d.addresses.find(x => x.host === host), session = cd.session;
let saving = false;
const save = async e => {
e.preventDefault();
if (saving) return; // a second Enter while the first is on its way
const newHost = $("ceHost").value.trim(), label = $("ceLabel").value;
if (!newHost) return $("ceHost").focus();
saving = true;
const res = await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost, kind: a.kind, label }, $("ceSave"));
saving = false;
if (res) testQuietly(d.id);
// Refused: what was typed stays, to correct. Cancelled or another address opened for
// editing while this was on its way: leave that one alone.
if (!res || cd.session !== session) return;
cd.editing = null; cd.sig = ""; cd.refocus = newHost;
renderConnAddrs();
};
$("ceSave").onclick = save;
[$("ceHost"), $("ceLabel")].forEach(i => i.onkeydown = e => { if (e.key === "Enter") save(e); if (e.key === "Escape") { e.preventDefault(); $("ceCancel").click(); } });
$("ceCancel").onclick = () => { cd.editing = null; cd.session++; cd.sig = ""; cd.refocus = host; renderConnAddrs(); };
}
}
$("connAdd").onsubmit = async e => {
e.preventDefault();
const d = activeDevice(), host = $("caHost").value.trim();
if (!d) return;
if (!host) return $("caHost").focus();
const res = await devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("caLabel").value });
if (res) { $("caHost").value = ""; $("caLabel").value = ""; testQuietly(d.id); }
};
// Try the addresses (without reconnecting), so a new one shows whether it answers.
function testQuietly(id) { api("/api/devices", { action: "test", id }).catch(() => {}); }
function onConnection(s) {
function onConnection(s) {
const prev = link.s;
const prev = link.s;
link.s = s; link.live = !s.local; link.lastData = Date.now();
link.s = s; link.live = !s.local; link.lastData = Date.now();
link.offset = Date.now() / 1000 - s.now;
link.offset = Date.now() / 1000 - s.now;
if (!link.live) return;
if (!link.live) return;
renderPill(); renderConnDlg();
const devId = s.device & & s.device.id;
const devId = s.device & & s.device.id;
window.connDevice = devId || null;
window.connDevice = devId || null;
window.connName = s.device ? s.device.name : null;
if (link.device !== null & & devId !== link.device) {
if (link.device !== null & & devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
log(`Now using ${s.device.name}`, "ok");
state = null;
state = null;
@@ -4315,8 +4524,10 @@ function onConnection(s) {
if ($("wiDlg").open & & !wi.job & & !wi.starting) $("wiDlg").close();
if ($("wiDlg").open & & !wi.job & & !wi.starting) $("wiDlg").close();
$("dispSel").innerHTML = "< option > Loading…< / option > "; $("dispSel").disabled = true; $("dispCtl").hidden = true;
$("dispSel").innerHTML = "< option > Loading…< / option > "; $("dispSel").disabled = true; $("dispCtl").hidden = true;
link.reload = true; // everything on the page was the other headset's
link.reload = true; // everything on the page was the other headset's
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
}
}
// After the other headset's status is cleared: the dialog's offer reads it.
renderPill(); renderConnDlg();
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase === "connected") {
if (s.phase === "connected") {
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
@@ -4373,7 +4584,9 @@ setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { rend
$("conn").onclick = () => {
$("conn").onclick = () => {
if (!link.live) { if (online === false) refresh(); return; }
if (!link.live) { if (online === false) refresh(); return; }
cd.editing = null; cd.sig = ""; cd.phase = null;
$("connDlg").showModal(); renderConnDlg();
$("connDlg").showModal(); renderConnDlg();
if (!dv.data) loadDevices();
};
};
$("offDetails").onclick = () => $("conn").click();
$("offDetails").onclick = () => $("conn").click();
$("connClose").onclick = () => $("connDlg").close();
$("connClose").onclick = () => $("connDlg").close();
@@ -4432,7 +4645,7 @@ async function devAction(label, body, btn) {
function deviceStatus(d) {
function deviceStatus(d) {
const s = link.s;
const s = link.s;
if (d.active & & s) {
if (d.active & & s) {
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via )}`]
return s.phase === "connected" ? ["on", `Connected via ${routeLabel(s )}`]
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
}
}
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
@@ -4446,7 +4659,7 @@ function renderDevices() {
const [dot, text] = deviceStatus(d);
const [dot, text] = deviceStatus(d);
return `< div class = "item dev-item${d.id === dv.sel ? " sel " : " " } " data-dev = "${esc(d.id)}" >
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 >
< 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 >
< div class = "s" > ${esc(text)}< / div > < / div >
${d.active ? "" : `< button class = "small" data-use = "${esc(d.id)}" > Use this headset< / button > `}< / 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 > `;
}).join("") || `< div class = "sub" > No headsets yet. Add one to get started.< / div > `;
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
@@ -4461,6 +4674,7 @@ function renderDevices() {
$("netName").disabled = $("netSave").disabled = !(n & & n.id);
$("netName").disabled = $("netSave").disabled = !(n & & n.id);
}
}
renderDetail();
renderDetail();
renderConnDlg(); // the pill's dialog lists the same addresses
}
}
$("netSave").onclick = e => {
$("netSave").onclick = e => {
const n = dv.data & & dv.data.network;
const n = dv.data & & dv.data.network;
@@ -4498,19 +4712,13 @@ function renderDetail() {
< button class = "small" data-ed > Edit< / button > < button class = "small danger" data-rm > Remove< / button > < / div > `).join("");
< 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 >
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 >
${d.active ? "" : `< button class = "small action" id = "dvUse" > Use this headset< / button > `}< / div >
< form class = "dev-form " id = "dvForm" >
< form class = "dev-name " id = "dvName Form" >
< label > Name< input type = "text" id = "dvName" value = "${esc(d.name)}" maxlength = "60" > < / label >
< 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 >
< button class = "small " type = "submit" > Rename < / button > < / form >
< label > User < input type = "text" id = "dvUser" value = "${esc(d.user)}" maxlength = "64" > < / label >
< h3 > Addresses < span class = "h3-note" > tried all at once; the first in the list wins a tie < / span > < / h3 >
< 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 >
< 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" >
< form class = "addr-add" id = "dvAdd" >
< input type = "text" id = "adHost" maxlength = "253" placeholder = "IP address or host name" aria-label = "New address" >
< 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" >
< input type = "text" id = "adLabel" maxlength = "60" placeholder = "Label, e.g. Office" aria-label = "Label" >
< button class = "small" type = "submit" > Add< / button > < / form >
< button class = "small" type = "submit" > Add< / button > < / form >
< div class = "row" style = "margin-top:14px" >
< div class = "row" style = "margin-top:14px" >
@@ -4518,22 +4726,33 @@ function renderDetail() {
< button class = "small" id = "dvTs" > Find on Tailscale< / button >
< button class = "small" id = "dvTs" > Find on Tailscale< / button >
< button class = "small" id = "dvMdns" > Find on this network< / button >
< button class = "small" id = "dvMdns" > Find on this network< / button >
< span class = "spacer" > < / span >
< 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 headset… < / button > < / div >
< button class = "small danger" id = "dvRemove" > Remove…< / button > < / div >
< div class = "found" id = "dvFound" > < / 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."
< details class = "adv" $ { dv . adv ? " open " : " " } > < summary > Advanced < / summary >
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
< form class = "dev-form" id = "dvForm" >
< code > .local < / code > names are mDNS, < code > 100.x < / code > and < code > .ts.net < / code > are Tailscale. < / div > `;
< label > SSH alias < input type = "text" value = "${esc(d.alias)}" readonly title = "Terminal: ssh ${esc(d.alias)}" > < / label >
$("adKind").insertAdjacentHTML("afterbegin", ` < option value = "" selected > Kind: auto < / option > `);
< label > User < input type = "text" id = "dvUser" value = "${esc(d.user)}" maxlength = "64" > < / label >
$("adKind").value = "";
< 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" > In Terminal it's < code > ssh ${esc(d.alias)}< / code > . User and port are written to its block in < code > ~/.ssh/config< / code > too.< / span > < / div >
< div class = "row" style = "margin-top:14px" > < button class = "small" id = "dvForget" title = "After reinstalling SteamOS the headset shows a new identity" > Forget identity< / button >
< span class = "sub" > Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
: "not saved yet; the next connection saves it."}< / span > < / div >
< div class = "hint" > An address's kind is worked out from it: < code > .local< / code > names are mDNS, < code > 100.x< / code > and
< code > .ts.net< / code > are Tailscale, and other IPs are this network. Edit an address to set it yourself.< / div >
< / details > `;
el.querySelector(".adv").ontoggle = e => { dv.adv = e.target.open; };
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
$("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
const saveDev = btn => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget );
user: $("dvUser").value, port: $("dvPort").value }, btn );
$("dvSave").onclick = e => saveDev(e.currentTarget);
$("dvNameForm").onsubmit = e => { e.preventDefault(); saveDev(e.submitter); };
$("dvAdd").onsubmit = e => {
$("dvAdd").onsubmit = e => {
e.preventDefault();
e.preventDefault();
const host = $("adHost").value.trim();
const host = $("adHost").value.trim();
if (!host) return $("adHost").focus();
if (!host) return $("adHost").focus();
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value , label: $("adLabel").value });
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "" , label: $("adLabel").value });
};
};
el.querySelectorAll("[data-host]").forEach(row => {
el.querySelectorAll("[data-host]").forEach(row => {
const host = row.dataset.host;
const host = row.dataset.host;