@@ -27,8 +27,10 @@
}
* { 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));
/* On a computer the header and the Activity bar are the window's chrome: the page scrolls in #scroll
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;
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; }
@@ -38,13 +40,14 @@
::-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; 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);
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 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;
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
border-bottom: 3px solid transparent; transition: color .15s; }
@@ -58,10 +61,17 @@
.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-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; }
@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; } }
/* The header gives way as the window narrows: the wordmark goes, then the tabs tighten, then the
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: 1250px) { .brand b { display: none; } }
@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 span { display: none; } }
nav svg { display: none; }
html { -webkit-text-size-adjust: 100%; }
body { -webkit-tap-highlight-color: transparent; }
@@ -87,7 +97,7 @@
.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: 4 20px; min-width: 190px ; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 3 20px; min-width: 0 ; 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; } }
@@ -149,8 +159,10 @@
/* ---- 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) ; }
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
.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 .spacer { flex: 1; }
.sub { color: var(--muted); font-size: 12.5px; }
@@ -179,6 +191,11 @@
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); }
/* 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; }
/* ---- headset view ---- */
@@ -223,15 +240,21 @@
@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 : 6 px; }
.battery .state.charging { co lor : 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; }
/* ---- the headset's details: a menu under the battery chip ---- */
.pop { position: fixed; inset: auto; margin: 0; width: min(390px, calc(100vw - 24px)); max-height: calc(100vh - 90px); overflow: auto;
padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 6px; background: #1e2329; color: var(--text);
box-shadow: 0 20px 60px rgba(0,0,0,.6); -webkit-app-region: no-drag ; }
.pop .shelf-head { margin-bot tom : 10 px; }
.pop .shelf-head h2 { min-width: 0; overf low : hidden; text-overflow: ellipsis ; }
.pop .stats { margin-top: 12px ; }
.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 > .stat:last-child:nth-child(odd) { grid-column: 1 / -1; }
.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;
@@ -258,12 +281,7 @@
.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; }
.shot-card .s { color: var(--muted); font-size: 12px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
@@ -355,7 +373,7 @@
#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 select:focus { 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; }
@@ -402,7 +420,6 @@
.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; }
@@ -411,7 +428,7 @@
.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 select:focus { 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; }
@@ -438,11 +455,14 @@
/* ---- 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)); }
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. */
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; }
.brand { display: flex; } #refreshAll { display: inline-flex; } .pill .pt span { display: block; } /* the tabs are at the bottom here */
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);
@@ -628,7 +648,7 @@
.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; } }
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
< / style >
< / head >
@@ -653,7 +673,7 @@
< 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 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" >
< 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 >
@@ -661,7 +681,21 @@
< 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 >
< 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 >
< div class = "notice" id = "updateBar" hidden >
< div class = "grow" id = "updateText" > < / div >
@@ -706,7 +740,6 @@
< 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 >
@@ -753,29 +786,7 @@
< / 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" > < / 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 = "shelf-head" > < h2 > Volume < / h2 > < / 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 >
@@ -789,7 +800,7 @@
< 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. 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" >
< button class = "small" id = "hudStart" > Open HUD in headset< / button >
< button class = "small" id = "hudStop" > Close HUD< / button >
@@ -873,7 +884,7 @@
< 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 >
< div class = "hint" > Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to < code > ~/Pictures/SteamFrame< / code > .< / div >
< / section >
< / div >
@@ -1093,22 +1104,23 @@
< 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 >
< button class = "small" id = "panelsRefresh" > Refresh< / button > < / 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.
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 = "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 = "list" id = "mvDisplays" > < / div >
< div class = "shelf-head" style = "margin-top:16px" > < h2 > Windows< / h2 > < span class = "count" id = "mvWinCount" > < / span > < / div >
@@ -1160,6 +1172,7 @@
< / div >
< / div >
< / main >
< / div >
< 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 >
@@ -1350,6 +1363,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 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`; }
// 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) {
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
@@ -1470,7 +1486,7 @@ function setOnline(ok, why) {
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true;
$("battChip").hidden = true; closeDevPop();
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
if (window.connBanner) window.connBanner();
} else if (was === false) {
@@ -1518,7 +1534,7 @@ function renderPerformance(s) {
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, "%"] ];
["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("");
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
}
@@ -1650,10 +1666,6 @@ function battery(b, power) {
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"; }
@@ -1669,7 +1681,7 @@ function battery(b, power) {
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`);
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
$("battState").textContent = label;
$("battState").className = "state " + cls;
$("battDetail").textContent = detail.join(" · ") || " ";
@@ -1678,13 +1690,25 @@ function battery(b, power) {
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 ;
$("battChip").title = `${ label}. Click for the headset's details.` ;
$("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) {
renderPerformance(s);
battery(s.battery, s.power);
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
const h = s.disk.home;
@@ -1875,7 +1899,7 @@ async function capture() {
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
ctrlShow();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = $("copyBtn").disabled = false;
$("saveBtn").disabled = false;
return true;
} catch (e) {
log("Capture failed: " + e.message, "e");
@@ -1957,7 +1981,7 @@ async function startVideo() {
$("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;
$("stamp").hidden = false; $("saveBtn").disabled = false;
}
frames++;
const now = performance.now();
@@ -2026,35 +2050,6 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
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");
@@ -2730,7 +2725,7 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
}, 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 (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
if (res & & res.app) await offerTest(res.app);
};
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
@@ -2964,7 +2959,7 @@ function renderChips() {
}
function filterGames(keepScroll) {
const y = window .scrollY ;
const y = scroller() .scrollTop ;
if (gm.src === "owned") {
if (!gm.owned) return;
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
@@ -2980,7 +2975,7 @@ function filterGames(keepScroll) {
$("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);
if (keepScroll) scrollToY (y);
}
function price(p) {
@@ -3335,7 +3330,7 @@ document.body.addEventListener("click", async e => {
if (action === "probe") { loadCatalog(); loadReports(); }
}
loadAndroid();
if (cat.apps) { const y = window .scrollY ; filterCatalog(); window. scrollTo(0, y); }
if (cat.apps) { const y = scroller() .scrollTop ; filterCatalog(); scrollToY (y); }
});
loadCatalog();
@@ -3532,11 +3527,8 @@ 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 shots = { list: [], urls: [] };
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);
@@ -3548,85 +3540,35 @@ async function shotBlob(id, thumb) {
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;
async function loadShots( ) {
$("shotsRefresh").disabled = true;
try {
go t = await api("/api/shots");
shots.lis t = ( await api("/api/shots")).shots ;
} catch (e) {
if (!quiet & & gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
return;
return failed($("shotGrid"), e);
} 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, () => {}); }
}
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [] ;
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) " >
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `< div class = "shot-card" >
< img class = "thumb" data-shot = "${i}" alt = "Screenshot from ${esc(shotApp(s.appid))}" title = "Open in the viewer" >
< 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 >
< div class = "s" > ${esc(new Date(s.time * 1000).toLocaleString())}< / div > < / div >
${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 > `;
// Thumbnails one at a time over the shared SSH connection.
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
const s = shots.list[+img.dataset.shot];
try {
const url = URL.createObjectURL(await shotBlob(s.id, true));
shots.urls.push(url);
img.src = url;
} catch (e) { img.alt = "Preview failed"; }
if (!img.isConnected) return; // the list was reloaded meanwhile
}
}
async function openShot(s) {
if (live) toggleLive(false);
@@ -3644,7 +3586,7 @@ async function openShot(s) {
draw();
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
$("saveBtn").disabled = $("copyBtn").disabled = false;
$("saveBtn").disabled = false;
$("view").scrollIntoView({ behavior: "smooth" });
} catch (e) {
toast("Couldn't open the screenshot: " + e.message, true);
@@ -3663,95 +3605,15 @@ async function saveShots(list, btn) {
() => 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();
$("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;
@@ -3863,7 +3725,7 @@ function showPage() {
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 (section) section.scrollIntoView(); else scrollToY (0);
if (page === "tools") { loadMacView(); loadPanels(); }
}
window.addEventListener("hashchange", showPage);
@@ -4345,7 +4207,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(() => loadShots() ); // after status, so app names resolve
refresh().then(loadShots); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden & & online === false) refresh(); });
< / script >
< script >
@@ -4454,6 +4316,7 @@ function onConnection(s) {
renderPill(); renderConnDlg();
const devId = s.device & & s.device.id;
window.connDevice = devId || null;
window.connName = s.device ? s.device.name : null;
if (link.device !== null & & devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
@@ -4466,7 +4329,7 @@ function onConnection(s) {
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;
$("saveBtn").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 => {
@@ -4475,7 +4338,7 @@ function onConnection(s) {
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() ;
androidApps = []; shots.list = [] ;
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.
@@ -4483,7 +4346,7 @@ function onConnection(s) {
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;
$("battChip").hidden = true; closeDevPop();
}
if (s.phase !== link.phase || devId !== link.device) {
if (s.phase === "connected") {