mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 23:01:36 +02:00
* Add read-only Linux PC Wireless Adapter doctor * Cross-reference coordinated SteamOS compatibility notes --------- Co-authored-by: SAPHID <saphid@users.noreply.github.com>
7490 lines
491 KiB
HTML
7490 lines
491 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Frame Control</title>
|
||
<style>
|
||
/* Motiva Sans is Steam's UI font; Valve's CDN serves it with CORS open. */
|
||
@font-face { font-family: "Motiva Sans"; font-weight: 300; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Light.ttf?v=4.015"); }
|
||
@font-face { font-family: "Motiva Sans"; font-weight: 400; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Regular.ttf?v=4.015"); }
|
||
@font-face { font-family: "Motiva Sans"; font-weight: 500; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Medium.ttf?v=4.015"); }
|
||
@font-face { font-family: "Motiva Sans"; font-weight: 700; src: url("https://store.fastly.steamstatic.com/public/shared/fonts/MotivaSans-Bold.ttf?v=4.015"); }
|
||
|
||
:root {
|
||
/* Steam client palette */
|
||
--bg: #1b2838; --bg-deep: #0e141b;
|
||
--text: #dcdedf; --bright: #ffffff; --muted: #8f98a0; --dim: #5e6873;
|
||
--blue: #1a9fff; --link: #66c0f4;
|
||
--green: #59bf40; --green-hi: #a4d007;
|
||
--warn: #d9a23a; --bad: #d94126;
|
||
--btn: rgba(103, 112, 123, .2); --btn-hi: rgba(103, 112, 123, .45);
|
||
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
|
||
--play-hi: linear-gradient(to right, #8ed629 5%, #6aa621 95%);
|
||
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
|
||
--action-hi: linear-gradient(to right, #5fcaff 5%, #2458d6 95%);
|
||
color-scheme: dark;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
[hidden] { display: none !important; } /* class display rules would otherwise win */
|
||
/* 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; }
|
||
::selection { background: var(--blue); color: #fff; }
|
||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||
::-webkit-scrollbar-thumb { background: #3d4450; border-radius: 5px; border: 2px solid transparent; background-clip: padding-box; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
|
||
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
||
#scroll { flex: 1; min-height: 0; overflow: auto; scroll-behavior: smooth; scroll-padding-top: 12px; }
|
||
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; flex: none; }
|
||
.brand svg { width: 30px; height: 30px; }
|
||
.brand b { font-weight: 700; font-size: 16px; letter-spacing: -.1px; }
|
||
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;
|
||
border-bottom: 3px solid transparent; transition: color .15s; }
|
||
nav a:hover { color: var(--bright); }
|
||
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
|
||
.spacer { flex: 1; }
|
||
/* The connection pill and the buttons after it, pushed to the right: closer together than the tabs, and the
|
||
pill (not the buttons) gives way when the window is narrow, so nothing is pushed past the right edge. */
|
||
.hdr-end { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; margin-left: auto; }
|
||
.hdr-end > :not(.pill, .devsel) { flex: none; }
|
||
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
|
||
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
|
||
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
||
.dot.off { background: var(--bad); }
|
||
|
||
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
||
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; align-items: start; }
|
||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
||
@media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
|
||
/* 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. */
|
||
.hdr-end > button:not(.pill), .hdr-end > .chip { flex: none; }
|
||
.hdr-end > button:not(.pill):not(.chip) { width: 36px; padding: 0; justify-content: center; }
|
||
@media (min-width: 1367px) and (max-width: 1500px) { header { gap: 22px; } }
|
||
@media (max-width: 1360px) { .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: 1030px) { 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; } header .pill .pt > span { font-size: 13px; color: var(--bright); } header .pill { min-width: 118px; } }
|
||
@media (max-width: 820px) { nav a { padding: 0 6px; } }
|
||
nav svg { display: none; }
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
body { -webkit-tap-highlight-color: transparent; }
|
||
.mobile-only { display: none; }
|
||
body.mobile .mobile-only { display: revert; }
|
||
body.mobile .desk-only { display: none; }
|
||
|
||
|
||
/* ---- pages: one per tab; the header nav switches between them ---- */
|
||
.page { display: flex; flex-direction: column; gap: 26px; }
|
||
.page:not(.on) { display: none; }
|
||
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
|
||
@media (max-width: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
|
||
|
||
/* ---- offline banner ---- */
|
||
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
|
||
background: rgba(217,65,38,.12); box-shadow: inset 3px 0 0 var(--bad); }
|
||
.banner svg { width: 22px; height: 22px; flex: none; color: #ff8a73; }
|
||
.banner .grow { flex: 1; min-width: 0; }
|
||
.banner .t { color: var(--bright); font-weight: 500; }
|
||
.banner .s { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||
|
||
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
|
||
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 320px; min-width: 106px; flex: 0 1 auto; background: var(--btn); }
|
||
.pill .dot { flex: none; }
|
||
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
|
||
@keyframes pulse { 50% { opacity: .35; } }
|
||
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
|
||
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
/* "Connected" is never cut short: when 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; min-width: 90px; }
|
||
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||
.dlg::backdrop { background: rgba(0,0,0,.55); }
|
||
.dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||
.dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
|
||
.dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
.dlg label input, .dev-form label input { margin-top: 5px; }
|
||
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; }
|
||
.facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
|
||
.stages { list-style: none; margin: 0; padding: 0; }
|
||
.stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); }
|
||
.stages li:first-child { border-top: 0; }
|
||
.stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; }
|
||
.stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); }
|
||
.stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; }
|
||
.stages .pending .ic { border: 1.5px solid var(--dim); }
|
||
.stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; }
|
||
.stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); }
|
||
.stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; }
|
||
.stages .failed .dt { color: #ff8a73; }
|
||
.stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||
.probes { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||
.probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; }
|
||
.probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; }
|
||
.res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; }
|
||
.res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); }
|
||
.res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); }
|
||
.dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; }
|
||
@media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } }
|
||
.dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; }
|
||
.dev-item:hover { background: rgba(255,255,255,.04); }
|
||
.dev-details { display:flex; align-items:center; gap:12px; text-align:left; background:transparent; border:0; height:auto; min-height:44px; padding:6px; }
|
||
.dev-details .t, .dev-details .s { display:block; }
|
||
.dev-details:focus-visible { outline:2px solid var(--blue); outline-offset:2px; }
|
||
.dev-item.sel { background: rgba(26,159,255,.12); }
|
||
.dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; }
|
||
.dev-form input[readonly] { color: var(--muted); }
|
||
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||
padding: 8px 8px; font: inherit; font-size: 13px; }
|
||
.addr .t { overflow-wrap: anywhere; }
|
||
.addr .s { white-space: normal; }
|
||
.item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }
|
||
.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 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||
@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; }
|
||
|
||
/* ---- drop anywhere ---- */
|
||
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
||
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
||
background: rgba(26,159,255,.08); color: var(--text); max-width: 520px; }
|
||
.dropzone b { display: block; color: var(--bright); font-size: 20px; font-weight: 500; margin-bottom: 6px; }
|
||
|
||
/* ---- panels and section headers ---- */
|
||
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
|
||
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
|
||
/* Buttons in a heading row wrap onto a line of their own rather than squeeze the heading. */
|
||
.shelf-head { display: flex; align-items: baseline; flex-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: 17px; font-weight: 600; color: var(--bright); 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; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||
.hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; }
|
||
|
||
/* ---- buttons ---- */
|
||
button { appearance: none; border: 0; border-radius: 3px; padding: 0 14px; height: 34px; font: inherit; font-size: 14px;
|
||
color: var(--text); background: var(--btn); cursor: pointer; white-space: nowrap;
|
||
transition: background .12s, color .12s, filter .12s; display: inline-flex; align-items: center; gap: 8px; }
|
||
button:hover { background: var(--btn-hi); color: var(--bright); }
|
||
button:active { filter: brightness(.9); }
|
||
button:disabled { opacity: .45; cursor: default; }
|
||
button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||
button.action { background: var(--action); color: #fff; font-weight: 500; }
|
||
button.action:hover { background: var(--action-hi); }
|
||
button.play { background: var(--play); color: #fff; font-weight: 700; letter-spacing: .5px; }
|
||
button.play:hover { background: var(--play-hi); }
|
||
button.danger:hover { background: rgba(217,65,38,.35); color: #fff; }
|
||
button.small { height: 28px; padding: 0 11px; font-size: 13px; }
|
||
button.on { background: rgba(26,159,255,.22); color: var(--link); }
|
||
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
||
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
||
input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||
textarea { resize: vertical; min-height: 76px; }
|
||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||
/* 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 ---- */
|
||
.viewer { position: relative; aspect-ratio: 16 / 9; max-height: calc(100vh - 190px); margin: 0 auto; background: #000; border-radius: 3px; overflow: hidden;
|
||
display: grid; place-items: center; box-shadow: inset 0 0 0 1px rgba(255,255,255,.05); }
|
||
/* Fit the whole frame; zoom and pan are a transform on top of that. */
|
||
.viewer canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block;
|
||
transform-origin: 0 0; cursor: zoom-in; }
|
||
.viewer.zoomed canvas { cursor: grab; }
|
||
.viewer.panning canvas { cursor: grabbing; }
|
||
.viewer:fullscreen { aspect-ratio: auto; border-radius: 0; }
|
||
.zoombar { position: absolute; left: 12px; bottom: 12px; display: flex; gap: 2px; padding: 2px; border-radius: 3px;
|
||
background: rgba(0,0,0,.6); }
|
||
.zoombar button { height: 26px; min-width: 28px; padding: 0 8px; font-size: 13px; background: transparent; justify-content: center; }
|
||
.zoombar button:hover { background: rgba(255,255,255,.12); }
|
||
.zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; }
|
||
.viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; }
|
||
/* Nothing captured yet: a short strip rather than a big black box. */
|
||
.viewer:has(> #viewerEmpty:not([hidden])) { aspect-ratio: auto !important; height: 190px;
|
||
background: radial-gradient(500px 160px at 50% 0%, rgba(42,71,94,.55), transparent 70%), #0b1016; }
|
||
.viewer:has(> #viewerEmpty:not([hidden])) .empty svg { width: 44px; height: 44px; margin-bottom: 4px; }
|
||
.viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; }
|
||
.viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase;
|
||
background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; }
|
||
.viewer .badge.live { left: auto; right: 12px; background: var(--bad); color: #fff; font-weight: 700; }
|
||
.view-actions { display: flex; gap: 10px; }
|
||
.ctrl-status { margin-top: 10px; font-size: 13px; color: var(--blue); }
|
||
.ctrl-status::before { content: "● "; }
|
||
.ctrl-status.warn { color: var(--warn); }
|
||
/* Control: the view takes taps, clicks, drags and keys, and none of them zoom or pan it. */
|
||
.viewer.control { outline: 1px solid var(--blue); }
|
||
.viewer.control canvas { cursor: crosshair; touch-action: none; }
|
||
.viewer.control.rel canvas { cursor: move; }
|
||
.viewer.immersive { position: fixed; z-index: 40; inset: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
|
||
max-height: none; aspect-ratio: auto; border-radius: 0; }
|
||
.ctrl-keys { margin-top: 10px; }
|
||
#panelPick { max-width: 240px; height: 30px; }
|
||
.viewer .stamp { position: absolute; right: 12px; bottom: 12px; font-size: 11px; color: #c7d5e0;
|
||
background: rgba(0,0,0,.6); padding: 3px 8px; border-radius: 2px; }
|
||
.viewer .asleep { position: absolute; inset: auto 0 0 0; padding: 14px 18px; text-align: center; color: var(--text);
|
||
background: linear-gradient(to top, rgba(0,0,0,.85), transparent); font-size: 13px; }
|
||
.viewer .loading { position: absolute; inset: 0; display: none; place-items: center; background: rgba(0,0,0,.35); }
|
||
.viewer.busy .loading { display: grid; }
|
||
.spinner { width: 34px; height: 34px; border: 3px solid rgba(255,255,255,.15); border-top-color: var(--blue);
|
||
border-radius: 50%; animation: spin .8s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||
|
||
/* ---- Home: what the headset's doing, and what you'd do next ---- */
|
||
.now-state { margin: 0 0 4px; color: var(--bright); font-size: 15px; }
|
||
.now-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 16px; }
|
||
.now-actions button { justify-content: center; height: 38px; white-space: normal; line-height: 1.2; text-align: center; }
|
||
.now-actions button:first-child { grid-column: 1 / -1; }
|
||
@media (max-width: 1150px) { .now { order: -1; } } /* stacked: what's happening comes first */
|
||
#padIntro { margin: 0; }
|
||
.group-head { margin: 4px 0 -8px; }
|
||
a.button-link { display: inline-flex; align-items: center; height: 34px; padding: 0 14px; border-radius: 3px; background: var(--btn);
|
||
color: var(--text); text-decoration: none; }
|
||
a.button-link:hover { background: var(--btn-hi); color: var(--bright); }
|
||
/* A panel that folds: its heading row is the summary. */
|
||
.fold > summary { list-style: none; cursor: pointer; margin: 0; color: inherit; font-size: inherit; }
|
||
.fold > summary::-webkit-details-marker { display: none; }
|
||
.fold > summary h2::before { content: "▸"; display: inline-block; width: 1.1em; color: var(--muted); }
|
||
.fold[open] > summary h2::before { content: "▾"; }
|
||
.fold:not([open]) > summary { margin-bottom: 0; }
|
||
.fold > summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 4px; }
|
||
|
||
/* ---- 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-bottom: 10px; }
|
||
.pop .shelf-head h2 { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||
.pop .stats { margin-top: 12px; }
|
||
.pop .chips { margin-top: 12px; }
|
||
.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;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.stat .d { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.meter { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; margin-top: 7px; overflow: hidden; }
|
||
.meter > i { display: block; width: 0; height: 100%; background: var(--link); transition: width .4s; }
|
||
.meter > i.warn { background: var(--warn); } .meter > i.bad { background: var(--bad); }
|
||
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 14px; }
|
||
.chips .chip { height: 26px; font-size: 12px; background: rgba(0,0,0,.22); }
|
||
.vol { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||
.vol input[type=range] { flex: 1; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px;
|
||
background: linear-gradient(to right, var(--blue) var(--fill, 0%), rgba(255,255,255,.12) var(--fill, 0%)); }
|
||
.vol input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
|
||
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
|
||
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
|
||
/* ---- Steam screenshots from the headset ---- */
|
||
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
||
.shot-card { display: flex; flex-direction: column; gap: 6px; }
|
||
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
|
||
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
|
||
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
|
||
.shot-card .row { flex-wrap: nowrap; }
|
||
.shot-card .grow { flex: 1; min-width: 0; }
|
||
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38;
|
||
box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); }
|
||
.ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; }
|
||
.ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; }
|
||
.ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; }
|
||
|
||
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
|
||
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||
.capsule { position: relative; aspect-ratio: 2 / 3; border-radius: 3px; overflow: hidden; background: #2a2f38 center/cover no-repeat;
|
||
box-shadow: 0 6px 16px rgba(0,0,0,.45); transition: transform .18s ease, box-shadow .18s ease; }
|
||
.capsule:hover, .capsule:focus-within { transform: translateY(-4px) scale(1.02); box-shadow: 0 14px 30px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.12); }
|
||
.capsule .fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: 12px; text-align: center;
|
||
font-weight: 500; color: var(--text); background: linear-gradient(160deg, #3d4450, #1e2329); }
|
||
.capsule .over { position: absolute; inset: auto 0 0 0; padding: 40px 10px 10px; display: flex; flex-direction: column; gap: 8px;
|
||
background: linear-gradient(to top, rgba(0,0,0,.92) 20%, transparent); opacity: 0; transition: opacity .18s; }
|
||
.capsule:hover .over, .capsule:focus-within .over { opacity: 1; }
|
||
.capsule .name { font-weight: 700; font-size: 13px; color: #fff; line-height: 1.25; }
|
||
.capsule .size { color: var(--muted); font-size: 11.5px; }
|
||
.capsule button { justify-content: center; }
|
||
|
||
/* ---- transfer / apps / power ---- */
|
||
.drop { border: 2px dashed rgba(143,152,160,.35); border-radius: 3px; padding: 24px 16px; text-align: center;
|
||
color: var(--muted); cursor: pointer; transition: border-color .15s, background .15s; }
|
||
.drop:hover { border-color: rgba(143,152,160,.6); }
|
||
.drop.over { border-color: var(--blue); background: rgba(26,159,255,.08); color: var(--text); }
|
||
.drop b { display: block; color: var(--bright); font-size: 15px; font-weight: 500; margin-bottom: 4px; }
|
||
.progress { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; margin-top: 12px; overflow: hidden; display: none; }
|
||
.progress > i { display: block; height: 100%; width: 0; background: var(--action); transition: width .2s; }
|
||
.list .item { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid rgba(255,255,255,.06); }
|
||
.list .item:first-child { border-top: 0; padding-top: 0; }
|
||
.item .grow { flex: 1; min-width: 0; }
|
||
.item .t { color: var(--bright); font-size: 14px; }
|
||
.item .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.tag { font-size: 10.5px; letter-spacing: 1px; text-transform: uppercase; padding: 3px 7px; border-radius: 2px;
|
||
background: rgba(0,0,0,.3); color: var(--muted); }
|
||
.actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||
.actions button { justify-content: flex-start; height: 40px; }
|
||
.actions svg { width: 16px; height: 16px; flex: none; opacity: .85; }
|
||
.notice { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-radius: 4px;
|
||
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
|
||
.notice .grow { flex: 1; min-width: 260px; }
|
||
.notice .progress { width: 160px; margin-top: 0; display: block; }
|
||
.contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; }
|
||
.contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; }
|
||
.contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); }
|
||
#contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; }
|
||
#cEmail { max-width: 420px; }
|
||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||
.popt b { font-weight: 600; color: var(--text); }
|
||
.sync-note { margin: 10px 0 0; }
|
||
.popt .sub { grid-column: 2; line-height: 1.45; }
|
||
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
|
||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
|
||
details summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 12px; }
|
||
.links { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 13px; color: var(--muted); }
|
||
.links a { color: var(--link); text-decoration: none; } .links a:hover { color: #fff; }
|
||
.links div { margin: 5px 0; }
|
||
|
||
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
||
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
|
||
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
||
.bottombar .label { font-size: 13px; font-weight: 600; color: var(--muted); }
|
||
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
||
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
||
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
||
.bottombar .last.e { color: #ff8a73; }
|
||
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
|
||
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
||
.drawer.open { max-height: 300px; }
|
||
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
||
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
||
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
||
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
||
.toast { position: fixed; 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);
|
||
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
||
.toast.show { opacity: 1; transform: none; }
|
||
.toast.err { border-left-color: var(--bad); }
|
||
/* ---- Android ---- */
|
||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||
.rep-item .s { white-space: normal; }
|
||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #androidImportDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
|
||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||
#bugForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||
#bugForm .popt { margin: 14px 0 0; }
|
||
#bugForm .sentlog { max-height: 200px; }
|
||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||
.about-text { max-height: 55vh; overflow: auto; }
|
||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||
#titleForm select:focus { border-color: var(--blue); }
|
||
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
#titleForm .note.warn { color: #d9a23a; }
|
||
#titleList { margin-top: 8px; }
|
||
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
|
||
#wiFacts dt { color: var(--muted); }
|
||
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
|
||
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
|
||
#wiProg:not([hidden]) { display: block; }
|
||
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
|
||
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
|
||
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
|
||
#repForm input:invalid:not(:placeholder-shown) { border-color: var(--bad); }
|
||
.rep-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
|
||
.rep-file { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
.rep-actions { margin-top: 16px; }
|
||
.and-item { flex-wrap: wrap; }
|
||
.and-btns { flex-basis: 100%; padding-left: 46px; }
|
||
.and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; }
|
||
.cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||
.cat-tools input[type=text], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; }
|
||
.cat-tools select, .source-offer select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
|
||
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
|
||
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
|
||
.vchip[aria-pressed=true] { opacity: 1; box-shadow: inset 0 0 0 1px currentColor; }
|
||
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||
.cat-card { background: rgba(0,0,0,.2); border-radius: 3px; padding: 10px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||
.cat-card .top { display: flex; gap: 10px; align-items: flex-start; }
|
||
.cat-card .t { color: var(--bright); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||
.cat-card .s { color: var(--muted); font-size: 12px; }
|
||
.cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; }
|
||
.cat-card .row { margin-top: auto; }
|
||
.v-works, .v-likely, .item .s.v-works, .item .s.v-likely { color: var(--green-hi); }
|
||
.v-maybe, .item .s.v-maybe { color: var(--warn); }
|
||
.v-unlikely, .v-no, .item .s.v-unlikely, .item .s.v-no { color: var(--bad); } /* over .item .s's grey */
|
||
.vbadge { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; margin-left: auto; }
|
||
@media (max-width: 1100px) { .and-grid { grid-template-columns: 1fr; } }
|
||
/* ---- Steam games (owned + store) ---- */
|
||
.game-card .art { width: 100%; aspect-ratio: 231 / 87; border-radius: 2px; object-fit: cover; background: rgba(0,0,0,.25); display: block; }
|
||
.game-card .price { color: var(--bright); font-size: 13px; }
|
||
.game-card .price s { color: var(--dim); margin-right: 6px; }
|
||
.cat-card .row a { color: var(--link); text-decoration: none; font-size: 13px; padding: 0 4px; }
|
||
.cat-card .row a:hover { color: #fff; }
|
||
.game-card .bar { height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; }
|
||
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
|
||
.f-0 { color: var(--muted); }
|
||
/* ---- Android display ---- */
|
||
.mv-windows { max-height: 420px; overflow-y: auto; }
|
||
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
|
||
.mv-perm .row { margin-top: 8px; }
|
||
.mv-live { color: var(--green, #7fd67f); font-size: 12px; }
|
||
.mv-opt { display: block; font-size: 13px; margin: 0 0 12px; }
|
||
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
|
||
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||
padding: 8px 10px; font: inherit; font-size: 13px; }
|
||
.disp select:focus { border-color: var(--blue); }
|
||
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
|
||
.disp select:disabled { opacity: .6; }
|
||
.disp .stat .v { font-size: 15px; }
|
||
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
|
||
.disp .seg { flex-wrap: wrap; }
|
||
.disp .seg button { padding: 0 10px; }
|
||
.disp input[type=number], #comfort input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||
/* ---- keyboard and trackpad ---- */
|
||
.pad-area { position: relative; height: 190px; margin: 12px 0; border-radius: 4px; background: rgba(0,0,0,.28);
|
||
border: 1px dashed rgba(255,255,255,.14); display: grid; place-items: center; cursor: pointer;
|
||
touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none; }
|
||
.pad-area:focus-visible, .pad-area.captured { border: 1px solid var(--blue); box-shadow: 0 0 0 1px var(--blue) inset; }
|
||
.pad-area.off { cursor: default; opacity: .55; }
|
||
.pad-hint { max-width: 420px; padding: 0 16px; text-align: center; color: var(--muted); font-size: 13px; pointer-events: none; }
|
||
.pad-keys { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||
.pad-keys input { flex: 1 1 220px; min-width: 0; height: 32px; padding: 0 10px; border-radius: 3px; border: 1px solid transparent;
|
||
background: rgba(0,0,0,.28); color: var(--text); font: inherit; }
|
||
.pad-keys input:focus { outline: none; border-color: var(--blue); }
|
||
#pad .hint { margin-top: 10px; }
|
||
|
||
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||
html { scroll-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; } /* the tabs are at the bottom here; Refresh is automatic */
|
||
.pill .pt > b { display: block; } .pill .via { display: inline; } header .pill .pt > span { font-size: 11.5px; color: var(--muted); }
|
||
header .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; }
|
||
/* Let connection text yield before battery and header actions. */
|
||
.hdr-end { flex: 1; gap: 6px; }
|
||
.hdr-end .pill, .hdr-end .devsel { min-width: 0; }
|
||
.hdr-end .pill { padding: 0 8px; gap: 6px; }
|
||
.hdr-end .pill > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||
/* The picker names the headset, so the pill keeps only its status dot: the name shows once. */
|
||
.hdr-end:has(.devsel:not([hidden])) .pill { flex: none; min-width: 0; width: 38px; padding: 0; justify-content: center; }
|
||
.hdr-end:has(.devsel:not([hidden])) .pill .pt { display: none; }
|
||
.hdr-end .devsel { flex: 1 1 auto; max-width: none; }
|
||
#reportBtn, #refreshAll { width: 38px; padding: 0; justify-content: center; }
|
||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 11px;
|
||
border-bottom: 0; color: var(--muted); }
|
||
nav a.on { border-bottom: 0; }
|
||
nav svg { display: block; width: 23px; height: 23px; }
|
||
.bottombar, .drawer { display: none; }
|
||
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||
main, .page { gap: 16px; }
|
||
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
|
||
.panel { padding: 14px; }
|
||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||
.toolbar { gap: 8px; }
|
||
.toolbar .spacer { display: none; }
|
||
.view-actions { flex: 1 1 100%; display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
|
||
.view-actions > button { grid-column:span 2; }
|
||
.view-actions > #copyBtn, .view-actions > #saveBtn { grid-column:span 3; }
|
||
.view-actions button { justify-content: center; padding: 0 4px; }
|
||
#panelPick { flex: 1 1 100%; max-width: none; }
|
||
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
|
||
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
|
||
.cat-grid { grid-template-columns: 1fr; }
|
||
.shot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||
.and-btns { padding-left: 0; }
|
||
.banner { flex-wrap: wrap; }
|
||
.banner .s { white-space: normal; }
|
||
/* Reserve the message row; connection actions wrap below it. */
|
||
#offline { align-items: flex-start; }
|
||
#offline > .grow { flex: 1 0 calc(100% - 36px); overflow-wrap: anywhere; }
|
||
button { height: 38px; }
|
||
button.small { height: 32px; }
|
||
.actions button { height: 46px; }
|
||
.cat-tools select, .source-offer select { max-width: none; flex: 1 1 100%; }
|
||
.pad-area { height: 240px; }
|
||
.pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); }
|
||
.pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); }
|
||
.pad-keys .seg button { padding: 0 4px; justify-content: center; text-align: center; }
|
||
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||
body.typing nav { display: none; }
|
||
}
|
||
/* ---- Discover: artwork first, details when you need them ---- */
|
||
.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }
|
||
#androidLibrary { grid-template-columns:1fr; }
|
||
#androidLibrary .and-col { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); align-items:start; }
|
||
.store-tabs { display:flex; align-items:center; gap:8px; margin-bottom:20px; border-bottom:1px solid #ffffff12; padding-bottom:14px; }
|
||
.store-tabs > button { background:transparent; color:var(--muted); border-radius:6px; }
|
||
.store-tabs > button.on { color:var(--bright); background:#ffffff0e; }
|
||
.store-heading h1 { font-size:clamp(28px,3.2vw,38px); letter-spacing:-1.2px; line-height:1.15; margin:8px 0 10px; font-weight:700; color:var(--bright); }
|
||
.store-heading p, .sources-intro p { color:#a8b5c2; margin:0 0 24px; font-size:15px; }
|
||
.store-eyebrow { color:var(--link); font-size:11px; text-transform:uppercase; font-weight:700; letter-spacing:1.8px; }
|
||
.store-toolbar { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,400px); align-items:center; gap:32px; }
|
||
.store-toolbar .store-heading p { margin-bottom:0; }
|
||
@media(max-width:900px) { .store-toolbar { display:block; } .store-toolbar .store-heading p { margin-bottom:20px; } }
|
||
.store-search { display:flex; align-items:center; gap:12px; border:1px solid #ffffff12; background:#0e1723a6; border-radius:8px; padding:6px 8px 6px 16px; }
|
||
.store-search svg { width:21px; height:21px; color:var(--muted); flex:none; }
|
||
.store-search input[type=search] { background:transparent; padding:9px 0; font-size:15px; border:0; box-shadow:none; min-width:0; }
|
||
.store-search:focus-within { border-color:var(--link); }
|
||
.store-search button { border-radius:5px; }
|
||
.store-filters { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:16px 0 24px; }
|
||
.store-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||
.store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; }
|
||
.store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; }
|
||
.store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; }
|
||
.store-heading a, .cross-link a { color:var(--link); }
|
||
.cross-link { margin:-6px 0 12px; }
|
||
body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); }
|
||
body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; }
|
||
.store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; }
|
||
.store-result-count:empty { display:none; }
|
||
.store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; }
|
||
.store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); }
|
||
.store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
|
||
.store-art > img.art-wash { inset:-24px; width:calc(100% + 48px); height:calc(100% + 48px); z-index:0; filter:blur(26px) saturate(1.4); opacity:.55; }
|
||
.store-art > img.art-icon { inset:0; margin:auto; width:64px; height:64px; border-radius:15px; z-index:0; box-shadow:0 10px 28px #0007; }
|
||
.detail-banner > img.art-icon, .store-hero .store-art > img.art-icon { width:88px; height:88px; border-radius:20px; }
|
||
.store-art .art-glyph { position:absolute; inset:0; display:grid; place-items:center; color:hsl(var(--hue) 45% 72% / .38); }
|
||
.store-art .art-glyph svg { width:34%; max-width:84px; height:auto; }
|
||
.runs { font-size:11px; font-weight:500; padding:3px 8px; border-radius:10px; white-space:nowrap; }
|
||
.runs.works { color:#c6efb0; background:#70bb5426; } .runs.ready { color:#b7d8ec; background:#6ec9ff1a; }
|
||
.runs.unknown { color:#a8b5c2; background:#ffffff0d; } .runs.blocked { color:#f1c28a; background:#d9a23a22; }
|
||
.runs.warning, .runs.issues { color:#f1c28a; background:#d9a23a22; white-space:normal; }
|
||
.store-compat-badges { display:flex; flex-wrap:wrap; gap:4px; }
|
||
.store-hero > .store-art { position:absolute; inset:0; }
|
||
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
|
||
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
|
||
.hero-copy .store-eyebrow { color:#c2e7ff; }
|
||
.hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; }
|
||
.hero-copy .hero-sum { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; }
|
||
.hero-actions { display:flex; gap:10px; margin-top:6px; }
|
||
.hero-actions button { height:38px; border-radius:6px; padding:0 18px; }
|
||
.hero-details { background:#ffffff20; color:#fff; border:1px solid #ffffff40; }
|
||
.store-hero { cursor:pointer; }
|
||
.store-row { margin-bottom:34px; }
|
||
.store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
|
||
.store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; }
|
||
.store-row-head p { color:var(--muted); font-size:12px; margin:0; }
|
||
.store-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(225px,1fr)); gap:18px; }
|
||
.store-card { display:block; width:100%; height:auto; min-width:0; padding:0; border:1px solid #ffffff0b; border-radius:9px; overflow:hidden; text-align:left; background:#1b2938; color:var(--text); transition:transform .18s,box-shadow .18s; font-weight:400; }
|
||
.store-card:hover { transform:translateY(-4px); background:#223448; box-shadow:0 12px 28px #0004; }
|
||
.store-card .store-art { aspect-ratio:16/9; width:100%; }
|
||
.store-card-content { padding:15px 14px; display:block; }
|
||
.store-card-title { display:flex; align-items:center; gap:9px; margin-bottom:9px; min-width:0; }
|
||
.store-icon { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; overflow:hidden; flex:none; background:hsl(var(--hue) 35% 28%); color:#fff; font-size:17px; position:relative; }
|
||
.store-icon img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
|
||
.store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; }
|
||
.store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; }
|
||
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
|
||
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
|
||
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
|
||
/* ---- Android: Store and Library ---- */
|
||
.store-tabs { border-bottom:0; padding-bottom:0; margin-bottom:26px; }
|
||
.store-switch { display:inline-flex; padding:4px; gap:4px; border-radius:12px; background:#0e1723; border:1px solid #ffffff10; }
|
||
.store-switch > button { display:inline-flex; align-items:center; gap:9px; height:42px; padding:0 22px; border-radius:9px; font-size:15px; font-weight:600; background:transparent; color:var(--muted); border:0; }
|
||
.store-switch > button svg { width:19px; height:19px; }
|
||
.store-switch > button.on { background:linear-gradient(180deg,#2a4763,#203a52); color:var(--bright); box-shadow:0 2px 10px #0006, inset 0 1px 0 #ffffff14; }
|
||
.store-switch > button:not(.on):hover { color:var(--text); background:#ffffff08; }
|
||
#andCount { font-size:12px; font-weight:600; color:#c6e2f5; background:#1a9fff2e; border-radius:10px; padding:1px 8px; }
|
||
#andCount:empty { display:none; }
|
||
.store-layout { display:grid; grid-template-columns:200px minmax(0,1fr); gap:32px; align-items:start; }
|
||
.store-side { position:sticky; top:88px; display:flex; flex-direction:column; gap:18px; }
|
||
.store-cats { display:flex; flex-direction:column; gap:2px; }
|
||
.store-cats button { display:flex; align-items:center; gap:11px; width:100%; height:38px; padding:0 12px; border-radius:8px; background:transparent; border:0; color:#b5c0cb; font-size:14px; font-weight:500; text-align:left; }
|
||
.store-cats button svg { width:18px; height:18px; flex:none; opacity:.85; }
|
||
.store-cats button:hover { background:#ffffff09; color:var(--bright); }
|
||
.store-cats button[aria-pressed=true] { background:#1a9fff26; color:var(--bright); box-shadow:inset 3px 0 0 var(--blue); }
|
||
.store-side-opts { display:flex; flex-direction:column; gap:10px; padding:14px 12px 0; border-top:1px solid #ffffff10; font-size:12px; color:#a8b5c2; }
|
||
.store-check { display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||
.store-check input { accent-color:var(--blue); width:15px; height:15px; margin:0; }
|
||
.store-source-select { display:flex; flex-direction:column; gap:6px; }
|
||
.store-source-select select { max-width:none; width:100%; }
|
||
.store-main { min-width:0; }
|
||
.store-toolbar { margin-bottom:20px; }
|
||
@media(max-width:900px) {
|
||
.store-layout { display:block; }
|
||
.store-side { position:static; margin-bottom:18px; gap:12px; }
|
||
.store-cats { flex-direction:row; overflow-x:auto; scrollbar-width:none; gap:6px; }
|
||
.store-cats button { width:auto; flex:none; height:34px; border-radius:18px; background:#ffffff07; border:1px solid #ffffff0c; font-size:12px; }
|
||
.store-cats button[aria-pressed=true] { box-shadow:none; background:#235779; border-color:#5287a5; }
|
||
.store-side-opts { flex-direction:row; flex-wrap:wrap; align-items:center; gap:14px; border:0; padding:0; }
|
||
.store-source-select { flex-direction:row; align-items:center; }
|
||
}
|
||
.store-shelf { display:grid; grid-auto-flow:column; grid-auto-columns:minmax(220px,calc((100% - 54px)/4)); gap:18px; overflow-x:auto; scroll-snap-type:x mandatory; padding:4px 2px 14px; scrollbar-width:thin; }
|
||
.store-shelf > * { scroll-snap-align:start; }
|
||
.store-row-head .see-all { margin-left:auto; font-size:12px; height:28px; padding:0 12px; border-radius:14px; background:#ffffff0a; color:var(--link); border:0; }
|
||
.store-row-head .see-all:hover { background:#ffffff14; color:var(--bright); }
|
||
.store-card { display:flex; flex-direction:column; }
|
||
.store-card-content { display:flex; flex-direction:column; flex:1; min-width:0; width:100%; }
|
||
.store-card-title strong { min-width:0; }
|
||
.store-summary { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; min-height:2.9em; line-height:1.45; }
|
||
.store-card-foot { margin-top:auto; padding-top:12px; }
|
||
.store-art.shots { display:flex; gap:6px; justify-content:center; padding:10px 10px 0; }
|
||
.store-art.shots > img { position:relative; inset:auto; width:auto; height:100%; max-width:31%; object-fit:cover; object-position:top; border-radius:6px 6px 0 0; box-shadow:0 4px 14px #0008; }
|
||
.store-art.icon-art > .art-backdrop { position:absolute; inset:-30%; width:160%; height:160%; object-fit:cover; filter:blur(28px) saturate(1.4) brightness(.55); }
|
||
.store-art.icon-art > .art-mark { position:absolute; z-index:2; left:50%; top:50%; width:34%; aspect-ratio:1; height:auto; transform:translate(-50%,-50%); border-radius:22%; box-shadow:0 8px 24px #0009; object-fit:cover; }
|
||
.store-art.icon-art .art-initial, .store-art.shots .art-initial { display:none; }
|
||
.store-cat { color:#a8b5c2; }
|
||
.store-fit { font-size:11px; font-weight:600; border-radius:4px; padding:2px 7px; }
|
||
.store-fit.works { color:#c3efb2; background:#59bf4029; }
|
||
.store-fit.ready { color:#bfe3fa; background:#1a9fff26; }
|
||
.store-fit.blocked { color:#f3b2a6; background:#d9412629; }
|
||
.store-vr { font-size:10px; font-weight:700; letter-spacing:.8px; color:#0e141b; background:#c2e7ff; border-radius:4px; padding:2px 6px; }
|
||
.store-art .store-vr { position:absolute; z-index:3; top:9px; left:9px; box-shadow:0 2px 8px #0007; }
|
||
.store-hero .hero-meta { display:flex; gap:8px; align-items:center; }
|
||
/* Library */
|
||
.lib-head { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap; margin-bottom:22px; }
|
||
.lib-head h1 { font-size:clamp(26px,3vw,34px); letter-spacing:-1px; margin:6px 0 6px; color:var(--bright); font-weight:700; }
|
||
.lib-head p { color:#a8b5c2; margin:0; font-size:14px; max-width:620px; }
|
||
.lib-tools { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||
.lib-tools input[type=search] { width:220px; border-radius:8px; }
|
||
.lib-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:18px; margin-bottom:36px; }
|
||
.lib-card { position:relative; display:flex; flex-direction:column; border-radius:10px; background:#1b2938; border:1px solid #ffffff0b; transition:transform .18s,box-shadow .18s; }
|
||
.lib-card:hover { transform:translateY(-3px); box-shadow:0 12px 28px #0005; background:#203247; }
|
||
.lib-card .store-art { aspect-ratio:16/9; width:100%; border-radius:10px 10px 0 0; }
|
||
.lib-card:has(.lib-more[open]) { z-index:5; transform:none; }
|
||
.lib-card.running { border-color:#59bf4080; box-shadow:0 0 0 1px #59bf4040; }
|
||
.lib-running { position:absolute; z-index:3; top:9px; right:9px; font-size:11px; font-weight:600; color:#d6f5c8; background:#2d5a1fd9; border-radius:10px; padding:2px 9px; display:inline-flex; align-items:center; gap:6px; }
|
||
.lib-running::before { content:""; width:7px; height:7px; border-radius:50%; background:#8ef05f; box-shadow:0 0 6px #8ef05f; }
|
||
.lib-body { display:flex; gap:11px; align-items:center; padding:13px 14px 4px; min-width:0; }
|
||
.lib-body .store-icon { width:40px; height:40px; border-radius:10px; font-size:19px; }
|
||
.lib-body strong { display:block; color:var(--bright); font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.lib-body small { display:block; color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.lib-body > div { min-width:0; }
|
||
.lib-warn { margin:6px 14px 0; font-size:11px; color:#e8c27a; }
|
||
.lib-actions { display:flex; gap:8px; align-items:center; padding:12px 14px 14px; margin-top:auto; }
|
||
.lib-actions .play, .lib-actions .stop { flex:1; height:36px; font-size:14px; font-weight:600; border-radius:6px; }
|
||
.lib-more { position:relative; }
|
||
.lib-more > summary { list-style:none; display:flex; align-items:center; justify-content:center; width:38px; height:36px; border-radius:6px; background:var(--btn); color:var(--text); cursor:pointer; font-size:18px; line-height:1; }
|
||
.lib-more > summary::-webkit-details-marker { display:none; }
|
||
.lib-more > summary:hover, .lib-more[open] > summary { background:var(--btn-hi); }
|
||
.lib-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:20; min-width:190px; padding:6px; border-radius:9px; background:#23364a; border:1px solid #ffffff1a; box-shadow:0 14px 34px #000a; display:flex; flex-direction:column; }
|
||
.lib-menu button { display:block; width:100%; text-align:left; height:auto; padding:8px 10px; border-radius:6px; background:transparent; font-size:13px; font-weight:400; color:var(--text); border:0; }
|
||
.lib-menu button small { display:block; color:var(--muted); font-size:11px; margin-top:2px; }
|
||
.lib-menu button:hover { background:#ffffff12; }
|
||
.lib-menu button.danger { color:#ff9c8a; }
|
||
.lib-empty { grid-column:1/-1; text-align:center; padding:48px 20px; border:1px dashed #ffffff1c; border-radius:12px; color:#a8b5c2; }
|
||
.lib-empty h2 { color:var(--bright); text-transform:none; letter-spacing:-.3px; font-size:20px; margin:0 0 8px; }
|
||
.lib-section { font-size:17px; color:var(--bright); margin:0 0 14px; font-weight:600; }
|
||
.lib-manage { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr)); gap:18px; align-items:start; }
|
||
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
|
||
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
|
||
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
|
||
.store-empty p { max-width:390px; color:var(--muted); margin:0 0 22px; }
|
||
.store-skeleton { aspect-ratio:1.2; background:linear-gradient(110deg,#233446 30%,#30455b 45%,#233446 60%); background-size:250% 100%; border-radius:9px; animation:store-shimmer 1.8s ease infinite; }
|
||
@keyframes store-shimmer { to { background-position:-150% 0; } }
|
||
.store-dialog { padding:0; border:1px solid #ffffff1a; border-radius:14px; background:#172331; color:var(--text); width:min(980px,calc(100vw - 40px)); max-width:none; max-height:calc(100dvh - 48px); overflow:auto; box-shadow:0 30px 100px #0009; }
|
||
.store-dialog::backdrop { background:#030a13c9; backdrop-filter:blur(7px); }
|
||
.store-close { position:sticky; float:right; top:14px; margin:14px 14px -54px 0; z-index:6; height:36px; width:36px; padding:0; border-radius:50%; background:#09131dcc; color:white; font-size:25px; line-height:1; border:1px solid #ffffff30; }
|
||
.detail-banner { height:195px; }
|
||
.detail-banner::after { content:""; position:absolute; inset:0; z-index:2; background:linear-gradient(0deg,#172331,transparent 70%); }
|
||
.detail-content { padding:0 30px 30px; position:relative; }
|
||
.detail-heading { display:flex; gap:16px; align-items:center; margin-top:-24px; position:relative; z-index:3; }
|
||
.detail-heading .store-icon { width:70px; height:70px; border-radius:15px; box-shadow:0 3px 18px #0004; font-size:32px; }
|
||
.detail-heading h1 { font-size:30px; color:white; letter-spacing:-.7px; margin:0 0 4px; }
|
||
.detail-heading p { margin:0; color:#a8b5c2; font-size:13px; }
|
||
.detail-content > .store-badges { margin:20px 0; }
|
||
.detail-layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:30px; margin-top:22px; }
|
||
.detail-layout h2 { text-transform:none; letter-spacing:0; font-size:17px; color:white; margin:0 0 14px; }
|
||
.detail-description { color:#b5c2ce; font-size:14px; line-height:1.75; white-space:pre-line; }
|
||
.detail-gallery { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x mandatory; margin:0 0 10px; }
|
||
.detail-gallery .store-art { flex:0 0 86%; aspect-ratio:16/9; border-radius:7px; scroll-snap-align:start; }
|
||
.detail-gallery .store-art img { object-fit:contain; background:#0b1420; }
|
||
.gallery-controls { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; font-size:11px; color:var(--muted); }
|
||
.gallery-controls button { height:30px; width:34px; padding:0; border-radius:5px; }
|
||
.detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; }
|
||
.detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; }
|
||
.detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; }
|
||
.detail-verdict.blocked { color:#e3b77a; }
|
||
.detail-verdict.warning, .detail-verdict.issues { color:#e3b77a; }
|
||
.detail-verdict.unknown { color:#adbac7; }
|
||
.detail-price { font-size:23px; color:white; font-weight:600; }
|
||
.detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; }
|
||
.offer-choice { display:flex; align-items:flex-start; gap:9px; padding:11px 9px; margin-top:8px; border:1px solid #ffffff12; border-radius:7px; cursor:pointer; }
|
||
.offer-choice:has(input:checked) { background:#1e3c52; border-color:#5386a6; }
|
||
.offer-choice input { margin:4px 0 0; accent-color:var(--blue); }
|
||
.offer-choice strong { font-size:12px; display:block; color:var(--bright); }
|
||
.offer-choice small { display:block; font-size:10px; color:#a8bbc9; margin-top:4px; }
|
||
.offer-mark { display:inline-flex; justify-content:center; align-items:center; height:26px; width:26px; flex:none; border-radius:6px; background:hsl(var(--hue) 30% 30%); color:white; font-size:11px; }
|
||
.detail-technical { border-top:1px solid #ffffff14; margin-top:25px; padding-top:16px; font-size:12px; color:var(--muted); }
|
||
.detail-technical summary { cursor:pointer; }
|
||
.detail-technical dl { display:grid; grid-template-columns:110px minmax(0,1fr); gap:6px; }
|
||
.detail-technical dd { margin:0; overflow-wrap:anywhere; }
|
||
.detail-buy .store-primary:disabled { opacity:1; color:#d6e8f7; background:#274f71; cursor:default; }
|
||
#detailProgress::-webkit-progress-bar { background:#283b4d; border-radius:3px; }
|
||
#detailProgress::-webkit-progress-value { background:var(--blue); border-radius:3px; }
|
||
#detailProgress { height:4px; width:100%; accent-color:var(--blue); display:block; margin-bottom:10px; }
|
||
.sources-dialog { width:min(560px,calc(100vw - 32px)); padding:30px; }
|
||
.sources-dialog .store-close { margin:-16px -16px -20px 0; top:0; }
|
||
.sources-intro h1 { font-size:28px; color:white; letter-spacing:-.7px; margin:9px 0; }
|
||
.source-setting { display:flex; align-items:center; gap:13px; padding:17px 0; border-top:1px solid #ffffff0c; }
|
||
.source-setting .offer-mark { width:42px; height:42px; font-size:16px; border-radius:10px; }
|
||
.source-setting .grow { flex:1; min-width:0; }
|
||
.store-dialog a { color:var(--link); }
|
||
.source-setting strong { display:block; font-size:14px; color:white; }
|
||
.source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; }
|
||
.source-setting small { color:var(--muted); font-size:10px; }
|
||
.source-web { font-size:12px; white-space:nowrap; }
|
||
.source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; }
|
||
.source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; }
|
||
.source-toggle:checked { background:#208dca; }
|
||
.source-toggle:checked::after { transform:translateX(15px); }
|
||
.source-repo-form { display:grid; gap:12px; border-top:1px solid #ffffff14; margin-top:10px; padding-top:24px; }
|
||
.source-repo-form h2 { font-size:19px; letter-spacing:0; text-transform:none; color:white; margin:0; }
|
||
.source-repo-form p { color:var(--muted); font-size:12px; margin:0; }
|
||
.source-repo-form label { font-size:12px; }
|
||
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
|
||
.source-repo-form details { font-size:11px; color:var(--muted); }
|
||
.source-repo-form button { justify-self:start; border-radius:6px; }
|
||
.source-token { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:8px 0 4px; }
|
||
.source-token input { flex:1; min-width:150px; border-radius:6px; font-size:12px; }
|
||
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||
.store-search input:focus-visible { outline:0!important; }
|
||
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
|
||
@media(max-width:1000px) { .store-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } .detail-layout { grid-template-columns:minmax(0,1fr) 260px; gap:20px; } }
|
||
@media(max-width:680px) {
|
||
.store-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||
.store-tabs { margin-bottom:24px; gap:2px; } .store-tabs button { padding:0 10px; font-size:12px; }
|
||
.store-heading p { font-size:13px; max-width:320px; }
|
||
.store-filters { align-items:flex-start; flex-direction:column; gap:10px; margin-bottom:18px; }
|
||
.store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; }
|
||
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
|
||
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
|
||
.hero-copy .hero-sum { font-size:13px; max-width:260px; }
|
||
.store-row-head { display:flex; align-items:center; } .store-row-head h2 { font-size:18px; } .store-row-head p { display:none; }
|
||
.store-heading .store-eyebrow, .store-heading p { display:none; } .store-heading h1 { font-size:26px; margin:0 0 12px; }
|
||
.store-shelf { grid-auto-columns:minmax(200px,72%); gap:12px; }
|
||
.store-switch > button { height:38px; padding:0 14px; font-size:14px; }
|
||
.lib-head { margin-bottom:16px; } .lib-head p { font-size:13px; } .lib-tools, .lib-tools input[type=search] { width:100%; }
|
||
.lib-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||
.lib-body { padding:10px 10px 2px; } .lib-body .store-icon { width:30px; height:30px; font-size:14px; } .lib-body strong { font-size:13px; }
|
||
.lib-actions { padding:10px; gap:6px; } .lib-actions .play, .lib-actions .stop { height:34px; font-size:13px; }
|
||
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
|
||
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
|
||
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
|
||
.store-dialog { width:calc(100vw - 16px); max-height:calc(100dvh - 20px); border-radius:10px; }
|
||
.detail-banner { height:190px; } .detail-content { padding:0 18px 24px; } .detail-heading h1 { font-size:24px; }
|
||
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
|
||
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
|
||
}
|
||
@media(prefers-reduced-motion:reduce) { html, #scroll { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
||
|
||
/* Task hierarchy shared by desktop and phone. */
|
||
:root { --muted:#a1adba; }
|
||
.workspace-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
|
||
.workspace-heading h1 { margin:4px 0 7px; color:white; font-size:32px; letter-spacing:-.8px; line-height:1.15; }
|
||
.workspace-heading p { margin:0; color:var(--muted); }
|
||
.eyebrow { color:var(--link); font-size:11px; font-weight:700; letter-spacing:1.5px; text-transform:uppercase; }
|
||
.workspace-link { color:var(--link); display:inline-flex; align-items:center; min-height:44px; text-decoration:none; font-weight:500; }
|
||
.primary-link { padding:0 16px; background:#246fa3; color:white; border-radius:7px; }
|
||
.home-grid { display:grid; grid-template-columns:minmax(0,1.7fr) minmax(0,1fr); gap:20px; }
|
||
.home-activity, .home-session { padding:26px; border:1px solid #ffffff0d; border-radius:12px; box-shadow:none; }
|
||
.home-activity h2, .home-session h2 { margin:12px 0 8px; color:white; font-size:24px; font-weight:600; }
|
||
.home-activity p, .home-session p { color:var(--muted); }
|
||
.home-actions { display:flex; gap:18px; align-items:center; flex-wrap:wrap; margin-top:24px; }
|
||
.home-running { display:flex; flex-wrap:wrap; gap:8px; }
|
||
.home-running button { min-height:44px; height:auto; text-align:left; padding:10px 14px; }
|
||
.home-running small { display:block; color:var(--muted); }
|
||
.quick-routes { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr)); gap:12px; }
|
||
.quick-routes a { padding:18px; display:flex; flex-direction:column; justify-content:center; gap:5px; min-height:96px; border:1px solid #ffffff12; border-radius:10px; background:#1b2a3a; text-decoration:none; }
|
||
.quick-routes a:hover { background:#243a50; border-color:#66c0f470; }
|
||
.quick-routes span { color:white; font-size:16px; font-weight:500; }
|
||
.quick-routes small { color:var(--muted); font-size:13px; }
|
||
[data-page=capture] .grid-top { display:block; }
|
||
.library-controls { display:grid; gap:12px; }
|
||
.scope-tabs { display:flex; gap:4px; flex-wrap:wrap; align-items:center; }
|
||
.scope-tabs button { min-height:44px; height:auto; padding:10px 16px; border-radius:7px; background:transparent; color:var(--muted); }
|
||
.scope-tabs [aria-pressed=true] { background:#28455e; color:white; box-shadow:inset 0 0 0 1px #66c0f440; }
|
||
#librarySources { padding-bottom:14px; border-bottom:1px solid #ffffff12; }
|
||
.lib-head { margin-bottom:12px; gap:12px; }
|
||
.lib-head h2 { margin:0 0 5px; font-size:17px; color:white; }
|
||
.lib-head p { font-size:13px; }
|
||
.lib-tools { gap:10px; }
|
||
.lib-tools input[type=search] { height:44px; }
|
||
.lib-grid { display:flex; flex-direction:column; gap:0; margin-bottom:0; border-top:1px solid #ffffff12; }
|
||
.lib-card, .lib-card:hover { display:grid; grid-template-columns:44px minmax(0,1fr) 82px 44px; align-items:center; gap:12px; padding:10px 4px; min-height:78px; border:0; border-bottom:1px solid #ffffff0d; border-radius:0; background:transparent; box-shadow:none; transform:none; }
|
||
.lib-card.running { box-shadow:inset 3px 0 #59bf40; background:#59bf4006; padding-left:12px; }
|
||
.lib-card > .store-icon { width:44px; height:44px; border-radius:11px; font-size:22px; }
|
||
.lib-name { min-width:0; min-height:48px; height:auto; padding:0; background:transparent; text-align:left; display:block; }
|
||
.lib-name:hover { background:transparent; }
|
||
.lib-name strong { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:white; font-size:15px; font-weight:500; }
|
||
.lib-name small { display:block; font-size:12px; color:var(--muted); margin-top:4px; }
|
||
.lib-card > button.play, .lib-card > button.stop, .lib-overflow { min-height:44px; height:auto; border-radius:7px; }
|
||
.lib-overflow { width:44px; padding:0; font-size:24px; }
|
||
.installed-detail { padding:28px; border-radius:12px; }
|
||
.installed-detail h2 { font-size:26px; text-transform:none; letter-spacing:-.5px; margin:10px 40px 8px 0; }
|
||
.installed-detail h3 { margin-top:26px; }
|
||
.installed-detail .store-close { position:absolute; right:12px; top:12px; margin:0; width:44px; height:44px; }
|
||
.installed-detail button, .installed-detail a { min-height:44px; }
|
||
.detail-actions { display:flex; flex-wrap:wrap; gap:10px; }
|
||
.inline-error { padding:12px; display:flex; align-items:center; gap:12px; border:1px solid #d9a23a60; border-radius:8px; margin-bottom:10px; }
|
||
.inline-error span { flex:1; }
|
||
.tool-group { margin-bottom:28px; }
|
||
.tool-group h2 { margin:0 0 12px; color:var(--muted); font-size:13px; font-weight:500; }
|
||
#toolWorkspace { display:block; }
|
||
#toolWorkspace > section { max-width:1040px; margin:0; }
|
||
#toolWorkspace .panel { box-shadow:none; border:1px solid #ffffff0d; border-radius:10px; }
|
||
#toolWorkspace button, #toolWorkspace summary, #toolWorkspace select { min-height:44px; height:auto; }
|
||
#toolWorkspace summary { cursor:pointer; }
|
||
#toolWorkspace #comfort { margin:0!important; padding:20px!important; }
|
||
#toolWorkspace .lib-manage { margin-top:18px; }
|
||
.power-actions { border-top:1px solid #ffffff16; padding-top:16px; }
|
||
.store-layout { display:flex; flex-direction:column; gap:18px; }
|
||
.store-main { width:100%; }
|
||
.store-side { position:static; order:2; width:100%; }
|
||
.store-cats { display:flex; flex-wrap:wrap; gap:6px; }
|
||
.store-cats button { width:auto; }
|
||
.store-side-opts { flex-direction:row; flex-wrap:wrap; }
|
||
.store-toolbar { display:flex; flex-direction:column; align-items:stretch; gap:12px; }
|
||
.store-search { width:100%; }
|
||
.store-search input, .store-search button, .store-cats button, .see-all { min-height:44px; height:auto; }
|
||
.store-heading .store-eyebrow { display:none; }
|
||
.store-heading h1 { font-size:23px; margin:0; }
|
||
.store-heading p { font-size:13px; margin:6px 0 0; }
|
||
:focus-visible { outline:2px solid var(--link); outline-offset:3px; }
|
||
@media(max-width:680px) {
|
||
main { gap:20px; }
|
||
.workspace-heading h1 { font-size:28px; }
|
||
.workspace-heading p { font-size:13px; }
|
||
.home-grid { grid-template-columns:1fr; gap:12px; }
|
||
.home-activity, .home-session { padding:20px; }
|
||
.home-activity h2, .home-session h2 { font-size:21px; }
|
||
.quick-routes { grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||
.quick-routes a { padding:15px; min-height:105px; }
|
||
.quick-routes span { font-size:14px; }
|
||
.quick-routes small { font-size:12px; }
|
||
.scope-tabs button { padding:10px 12px; font-size:13px; }
|
||
.lib-tools { width:100%; }
|
||
.lib-tools .store-chips button { min-height:44px; height:auto; }
|
||
.lib-card, .lib-card:hover { grid-template-columns:40px minmax(0,1fr) 62px 44px; gap:8px; min-height:76px; padding:9px 0; }
|
||
.lib-card.running { padding-left:7px; }
|
||
.lib-card > .store-icon { width:40px; height:40px; font-size:20px; }
|
||
.lib-name strong { font-size:14px; }
|
||
.lib-name small { font-size:11px; }
|
||
.lib-card > button.play, .lib-card > button.stop { padding:0 8px; font-size:13px; }
|
||
.lib-head { gap:10px; }
|
||
.store-layout { display:flex; flex-direction:column; gap:16px; }
|
||
.store-main { width:100%; }
|
||
.store-side { order:2; }
|
||
.store-side { width:100%; }
|
||
.store-hero { height:230px; margin-bottom:20px; }
|
||
.store-cats { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||
.store-heading h1 { font-size:21px; }
|
||
.store-heading p { display:block; max-width:none; }
|
||
.store-search { min-height:48px; }
|
||
#sourcesOpen { min-height:44px; }
|
||
#nav a { min-width:0; }
|
||
.drawer.open { display:block; bottom:calc(58px + env(safe-area-inset-bottom)); }
|
||
}
|
||
|
||
.store-cats { flex-direction:row; align-items:center; }
|
||
.store-cats button { flex:none; }
|
||
.store-options > summary { min-height:44px; display:flex; align-items:center; color:var(--muted); cursor:pointer; font-size:12px; }
|
||
.store-options > summary::after { content:' +'; margin-left:8px; }
|
||
.store-options[open] > summary::after { content:' −'; }
|
||
.store-options .store-side-opts { padding:12px; border:1px solid #ffffff12; border-radius:8px; }
|
||
.library-filter-menu { position:relative; }
|
||
.library-filter-menu > summary { display:flex; align-items:center; padding:0 14px; min-height:44px; border-radius:7px; background:var(--btn); color:var(--text); cursor:pointer; list-style:none; }
|
||
.library-filter-menu > summary::-webkit-details-marker { display:none; }
|
||
.library-filter-menu > summary::after { content:'⌄'; padding-left:8px; }
|
||
.library-filter-menu .store-chips { position:absolute; right:0; top:calc(100% + 8px); z-index:10; width:220px; max-width:calc(100vw - 24px); padding:8px; background:#22374a; border:1px solid #ffffff24; border-radius:10px; box-shadow:0 12px 24px #0008; display:flex; flex-direction:column; align-items:stretch; gap:4px; }
|
||
.library-filter-menu .store-chips button { min-height:44px; text-align:left; border-radius:6px; }
|
||
@media(max-width:680px) {
|
||
[data-page=games] .workspace-heading p, [data-page=games] .workspace-heading .eyebrow { display:none; }
|
||
[data-page=games] .workspace-heading h1 { margin:0; }
|
||
[data-page=games] .workspace-heading > .workspace-link { font-size:12px; }
|
||
[data-page=games] { gap:16px; }
|
||
.library-controls { gap:6px; }
|
||
.lib-tools { flex-wrap:nowrap; }
|
||
.lib-tools input[type=search] { width:auto; min-width:0; flex:1; }
|
||
.store-cats { display:flex; flex-direction:row; flex-wrap:nowrap; overflow-x:auto; max-width:100%; padding-bottom:4px; }
|
||
.store-cats button { flex:none; white-space:nowrap; }
|
||
.store-toolbar { gap:10px; }
|
||
.store-heading p { display:none; }
|
||
}
|
||
|
||
|
||
.add-install { min-height:44px; height:auto; white-space:nowrap; }
|
||
[data-page=apps] #android { margin:0; }
|
||
#appsInstalled, #appsLinux { margin-top:18px; }
|
||
.app-browse-heading { display:flex; align-items:center; flex-wrap:wrap; justify-content:space-between; gap:12px; margin:16px 0 8px; }
|
||
.app-browse-heading p { margin:0; color:var(--muted); font-size:13px; }
|
||
#settingsWorkspace > section { max-width:1040px; margin:0; }
|
||
#settingsWorkspace button, #settingsWorkspace summary, #settingsWorkspace select, #appsLinux button { min-height:44px; height:auto; }
|
||
.install-chooser { width:min(620px,calc(100vw - 24px)); max-height:calc(100dvh - 32px); overflow:auto; }
|
||
.install-chooser .store-close { width:44px; height:44px; position:static; margin-left:auto; flex:none; }
|
||
.install-chooser-head { position:sticky; top:0; z-index:2; display:flex; align-items:center; gap:16px; background:rgb(30,35,41); margin:-1px -1px 0; padding:0 0 12px; }
|
||
.install-chooser-head .eyebrow { flex:1; }
|
||
.install-choices { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin:20px 0; }
|
||
.install-choices a, .install-choices button { display:flex; flex-direction:column; align-items:flex-start; justify-content:center; text-align:left; gap:5px; min-height:80px; height:auto; padding:16px; background:#22374a; border:1px solid #ffffff16; border-radius:9px; text-decoration:none; }
|
||
.install-choices strong { color:white; font-size:15px; font-weight:500; }
|
||
.install-choices span { color:var(--muted); font-size:12px; line-height:1.4; }
|
||
#installLinkForm label { display:block; font-size:13px; color:var(--muted); margin-bottom:8px; }
|
||
.install-link-row { display:flex; gap:8px; }
|
||
.install-link-row input { min-width:0; flex:1; height:44px; }
|
||
.install-link-row button { min-height:44px; height:auto; }
|
||
#installLinkError { color:#ffb4a9; margin:10px 0; }
|
||
.install-chooser > .sub { font-size:12px; line-height:1.5; margin:16px 0 0; }
|
||
#wiDlg { max-width:calc(100vw - 24px); }
|
||
#wiFacts dd { overflow-wrap:anywhere; }
|
||
#wiDlg button, #getgames button { min-height:44px; height:auto; }
|
||
@media(max-width:680px) {
|
||
[data-page=apps] .workspace-heading .eyebrow, [data-page=apps] .workspace-heading p, [data-page=games] .workspace-heading .eyebrow { display:none; }
|
||
.add-install { font-size:13px; padding:10px 12px; }
|
||
.install-chooser { padding:20px; max-height:calc(100dvh - 20px); }
|
||
.install-chooser h2 { font-size:23px; }
|
||
.install-choices { grid-template-columns:1fr; gap:8px; margin:16px 0; }
|
||
.install-choices a, .install-choices button { padding:12px; min-height:64px; }
|
||
.install-link-row { flex-wrap:wrap; }
|
||
.install-link-row input { flex-basis:100%; }
|
||
.install-link-row button { width:100%; }
|
||
#appsInstalled, #appsLinux { margin-top:12px; }
|
||
#appsLinux .row { flex-wrap:wrap!important; }
|
||
}
|
||
|
||
#storage .card { padding:16px; border:1px solid rgba(255,255,255,.1); border-radius:8px; margin-top:16px; }
|
||
#storage .grow, #storage .sub { overflow-wrap:anywhere; }
|
||
#storage .item > span { flex-shrink:0; }
|
||
</style>
|
||
<link rel="stylesheet" href="/control-center.css">
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<a class="brand" href="#home" aria-label="Frame Control">
|
||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1a9fff"/><stop offset="1" stop-color="#1a44c2"/></linearGradient></defs>
|
||
<circle cx="16" cy="16" r="15" fill="url(#lg)"/>
|
||
<rect x="6.5" y="11" width="19" height="10" rx="4" fill="none" stroke="#fff" stroke-width="2"/>
|
||
<circle cx="12" cy="16" r="1.8" fill="#fff"/><circle cx="20" cy="16" r="1.8" fill="#fff"/>
|
||
</svg>
|
||
<b>Frame Control</b>
|
||
</a>
|
||
<nav id="nav" aria-label="Main navigation">
|
||
<a href="#apps" class="on" title="Apps and app installs (1)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></svg>Apps<kbd>1</kbd></a>
|
||
<a href="#games" title="Games and game installs (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7h10c3 0 5 10 3 11-2 1-4-3-5-3H9c-1 0-3 4-5 3-2-1 0-11 3-11zM7 9v5M4.5 11.5h5M16 10h.01M18 13h.01"/></svg>Games<kbd>2</kbd></a>
|
||
<a href="#tools" title="Capture, files and remote controls (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>3</kbd></a>
|
||
<a href="#settings" title="Connection and preferences (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="currentColor"/><circle cx="16" cy="12" r="2" fill="currentColor"/><circle cx="8" cy="18" r="2" fill="currentColor"/></svg>Settings<kbd>4</kbd></a>
|
||
</nav>
|
||
<div class="hdr-end">
|
||
<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>
|
||
<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 or send feedback" aria-label="Report a problem or send feedback">
|
||
<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 4z"/><path d="M8 9.5h8M8 12.5h5" stroke-linecap="round"/></svg>
|
||
</button>
|
||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||
</button>
|
||
</div>
|
||
</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>
|
||
<div class="progress" id="updateProg" hidden><i></i></div>
|
||
<button class="small" id="updateNotes">What's new</button>
|
||
<button class="action small" id="updateGo">Update and restart</button>
|
||
<button class="small" id="updateLater">Later</button>
|
||
</div>
|
||
<div class="notice" id="privacyNotice" hidden>
|
||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||
your operating system, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
|
||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||
worked and the details of errors, which helps fix problems faster.</div>
|
||
<button class="small" id="noticeSettings">Privacy settings</button>
|
||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||
<button class="action small" id="noticeOk">OK</button>
|
||
</div>
|
||
<form class="notice" id="contactNotice" hidden>
|
||
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
|
||
you can change or remove it any time in Privacy & updates.
|
||
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
|
||
<div class="contact-opts">
|
||
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
|
||
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
|
||
<span class="sub" id="cpMsg" role="status"></span>
|
||
<button type="button" class="small" id="cpNo">No thanks</button>
|
||
<button type="submit" class="action small" id="cpSave">Save</button>
|
||
</form>
|
||
<div class="banner" id="offline" role="alert" hidden>
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
|
||
<button class="small desk-only" id="offDetails" hidden>Details</button>
|
||
<button class="small desk-only" id="offTrust" hidden>Check the new key…</button>
|
||
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
|
||
<button class="action small" id="offRetry">Retry now</button>
|
||
</div>
|
||
|
||
<div class="page cc" data-page="control" id="controlCenter">
|
||
<section class="cc-hero" aria-labelledby="ccTitle">
|
||
<div><div class="cc-eyebrow">Your Frame, at a glance</div><h1 id="ccTitle">Control Center</h1><p>Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.</p></div>
|
||
<svg class="cc-hero-mark" viewBox="0 0 120 84" fill="none" aria-hidden="true"><path d="M14 28C14 15 106 15 106 28v23c0 18-33 23-39 11h-14c-6 12-39 7-39-11z" stroke="currentColor" stroke-width="1.5"/><path d="M26 32c0-6 25-6 25 0v15c0 11-25 11-25 0zM69 32c0-6 25-6 25 0v15c0 11-25 11-25 0z" stroke="currentColor" stroke-opacity=".4"/><path d="M7 33h7m92 0h7M48 18l4-7h16l4 7" stroke="currentColor" stroke-width="1.5"/><circle cx="60" cy="38" r="2" fill="currentColor"/></svg>
|
||
</section>
|
||
<section aria-labelledby="ccDeviceName">
|
||
<div class="cc-topline"><h2 id="ccDeviceName">Steam Frame</h2><span class="cc-sample" id="ccSampleState" role="status">Connect your Frame to read live metrics</span><button class="small" id="ccRefresh">Refresh metrics</button></div>
|
||
<div class="cc-summary">
|
||
<div class="cc-metric"><span>Battery</span><strong data-cc-metric="battery">Unavailable</strong><small data-cc-metric="batteryDetail">Connect to read charge and power</small></div>
|
||
<div class="cc-metric"><span>Storage free</span><strong data-cc-metric="storage">Unavailable</strong><small data-cc-metric="storageDetail">User storage unavailable</small></div>
|
||
<div class="cc-metric"><span>Memory used</span><strong data-cc-metric="memory">Unavailable</strong><small data-cc-metric="memoryDetail">Memory sensor unavailable</small></div>
|
||
<div class="cc-metric"><span>Compositor FPS</span><strong data-cc-metric="fps">Unavailable</strong><small data-cc-metric="fpsDetail">Compositor FPS · not game FPS</small></div>
|
||
</div>
|
||
<details class="cc-device-facts"><summary id="ccBuild">System information unavailable</summary>
|
||
<dl class="cc-readings">
|
||
<div><dt>Wi-Fi network</dt><dd data-cc-metric="wifi">Unavailable</dd></div><div><dt>Wi-Fi signal</dt><dd data-cc-metric="signal">Unavailable</dd></div>
|
||
<div><dt>IP address</dt><dd data-cc-metric="ip">Unavailable</dd></div><div><dt>Uptime</dt><dd data-cc-metric="uptime">Unavailable</dd></div>
|
||
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
|
||
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
|
||
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
|
||
<div><dt>Charge cycles</dt><dd data-cc-metric="cycles">Unavailable</dd></div><div><dt>Full charge capacity</dt><dd data-cc-metric="fullCapacity">Unavailable</dd></div>
|
||
<div><dt>Design capacity</dt><dd data-cc-metric="designCapacity">Unavailable</dd></div><div><dt>Full / design capacity</dt><dd data-cc-metric="capacityHealth">Unavailable</dd></div>
|
||
<div><dt>Battery source</dt><dd data-cc-metric="batterySource">Unavailable</dd></div>
|
||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Unavailable sensors are not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.</p>
|
||
<div class="cc-services" id="ccServices"></div>
|
||
</details>
|
||
</section>
|
||
<div class="cc-workspace">
|
||
<aside class="cc-sidebar" aria-label="Control Center categories"><p>Browse controls</p><div class="cc-categories" id="ccCategories"></div>
|
||
<div class="cc-status"><div><i class="dot" id="ccStatusDot"></i><span id="ccStatusLabel">No live sample</span></div><p id="ccStatusNote">Read-only status over SSH. Controls run only when you use them.</p></div>
|
||
</aside>
|
||
<div>
|
||
<div class="cc-search-row"><div class="cc-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></svg><input id="ccSearch" type="search" placeholder="Search settings, controls and guides…" aria-label="Search Control Center" autocomplete="off"><button id="ccClear" type="button" aria-label="Clear search" hidden>×</button></div><span class="cc-results" id="ccResultCount" role="status" aria-live="polite"></span></div>
|
||
<div class="cc-cards" id="ccCards"></div>
|
||
<div class="cc-empty" id="ccEmpty" hidden><h2>No matching controls</h2><p>Try a different word or browse all categories.</p><button class="small" id="ccResetFilter">Reset search & category</button></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="page on" data-page="home">
|
||
<div class="workspace-heading"><div><span class="eyebrow">Your Steam Frame</span><h1 id="homeTitle">Let's connect</h1><p id="homeReadiness" role="status">Checking your headset…</p></div><a class="workspace-link" href="#devices">Devices & connection</a></div>
|
||
<div class="home-grid"><section class="panel home-activity"><span class="eyebrow">Current activity</span><h2 id="homeActivity">Connect to see what's running</h2><p id="homeActivityNote">Your installed apps and current downloads appear here.</p><div id="homeRunning" class="home-running"></div><div class="home-actions"><a href="#library" class="workspace-link primary-link">Open Games →</a><a href="#discover" class="workspace-link">Find something new</a></div></section>
|
||
<section class="panel home-session"><span class="eyebrow">Background tasks</span><h2 id="homeJobs">No active tasks</h2><p id="homeDownload">Install progress stays here while you explore.</p><button class="small" id="homeLog">View activity log</button></section></div>
|
||
<div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and share the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#comfort"><span>Family & comfort</span><small>Sessions, breaks and alerts</small></a><a href="#control"><span>Control Center</span><small>Search settings and live metrics</small></a></div>
|
||
<section class="panel now" id="device">
|
||
<div class="shelf-head"><h2>Right now</h2></div>
|
||
<p class="now-state" id="nowState">Waiting for the Frame…</p>
|
||
<div class="vol">
|
||
<button class="small" id="muteBtn" aria-label="Mute">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path id="volWaves" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||
</button>
|
||
<input type="range" id="vol" min="0" max="100" step="1" value="0" aria-label="Volume">
|
||
<span class="num" id="volNum">—</span>
|
||
</div>
|
||
<div class="now-actions">
|
||
<button class="action" id="castBtn" title="The live headset view, full screen, for people in the room">Cast to this screen</button>
|
||
<button data-go="media">Play a video or photo</button>
|
||
<button data-go="macview" id="nowMac" hidden>Show a Mac window</button>
|
||
<button id="nowSend">Send files…</button>
|
||
<button data-go="pad" title="Type on the headset (T)">Type on the headset</button>
|
||
</div>
|
||
<p class="hint">Casting shows everything the wearer sees, including anything private.</p>
|
||
</section>
|
||
</div>
|
||
<div class="page" data-page="capture">
|
||
<div class="workspace-heading"><div><a class="workspace-link" href="#tools">← Tools</a><h1>Capture & Live</h1><p>See the headset, capture a moment, or control a desktop panel.</p></div><a class="workspace-link" href="#shots">Screenshots</a></div>
|
||
<div class="grid-top" id="view">
|
||
<section class="panel">
|
||
<div class="toolbar">
|
||
<div class="seg" aria-label="What to capture">
|
||
<button data-view="headset" class="on" title="What the headset shows, via the SteamVR compositor">Headset view</button>
|
||
<button data-view="flat" title="The app panel in use, flat: it stays still however the wearer looks around">Desktop</button>
|
||
</div>
|
||
<select id="panelPick" class="small" hidden aria-label="Which panel to show"></select>
|
||
<div class="seg" id="eyeSeg" aria-label="Crop">
|
||
<button data-eye="left" class="on" title="One eye, like pointing a camera into one lens">Left eye</button>
|
||
<button data-eye="both" title="Both eyes side by side">Both eyes</button>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<span class="view-actions">
|
||
<button class="action" id="shotBtn">Capture</button>
|
||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||
<button id="recBtn" title="Record what the headset shows, with its sound, to ~/Videos/SteamFrame on this computer">Record</button>
|
||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
|
||
<button id="saveBtn" disabled>Save</button>
|
||
</span>
|
||
</div>
|
||
<div class="viewer" id="viewer">
|
||
<div class="empty" id="viewerEmpty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="2" y="7" width="20" height="10" rx="4"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="12" r="2"/></svg>
|
||
<div>Press <b>Capture</b> to grab what the Frame is showing, or <b>Live</b> to keep it updating.</div>
|
||
</div>
|
||
<canvas id="canvas" hidden></canvas>
|
||
<span class="badge" id="srcBadge" hidden></span>
|
||
<span class="badge live" id="liveBadge" hidden>Live</span>
|
||
<span class="stamp" id="stamp" hidden></span>
|
||
<div class="zoombar" id="zoombar" hidden>
|
||
<button id="zoomOut" title="Zoom out (−)" aria-label="Zoom out">−</button>
|
||
<button id="zoomFit" class="lvl" title="Fit the whole frame (0)">Fit</button>
|
||
<button id="zoomIn" title="Zoom in (+)" aria-label="Zoom in">+</button>
|
||
<button id="fullBtn" title="Full screen (F)" aria-label="Full screen">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="asleep" id="asleep" hidden>The headset is showing a blank frame. It's probably asleep or not being worn. Put it on and capture again.</div>
|
||
<div class="loading"><div class="spinner"></div></div>
|
||
</div>
|
||
<div class="ctrl-status" id="ctrlBadge" role="status" hidden></div>
|
||
<div class="pad-keys ctrl-keys" id="ctrlKeys" hidden aria-label="Keys and clicks for the Frame">
|
||
<input id="ctrlType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||
<span class="seg pad-clicks">
|
||
<button data-ctrl-click="left" title="Left click where the pointer is">Click</button>
|
||
<button data-ctrl-click="right" title="Right click where the pointer is">Right-click</button>
|
||
</span>
|
||
<span class="seg pad-special">
|
||
<button data-ctrl-key="1" title="Escape">Esc</button>
|
||
<button data-ctrl-key="15" title="Tab">Tab</button>
|
||
<button data-ctrl-key="105" title="Left arrow" aria-label="Left arrow">←</button>
|
||
<button data-ctrl-key="103" title="Up arrow" aria-label="Up arrow">↑</button>
|
||
<button data-ctrl-key="108" title="Down arrow" aria-label="Down arrow">↓</button>
|
||
<button data-ctrl-key="106" title="Right arrow" aria-label="Right arrow">→</button>
|
||
<button data-ctrl-key="14" title="Backspace" aria-label="Backspace">⌫</button>
|
||
<button data-ctrl-key="28" title="Enter">Enter</button>
|
||
</span>
|
||
</div>
|
||
<div class="hint" id="recStatus" role="status" hidden></div>
|
||
<div class="hint" id="viewHint"></div>
|
||
</section>
|
||
|
||
|
||
</div>
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
<section id="shots">
|
||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||
<button class="small" id="shotsRefresh">Refresh</button>
|
||
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
|
||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||
</div>
|
||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||
<div class="sub sync-note" id="shotsSyncNote" hidden></div>
|
||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
|
||
</section>
|
||
<section id="clips">
|
||
<div class="shelf-head"><h2>Steam clips</h2><span class="count" id="clipCount"></span><span class="spacer"></span>
|
||
<button class="small" id="clipsRefresh">Refresh</button>
|
||
<button class="small" id="videosFolder" title="Open the SteamFrame folder in your Videos">Show folder</button>
|
||
</div>
|
||
<div class="list" id="clipList"><div class="sub">Loading…</div></div>
|
||
<div class="sub sync-note" id="clipsSyncNote" hidden></div>
|
||
<div class="hint">Clips and background recordings from Steam's game recording in the headset. Export joins one into a single <code>.mp4</code> in <code>~/Videos/SteamFrame</code>, however long it is, without re-encoding it. Recordings you make with <b>Record</b> go there too.</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<div class="page" data-page="games">
|
||
<div class="workspace-heading"><div><span class="eyebrow">Play on your Frame</span><h1>Games</h1><p>Your Steam library and games you add yourself.</p></div><button class="action add-install" data-open-install>+ Add / Install</button></div>
|
||
<div class="library-controls"><div class="scope-tabs" id="libraryScope" aria-label="Game library"><button data-scope="installed" aria-pressed="true">Installed</button><button data-scope="owned" aria-pressed="false">Owned</button><button data-scope="store" aria-pressed="false">Store</button></div>
|
||
<div class="scope-tabs" id="librarySources" aria-label="Installed content source"><button data-library-source="all" aria-pressed="true">All</button><button data-library-source="steam" aria-pressed="false">Steam</button><button data-library-source="sideloaded" aria-pressed="false">Sideloaded</button></div></div>
|
||
<section class="panel now-running" id="nowRunning" aria-labelledby="nowRunningTitle" hidden>
|
||
<div class="shelf-head"><h2 id="nowRunningTitle">Running now</h2><span class="spacer"></span><button class="small" id="runningCheck">Check again</button></div>
|
||
<div class="list" id="runningList"></div>
|
||
<div class="hint">Force quit a game that has stopped responding. Steam is asked to quit it first; unsaved progress is lost.</div>
|
||
</section>
|
||
<div id="libraryOwned"></div><div id="steamDiscover" hidden></div>
|
||
<section id="library">
|
||
<div class="shelf-head"><h2>Steam games</h2><span class="count" id="gameCount"></span></div>
|
||
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
|
||
</section>
|
||
|
||
<div class="lib" id="androidLibrary">
|
||
<div class="lib-head"><div><h2>Installed apps</h2><p id="libSummary">Installed on this Frame. Open an app or choose More for its settings.</p></div>
|
||
<div class="lib-tools"><input id="libQ" type="search" aria-label="Filter your apps" placeholder="Filter your apps">
|
||
<details id="libFilterMenu" class="library-filter-menu"><summary id="libFilterLabel">Filters</summary><div class="store-chips" id="libFilters"><button data-lib="all" aria-pressed="true">All apps</button><button data-lib="vr" aria-pressed="false">VR</button><button data-lib="flat" aria-pressed="false">Flat-screen</button><button data-lib="running" aria-pressed="false">Running</button></div></details></div></div>
|
||
<div id="androidLoadError" class="inline-error" role="status" hidden><span id="androidLoadMessage"></span><button id="androidRetry">Retry</button></div><div class="lib-grid" id="andApps"><div class="sub">Loading…</div></div>
|
||
<div hidden>
|
||
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||
<div class="cat-tools">
|
||
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
|
||
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
|
||
</div>
|
||
<div class="vchips" id="catChips"></div>
|
||
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
|
||
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
|
||
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
|
||
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div></div>
|
||
</div>
|
||
</div>
|
||
<section class="panel" id="sideloaded">
|
||
<div class="shelf-head"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span><span class="spacer"></span>
|
||
<button class="small" id="titleAdd" title="Pick a game's .zip or .exe to add to the Steam library">+ Add a game…</button></div>
|
||
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
|
||
<div class="hint">Your own Linux or Windows builds, added to the Steam library as Devkit Games. Drop a game's
|
||
<code>.zip</code>, folder or <code>.exe</code> anywhere in this window.</div>
|
||
</section>
|
||
|
||
<section id="getgames">
|
||
<div class="shelf-head"><h2 id="steamScopeTitle">Owned, not installed</h2><span class="count" id="gmCount"></span><span class="spacer"></span>
|
||
<span class="sub" id="gmDl"></span></div>
|
||
<div class="panel">
|
||
<div class="cat-tools">
|
||
<div hidden class="seg" id="gmSrc" aria-label="Where to look">
|
||
<button data-src="owned" class="on">Your games</button>
|
||
<button data-src="store">Steam store</button>
|
||
</div>
|
||
<input type="text" id="gmQ" placeholder="Filter your games…">
|
||
<button class="small" id="gmReload" title="Read the library again, e.g. after buying something">Refresh</button>
|
||
</div>
|
||
<div class="vchips" id="gmChips"></div>
|
||
<div class="cat-grid" id="gmGrid"><div class="sub">Loading your library…</div></div>
|
||
<div class="row" style="margin-top:12px"><button class="small" id="gmMore" hidden>Show more</button></div>
|
||
<div class="hint">Install downloads a game you own straight to the Frame and accepts Steam's default options;
|
||
EULAs and license prompts still need answering in the headset. To buy, open the store page in your browser
|
||
(signed in to Steam) or in the headset, then Refresh and install. Ratings are Valve's Steam Frame
|
||
compatibility categories.</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="page" data-page="apps">
|
||
<section id="android">
|
||
<div class="workspace-heading"><div><span class="eyebrow">Made for your Frame</span><h1>Apps</h1><p>VR experiences, Android apps and desktop apps.</p></div><button class="action add-install" data-open-install>+ Add / Install</button></div>
|
||
<div class="scope-tabs" id="appViews" aria-label="App library"><button data-app-view="installed" aria-pressed="true">Installed</button><button data-app-view="browse" aria-pressed="false">Browse</button><button data-app-view="linux" aria-pressed="false">Linux apps</button></div>
|
||
<section class="panel" id="installQueue" hidden>
|
||
<div class="shelf-head"><h2>Install queue</h2><span class="count" id="queueCount"></span></div>
|
||
<div class="list" id="queueList"></div>
|
||
<div class="hint">Games from the store, your Quest and your Meta library install one after another.
|
||
Remove takes a game out before it starts.</div>
|
||
</section>
|
||
<div id="appsInstalled"></div><div id="appsLinux" hidden></div>
|
||
<div class="app-browse-heading" id="appBrowseHeading" hidden><p>Find something for your headset, then install from its app page.</p></div><button class="small" id="repNew" hidden>Report an APK</button><span id="andCount" hidden></span>
|
||
<section id="appStore" aria-label="Android store">
|
||
<div class="store-layout">
|
||
<div class="store-main">
|
||
<div class="store-toolbar"><div class="store-heading" id="storeHeading"><div class="store-eyebrow">Android apps for your Frame</div><h1>Store</h1><p>VR games, emulators, video and reading apps that work in a panel. Free, from open sources.</p></div>
|
||
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form><div id="storeCats" class="store-cats"></div><details class="store-options"><summary>Source & compatibility</summary> <div class="store-side-opts">
|
||
<label class="store-check"><input type="checkbox" id="storeFits"> Only apps that fit the Frame</label>
|
||
<label class="store-check" title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="storePhone"> Show phone-only apps</label>
|
||
<label class="store-source-select"><span>Source</span><select id="sourceFilter"><option value="">All sources</option></select></label>
|
||
<button type="button" id="sourcesOpen" class="small">Manage sources</button>
|
||
</div>
|
||
</details></div>
|
||
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
|
||
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
|
||
<div id="searchGrid" aria-busy="false"></div>
|
||
</div>
|
||
</div>
|
||
<div class="store-owned" id="storeOwned">
|
||
<section class="panel" id="androidBackup">
|
||
<div class="shelf-head"><h2>Import Android game</h2></div>
|
||
<p class="sub">Have a backup? Choose the game's APK and all its .obb files together, or one XAPK/APKS/APKM bundle containing a standalone APK. Split sets and Android/data restore are not supported here.
|
||
Frame Control installs the app and puts its data in the right place.</p>
|
||
<button class="action small" id="androidBackupChoose">Choose game files…</button>
|
||
<input type="file" id="androidBackupInput" accept=".apk,.obb,.xapk,.apks,.apkm" multiple hidden>
|
||
<p class="hint desk-only">In the desktop app, drop the backup folder or its APK. Matching game data in the same folder is found automatically.</p>
|
||
</section>
|
||
<section class="panel" id="quest">
|
||
<div class="shelf-head"><h2>Import from my Quest</h2><span class="count" id="questCount"></span><span class="spacer"></span>
|
||
<button class="small" id="questScan" title="Look for a Meta Quest plugged into this computer by USB">Look for my Quest</button></div>
|
||
<div class="sub" id="questState" role="status" aria-live="polite">Plug your Quest into this computer by USB, put it on, then Look for my Quest.</div>
|
||
<label class="sub" style="display:block;margin-top:8px"><input type="checkbox" id="questData"> Also copy each game's files in Android/data (some games keep saves there)</label>
|
||
<label class="sub" style="display:block;margin-top:4px"><input type="checkbox" id="questOlder"> Allow replacing a newer version on the Frame with the Quest's older one</label>
|
||
<progress id="questProgress" max="100" hidden aria-label="Import progress" style="width:100%;margin-top:8px"></progress>
|
||
<div class="list" id="questApps" style="margin-top:8px"></div>
|
||
<div class="hint">Copies games you own from your own Quest (developer mode on) to the Frame. A game with
|
||
OBB data takes two imports: the first installs it; start it from the Steam library, then import again to
|
||
copy its data in. Frame Control never starts the game itself. Games that
|
||
use Meta's licence check install with a warning: on the Frame that check can't reach Meta, so they may quit,
|
||
hang, or run without online features. Frame Control won't remove that check. <a href="https://github.com/saphid/frame-control/blob/main/docs/quest-import.md" target="_blank" rel="noopener noreferrer">How it works</a></div>
|
||
</section>
|
||
<section class="panel" id="metaLib">
|
||
<div class="shelf-head"><h2>My Meta library</h2><span class="tag">Experimental</span><span class="count" id="metaCount"></span><span class="spacer"></span>
|
||
<button class="small" id="metaRefresh" hidden title="Read your Meta library again">Refresh</button>
|
||
<button class="small" id="metaDisconnect" hidden title="Forget your Meta token on this computer (and sign out of Meta in Frame Control)">Disconnect</button></div>
|
||
<div class="sub" id="metaState" role="status" aria-live="polite">Off. Nothing is sent to Meta until you connect.</div>
|
||
<p class="hint" style="color:var(--warn)">Experimental. This uses Meta's undocumented internal API with your own Meta
|
||
access token. It may stop working whenever Meta changes things, and using it may be against Meta's terms; your
|
||
account is your responsibility. It only lists and downloads games your account owns, installs them as they are,
|
||
and never removes a licence check.</p>
|
||
<div id="metaConnectBox">
|
||
<label class="sub" style="display:block;margin-top:6px"><input type="checkbox" id="metaAck"> I understand, and want to
|
||
use my own Meta token with Frame Control</label>
|
||
<div id="metaSignInRow" hidden style="display:flex;gap:8px;align-items:center;margin-top:6px">
|
||
<button class="small" id="metaSignIn" disabled title="Opens Meta's own login page in a Frame Control window">Sign in with Meta…</button>
|
||
<span class="hint" style="margin:0">or paste your token:</span><span class="spacer"></span>
|
||
<button class="small" id="metaSignOut" title="Clear the Meta sign-in that Frame Control's sign-in window keeps">Sign out of Meta in Frame Control</button></div>
|
||
<div style="display:flex;gap:8px;margin-top:6px">
|
||
<input type="password" id="metaToken" autocomplete="off" spellcheck="false" style="flex:1"
|
||
placeholder="Your Meta access token (starts with OC or FRL)" aria-label="Meta access token" disabled>
|
||
<button class="small" id="metaConnect" disabled>Connect</button></div>
|
||
<div class="hint"><a href="https://github.com/saphid/frame-control/blob/main/docs/meta-library.md#your-token" target="_blank" rel="noopener noreferrer">How to get your token</a>.
|
||
Sign in with Meta (desktop app) opens Meta's login page in its own Frame Control window and takes the token
|
||
from there; it never reads your browser.
|
||
Frame Control checks it with Meta, then keeps it in a private file on this computer (never on the Frame, and
|
||
never in reports). Disconnect deletes it.</div>
|
||
</div>
|
||
<div class="sub" id="metaShare" hidden style="margin-top:6px"></div>
|
||
<label class="sub" id="metaOlderBox" hidden style="display:block;margin-top:4px"><input type="checkbox" id="metaOlder"> Allow replacing a newer version on the Frame</label>
|
||
<progress id="metaProgress" max="100" hidden aria-label="Install progress" style="width:100%;margin-top:8px"></progress>
|
||
<div class="list" id="metaApps" style="margin-top:8px"></div>
|
||
<div class="hint">Install downloads the game from Meta and installs it like Import from my Quest; a game with OBB
|
||
data takes a second Install while it is open. Install and test also starts it, adds its data, watches it for
|
||
about 30 seconds and records whether it ran; Test again does just the test. Results go to the compatibility
|
||
reports, shared only if you share compatibility results. <a href="https://github.com/saphid/frame-control/blob/main/docs/meta-library.md" target="_blank" rel="noopener noreferrer">How it works</a></div>
|
||
</section>
|
||
</div>
|
||
</section>
|
||
|
||
</section>
|
||
</div>
|
||
|
||
<div class="page" data-page="tools">
|
||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||
<div class="grid-3" id="toolWorkspace">
|
||
<section class="panel" id="powerReport">
|
||
<h2>Power & heat report</h2>
|
||
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
|
||
<label for="powerPreset">Observation length</label>
|
||
<select id="powerPreset"><option value="minute">1 minute · 12 samples</option><option value="five-minutes">5 minutes · 60 samples</option></select>
|
||
<div class="row"><button class="action" id="powerStart">Start observation</button><button id="powerStop" disabled>Stop and keep samples</button></div>
|
||
<progress id="powerProgress" max="12" value="0" aria-label="Samples received" hidden></progress>
|
||
<p id="powerStatus" role="status" aria-live="polite">No observation yet.</p>
|
||
<div class="row"><button data-power-format="html" disabled>Download HTML report</button><button data-power-format="json" disabled>Download JSON</button><button data-power-format="csv" disabled>Download CSV</button></div>
|
||
<p id="powerSaveStatus" role="status" aria-live="polite"></p>
|
||
<p class="hint">Download before starting again or quitting: only the latest observation is kept in memory. Downloads during collection are incomplete snapshots. Stop, disconnect or switching headsets keeps received samples, labeled with the original headset. A smoke session blocks sensor reads.</p>
|
||
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
|
||
</section>
|
||
|
||
<section class="panel" id="steamPipewire" hidden>
|
||
<h2>Steam desktop sharing <span class="tag">Linux PC only</span></h2>
|
||
<p>Help for Steam's own <code>steam -pipewire</code> desktop-in-headset flow on Wayland.
|
||
Frame Control's Linux “PC in the headset” feature remains disabled. This helper needs no headset connection.</p>
|
||
<p>Save your games and exit Steam using <b>Steam → Exit</b> before launching.
|
||
This launch does not save a setting or restart an existing Steam session.</p>
|
||
<div class="row"><button id="steamPipewireLaunch" disabled>Launch Steam with PipeWire</button>
|
||
<button id="steamPipewireReset" disabled>Reset screen-share choice…</button></div>
|
||
<p class="sub">Reset opens KDE's Application Permissions so you can revoke a verified Steam screen-sharing
|
||
session yourself. Frame Control does not reset permissions automatically. Other desktops get guidance only.</p>
|
||
<p id="steamPipewireGuidance">KDE System Settings → Applications → Application Permissions → Steam Linux Runtime 3.0
|
||
→ Screen sharing → Manage 1 Session → Revoke. Verify the session belongs to Steam. Never use Revoke All.
|
||
If the entry is missing or ambiguous, stop.</p>
|
||
<p id="steamPipewireStatus" role="status" aria-live="polite"></p>
|
||
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/steam-pipewire.md" target="_blank" rel="noopener">Setup, missing KDE module and limitations →</a></p>
|
||
</section>
|
||
|
||
<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. Battery and temperature are in the battery 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>
|
||
</div>
|
||
<div class="hint">The HUD is ours and uses the Frame’s built-in tools. Place its panel with SteamVR’s Float in World or controller docking controls. It updates every two seconds until closed.</div>
|
||
<h3>Playspace and seated comfort</h3>
|
||
<p class="sub" id="comfortNote">Tracking-origin controls are not available yet: reversible changes still need an idle-headset test. Use SteamVR’s recenter and room setup for now. A seated origin only affects apps that use seated tracking; it cannot force a game into seated mode.</p>
|
||
<div class="hint">For motion comfort, choose snap-turn, teleport movement and a movement vignette in each game’s settings. SteamVR has no verified universal snap-turn or locomotion-vignette switch.</div>
|
||
<details style="margin-top:16px"><summary>Optional VR software</summary>
|
||
<p class="sub">None of Frame Control’s features needs these apps. Compatibility reports and account ownership are separate. Paid apps can only be installed here when already owned.</p>
|
||
<button class="small" id="vrUtilitiesRefresh">Check ownership and reports</button>
|
||
<div class="list" id="vrUtilities"><div class="sub">Load this optional list to check the Frame account.</div></div>
|
||
</details>
|
||
</section><section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||
<div class="shelf-head"><h2>Family and comfort</h2><span class="count">Session timer and reminders</span></div>
|
||
<div id="desktopBatteryComfort" hidden>
|
||
<h3>Desktop low-battery alert</h3>
|
||
<label><input id="desktopBatteryAlert" type="checkbox"> Alert on this computer at 15% or less</label>
|
||
<p class="hint">No session timer needed. Off when this page opens or you switch headsets. Uses normal status refreshes while the page is visible; no extra polling or headset changes. Two readings must confirm low charge and battery drain. One alert until charge recovers to 20%, with at least ten minutes between alerts.</p>
|
||
<p id="desktopBatteryStatus" class="hint" role="status">Off.</p>
|
||
</div>
|
||
<form id="comfortForm">
|
||
<div class="row" style="flex-wrap:wrap;gap:12px">
|
||
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
|
||
<label>Break every <input id="breakMinutes" type="number" min="0" max="120" value="20" required style="width:75px"> minutes</label>
|
||
<label>Check in after <input id="stillMinutes" type="number" min="0" max="240" value="30" required style="width:75px"> active minutes</label>
|
||
</div>
|
||
<div class="row" style="flex-wrap:wrap;margin:14px 0;gap:14px">
|
||
<label><input id="batteryAlert" type="checkbox" checked> Alert on low battery</label>
|
||
<label><input id="heatAlert" type="checkbox" checked> Alert on overheating</label>
|
||
<button class="action small" id="sessionStart" type="submit">Start session</button>
|
||
<button class="small" id="sessionCancel" type="button" disabled>Cancel session</button>
|
||
<button class="small" id="testNotification" type="button">Enable / test notifications</button>
|
||
</div>
|
||
</form>
|
||
<p id="comfortStatus" role="status">Checking session…</p>
|
||
<p id="comfortNotificationStatus" class="hint" role="status" hidden></p>
|
||
<p class="hint">A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.</p>
|
||
<details class="hint"><summary>How sessions and alerts work</summary>
|
||
<p>This is a reminder, not a parental lock. The timer continues on the Frame if you disconnect; a headset restart cancels it. Cancel before changing settings. Set break/check-in to 0 to turn them off.</p>
|
||
<p>Battery, heat and check-in alerts appear on connected companions during a session. Headset warnings and break reminders continue without a companion. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.</p>
|
||
</details>
|
||
<div id="comfortEvents" class="hint"></div>
|
||
<span id="comfortAnnouncement" class="sr-only" aria-live="polite"></span>
|
||
</section><section class="panel" id="pad">
|
||
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
|
||
<button class="small" id="padOn">Turn on</button>
|
||
</div>
|
||
<p class="sub" id="padIntro">Use this computer's keyboard and mouse in the panel you're using in the headset.</p>
|
||
<div id="padBody" hidden>
|
||
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
|
||
<div class="pad-hint" id="padHint"></div>
|
||
</div>
|
||
<div class="pad-keys" aria-label="Keys and clicks">
|
||
<input id="padType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||
<span class="seg pad-clicks">
|
||
<button data-pad-click="singleclick" title="Left click">Click</button>
|
||
<button data-pad-click="rightclick" title="Right click">Right-click</button>
|
||
</span>
|
||
<span class="seg pad-special">
|
||
<button data-pad-key="14" title="Escape">Esc</button>
|
||
<button data-pad-key="2" title="Tab">Tab</button>
|
||
<button data-pad-key="4" title="Left arrow" aria-label="Left arrow">←</button>
|
||
<button data-pad-key="5" title="Up arrow" aria-label="Up arrow">↑</button>
|
||
<button data-pad-key="7" title="Down arrow" aria-label="Down arrow">↓</button>
|
||
<button data-pad-key="6" title="Right arrow" aria-label="Right arrow">→</button>
|
||
<button data-pad-key="1" title="Backspace" aria-label="Backspace">⌫</button>
|
||
<button data-pad-key="12" title="Enter">Enter</button>
|
||
</span>
|
||
</div>
|
||
<div class="hint">Types and points in whichever panel has focus in the headset, through Valve's own input path: nothing to
|
||
install and no internet needed. Accents and emoji go through KDE Connect, which comes with Frame Control and starts
|
||
the first time you type one (it copies Valve's build for the Frame, 3.6 MB, into your home folder there). <a href="#" data-about>Licences</a></div>
|
||
</div>
|
||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||
<div class="row" style="margin-top:8px">
|
||
<button class="action small" id="clipSend">Send text</button>
|
||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||
<button class="small" id="clipFrom">Copy the headset's clipboard here</button>
|
||
</div>
|
||
<div class="hint">Text goes both ways between here and the headset's desktop: copy in an app on the Frame, then
|
||
Copy the headset's clipboard. Needs the desktop panel open in the headset. Text only, up to 1 MB.</div>
|
||
</section><section id="androidSettings"> <h2 class="lib-section">Display and reports</h2>
|
||
<div class="lib-manage">
|
||
<section class="panel disp" id="display">
|
||
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
|
||
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
|
||
<select id="dispSel" aria-label="Lepton instance"><option>Loading…</option></select>
|
||
<div class="hint" id="dispEmpty" hidden>Open an Android app in the headset, then Refresh, to change its
|
||
resolution, UI scale and text size here.</div>
|
||
<div id="dispCtl">
|
||
<div class="stats">
|
||
<div class="stat"><div class="k">Resolution</div><div class="v" id="dispSize">—</div><div class="d" id="dispSizeD"> </div></div>
|
||
<div class="stat"><div class="k">UI scale</div><div class="v" id="dispDens">—</div><div class="d" id="dispDensD"> </div></div>
|
||
<div class="stat"><div class="k">Text size</div><div class="v" id="dispFont">—</div><div class="d" id="dispFontD"> </div></div>
|
||
</div>
|
||
<div class="k2">Resolution</div>
|
||
<div class="seg" id="dispRes" aria-label="Resolution">
|
||
<button data-res="native" title="1920×1080, Lepton's panel size">Native 1920×1080</button>
|
||
<button data-res="sharp" title="More Android pixels in the same panel; UI scale is raised to match">Sharp 2560×1440</button>
|
||
</div>
|
||
<div class="k2">UI scale</div>
|
||
<div class="row">
|
||
<div class="seg" id="dispScale" aria-label="UI scale">
|
||
<button data-scale="0.85">Smaller</button><button data-scale="1">Default</button><button data-scale="1.15">Larger</button>
|
||
</div>
|
||
<input type="number" id="dispDpi" min="120" max="640" step="1" aria-label="Density in dpi" title="Density (dpi), 120–640">
|
||
<button class="small" id="dispDpiSet">Set dpi</button>
|
||
</div>
|
||
<div class="k2">Text size</div>
|
||
<div class="seg" id="dispText" aria-label="Text size">
|
||
<button data-font="0.85">0.85×</button><button data-font="1">1.0×</button><button data-font="1.15">1.15×</button><button data-font="1.3">1.3×</button>
|
||
</div>
|
||
<div class="row" style="margin-top:14px"><button class="small danger" id="dispReset" title="wm size reset, wm density reset, and clear font_scale">Reset all</button></div>
|
||
<div class="hint">Changes apply at once, and the app may redraw or restart its screen. They may not survive the app
|
||
relaunching (not yet verified). Sharp gives Android more pixels in the same panel, which should make text crisper.</div>
|
||
</div>
|
||
</section>
|
||
<div class="panel">
|
||
<div class="shelf-head"><h2>Recent reports</h2><span class="count" id="repCount"></span></div>
|
||
<div class="list" id="repList"><div class="sub">Loading…</div></div>
|
||
<div class="row" style="margin-top:10px"><button class="small" id="repMore" hidden>Show all</button></div>
|
||
<div class="hint" id="repHint">Reports change the verdicts in the catalogue. Any APK can be
|
||
reported, including ones not on F-Droid.</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="panel" id="transfer">
|
||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||
<b><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></b>
|
||
Android backups: choose the <code>.apk</code> and its <code>.obb</code> game data together.
|
||
Other files land in <code>~/Downloads</code>;
|
||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||
<input type="file" id="fileInput" multiple hidden>
|
||
</div>
|
||
<label class="sub" for="apkDisplay">Android display</label>
|
||
<select id="apkDisplay" aria-label="Android app display mode">
|
||
<option value="auto">Automatic (detect VR)</option>
|
||
<option value="flat">Flat screen</option>
|
||
<option value="vr">VR app</option>
|
||
</select>
|
||
<div class="progress" id="prog"><i></i></div>
|
||
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="media">
|
||
<div class="shelf-head"><h2>Media player</h2><span class="count">Preview</span></div>
|
||
<p class="sub">Watch your own films and stereo photos on a big screen in the headset.</p>
|
||
<div class="row"><button class="small" id="mediaChoose">Send media…</button>
|
||
<button class="small" id="mediaRefresh">Refresh</button></div>
|
||
<input type="file" id="mediaInput" accept=".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden>
|
||
<div class="row" style="margin-top:12px"><label for="mediaFile">On the Frame</label>
|
||
<select id="mediaFile"><option value="">Refresh to load your media</option></select></div>
|
||
<div class="row" style="margin-top:8px"><label for="mediaLayout">Layout</label>
|
||
<select id="mediaLayout">
|
||
<option value="auto">Detect from name or metadata</option><option value="mono">2D</option>
|
||
<option value="sbs">Half SBS · left / right</option><option value="ou">Half OU · top / bottom</option>
|
||
<option value="full-sbs">Full SBS · left / right</option><option value="full-ou">Full OU · top / bottom</option>
|
||
</select></div>
|
||
<div class="row" style="margin-top:8px"><label><input type="checkbox" id="mediaTheatre" checked>
|
||
Theatre · bigger screen, dark surround</label></div>
|
||
<div class="row" style="margin-top:12px"><button class="action small" id="mediaPlay">Play</button>
|
||
<button class="small" id="mediaStop">Stop</button></div>
|
||
<p class="sub" id="mediaState" role="status" aria-live="polite">Choose a file to begin.</p>
|
||
<div class="hint">H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
|
||
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
|
||
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
|
||
Stop works from here; playback also ends after four hours.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="apps">
|
||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span><span class="spacer"></span><a href="https://flathub.org" target="_blank" rel="noopener">Browse Flathub ↗</a></div>
|
||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
|
||
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
|
||
<button class="action" id="fpInstall">Install</button>
|
||
</div>
|
||
<div class="row" style="margin-top:8px" id="fpQuick"></div>
|
||
</section>
|
||
|
||
|
||
<section class="panel" id="artworkSettings">
|
||
<div class="shelf-head"><h2>Library artwork</h2></div>
|
||
<p class="sub">SteamGridDB supplies community artwork for sideloaded games. Without a key, Frame Control uses source images and creates the remaining art.</p>
|
||
<label for="steamGridKey">SteamGridDB API key</label>
|
||
<input type="password" id="steamGridKey" autocomplete="off" spellcheck="false" placeholder="Optional API key">
|
||
<div class="row" style="margin-top:8px">
|
||
<button id="saveSteamGridKey" class="action small">Save key</button>
|
||
<button id="clearSteamGridKey" class="small">Remove saved key</button>
|
||
<a href="https://www.steamgriddb.com/profile/preferences/api" target="_blank" rel="noreferrer">Get a free key</a>
|
||
</div>
|
||
<div id="steamGridStatus" class="hint" role="status"></div>
|
||
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
|
||
</section>
|
||
<section class="panel" id="autoSync">
|
||
<div class="shelf-head"><h2>Copy to this computer</h2></div>
|
||
<p class="sub">Copy new screenshots and clips to this computer automatically, about once a minute while a headset is connected.</p>
|
||
<label class="popt"><input type="checkbox" id="syncShots"><b>Screenshots</b>
|
||
<span class="sub">Into <code>~/Pictures/SteamFrame</code>, as Save does.</span></label>
|
||
<label class="popt"><input type="checkbox" id="syncClips"><b>Steam clips</b>
|
||
<span class="sub">Clips you save in Steam, as <code>.mp4</code> files in <code>~/Videos/SteamFrame</code>, once Steam has finished
|
||
writing them. Steam's background recording isn't copied.</span></label>
|
||
<div class="row"><button class="small" id="syncNow">Copy everything now</button>
|
||
<button class="small action" id="syncResume" hidden>Resume</button></div>
|
||
<div class="hint" id="syncStatus" role="status"></div>
|
||
<div class="hint">Only what you take after turning it on is copied; Copy everything now copies the rest, once. A file you
|
||
delete from this computer isn't copied again. It copies from the headset you're connected to, and waits while an
|
||
install, an export or a recording is running.</div>
|
||
</section>
|
||
<section class="panel" id="power">
|
||
<div class="shelf-head"><h2>Remote access</h2></div>
|
||
<div class="actions">
|
||
<button data-open="terminal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-5-6-5M12 19h8"/></svg>SSH</button>
|
||
<button data-open="sftp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4"/></svg>SFTP</button>
|
||
<button data-open="steamlink"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5l12 7-12 7z"/></svg>Steam Link</button>
|
||
<button data-open="rdp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg>Remote desktop</button>
|
||
</div><h3>Device power</h3><div class="actions power-actions">
|
||
<button data-open="suspend" data-confirm="Put the Frame to sleep?"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5z"/></svg>Sleep</button>
|
||
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
||
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
||
</div>
|
||
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
|
||
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
|
||
<div class="row" style="margin-top:14px"><button class="small" data-about>About and licences</button></div>
|
||
<div class="links desk-only">
|
||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" id="panels">
|
||
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
|
||
<button class="small" id="panelsRefresh">Refresh</button></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="storage" hidden>
|
||
<div class="shelf-head"><h2>Game storage</h2><span class="spacer"></span><button class="small" id="storageRefresh">Refresh</button></div>
|
||
<p class="sub">Read-only snapshot. Steam's recorded install sizes may be stale and exclude saves, shaders, downloads, Android and sideloaded content. No files are scanned recursively or changed.</p>
|
||
<div id="storageResult" role="status" aria-live="polite">Open this tool to read headset storage.</div>
|
||
<div class="card" style="margin-top:16px"><h3>Move a game with Steam</h3>
|
||
<p>On the headset, open Steam → Settings → Storage, choose the source drive, select the game, then choose Move and a destination. If Storage is unavailable in the headset UI, use desktop Steam’s Settings → Storage.</p>
|
||
<p class="sub">Steam offers destinations it supports. Let Steam finish and handle any errors, then Refresh here. Frame Control cannot verify a supported move API or its recovery behaviour, so it never moves or deletes game files.</p>
|
||
<a href="https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move" target="_blank" rel="noopener">Steam's storage and moving guide ↗</a>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" id="backup" hidden>
|
||
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
|
||
<button class="small" id="backupFolder">Open backups folder</button></div>
|
||
<p class="sub">Copies game saves, settings and Android app data from the headset to a dated folder on this computer.
|
||
It only reads the headset. Games themselves aren't copied: reinstall them from Steam.</p>
|
||
<div class="chips" id="backupKinds">
|
||
<label><input type="checkbox" value="saves" checked> Game saves</label>
|
||
<label><input type="checkbox" value="settings" checked> Steam and desktop settings</label>
|
||
<label><input type="checkbox" value="android" checked> Android app data</label>
|
||
</div>
|
||
<div class="row" style="margin-top:12px"><button class="small" id="backupPreview">See what's included</button>
|
||
<button class="small action" id="backupStart">Back up now</button></div>
|
||
<div class="sub" id="backupProgress" role="status" aria-live="polite"></div>
|
||
<div class="list" id="backupSummary"></div>
|
||
<div class="hint">Never copied: Steam sign-in files (steam.token, config.vdf, loginusers.vdf), SSH keys,
|
||
keyrings and other passwords. Backups aren't encrypted, and game folders can hold launcher logins, so keep them private.
|
||
Close Android apps first: open ones are skipped.</div>
|
||
|
||
<div class="shelf-head" style="margin-top:20px"><h2>Restore</h2><span class="spacer"></span>
|
||
<button class="small" id="backupRefresh">Refresh</button></div>
|
||
<div class="list" id="backupList"><div class="sub">Open this tool to see your backups.</div></div>
|
||
<div id="restorePlan"></div>
|
||
<div class="hint">After a factory reset, sign in to Steam, reinstall your games and start each once, and reinstall
|
||
your Android apps. Then restore. Parts that can't go back yet are skipped, not failed. Files a restore replaces are
|
||
kept on the headset in ~/.local/share/frame-control/restore-previous.</div>
|
||
|
||
<div class="shelf-head" style="margin-top:20px"><h2>Stuck in Desktop Mode?</h2><span class="spacer"></span>
|
||
<button class="small" id="sessionCheck">Check</button></div>
|
||
<div class="sub" id="sessionState">Check which mode the headset is in.</div>
|
||
<div class="row" style="margin-top:12px"><button class="small" id="sessionGame" disabled>Return to Game Mode</button></div>
|
||
<div class="hint">Uses Valve's supported Game Mode switch for this SteamOS build.
|
||
It closes the desktop and anything open in it. If the headset can't be reached, hold Power for 10 seconds to
|
||
restart it; <a href="https://github.com/saphid/frame-control/blob/main/docs/backup-and-recovery.md#stuck-in-desktop-mode" target="_blank">more ways back</a>.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="macview" hidden>
|
||
<div class="shelf-head"><h2 id="mvTitle">Mac in the headset</h2><span class="tag" id="mvTag" hidden>Experimental</span><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||
<div id="mvPc" hidden>
|
||
<p class="hint" style="color:var(--warn)">Experimental. Shows a whole PC screen as a panel in the
|
||
headset, with laser and keyboard input. Individual windows are offered only when this build supports them.
|
||
Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified. Clicks and keys
|
||
can't reach administrator apps or the sign-in screen.
|
||
<a href="https://github.com/saphid/frame-control/blob/main/docs/pc-in-headset.md" target="_blank" rel="noopener noreferrer">What's verified</a></p>
|
||
<label class="mv-opt"><input type="checkbox" id="mvPcOn"> Turn on PC in the headset
|
||
<span class="sub">off by default; nothing runs and no windows are listed until it's on</span></label>
|
||
<div class="sub" id="mvPcState" role="status" aria-live="polite" hidden></div>
|
||
</div>
|
||
<div id="mvMain">
|
||
<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>
|
||
<div class="list mv-windows" id="mvWindows"><div class="sub">Loading…</div></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="small" data-mv="show" data-src="test" data-title="Test pattern">Test pattern</button>
|
||
<button class="small danger" data-mv="stopall">Stop all</button>
|
||
</div>
|
||
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
|
||
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on your computer's keyboard.</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" id="privacy">
|
||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||
<span class="sub">Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
|
||
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
|
||
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
|
||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
|
||
compatibility database, so the verdicts get better for everyone.</span></label>
|
||
<label class="popt"><input type="checkbox" id="tDiag"><b>Send error details</b>
|
||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||
folder, user name, addresses and keys removed.</span></label>
|
||
<div class="hint" id="tStatus"></div>
|
||
<h3 style="margin-top:22px">Contact email (optional)</h3>
|
||
<form id="contactForm">
|
||
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
|
||
<div class="contact-opts">
|
||
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
|
||
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
|
||
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
|
||
<button type="button" class="small" id="cRemove">Remove my email</button></div>
|
||
</form>
|
||
<div class="hint" id="cStatus">Sent privately to the Frame Control maintainer, never shared or published.
|
||
Updates are occasional release notices; follow-up questions are mainly about problem reports you send.</div>
|
||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||
<a class="sub" href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md" target="_blank">What's collected, exactly</a></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
<div class="page" data-page="settings">
|
||
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
|
||
<div id="settingsDirectory" class="quick-routes"><a id="wirelessDoctorSettings" href="/wireless-doctor" hidden><span>Linux Wireless Adapter doctor</span><small>Read-only checks on this PC</small></a><a href="#devices"><span>Devices & connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display & reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy & updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote & power</span><small>Remote access, sleep and restart</small></a></div>
|
||
<div id="settingsWorkspace" hidden></div>
|
||
</div>
|
||
|
||
<div class="page" data-page="devices">
|
||
<section class="panel" id="mobileConnection" hidden>
|
||
<h1>Headset connection</h1><p id="mobileConnectionStatus" role="status"></p>
|
||
<button class="action" id="mobileConnectionSetup">Change headset…</button>
|
||
<p class="sub" id="mobileConnectionNote"></p>
|
||
</section>
|
||
<div class="dev-grid" id="desktopConnections">
|
||
<section class="panel">
|
||
<div class="shelf-head"><h2>Headsets</h2><span class="count" id="devCount"></span><span class="spacer"></span>
|
||
<button class="action small" id="devAdd">+ Add a headset…</button></div>
|
||
<div class="list" id="devList"><div class="sub">Loading…</div></div>
|
||
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
|
||
it tries them all at once and uses the best one that answers on the network you're on.</div>
|
||
<p id="wirelessDoctorLink" hidden><a href="/wireless-doctor">Linux Wireless Adapter doctor ↗</a><br><span class="sub">Read-only PC checks; works without a connected Frame.</span></p>
|
||
<h3 style="margin-top:22px">This computer's network</h3>
|
||
<div id="netNow" class="sub">Checking…</div>
|
||
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
|
||
<input type="text" id="netName" maxlength="60" placeholder="Name this network, e.g. Home Wi-Fi">
|
||
<button class="small" id="netSave">Save</button></div>
|
||
<div class="hint">Networks are told apart by their router (its IP and hardware address), so this works on wired
|
||
networks and when your computer won't share the Wi-Fi name.</div>
|
||
</section>
|
||
<section class="panel dev-panel" id="devDetail"><div class="sub">Pick a headset.</div></section>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||
<span class="sub">Android backups: drop an APK with its OBB files, or one XAPK/APKS/APKM bundle (standalone APK only).
|
||
Other files go to <code>~/Downloads</code>;
|
||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||
|
||
<div class="drawer" id="drawer"><button class="small" id="closeLog">Close activity log</button><div class="log" id="log"></div></div>
|
||
<div class="bottombar" id="bottombar" role="button" aria-label="Show activity">
|
||
<span class="label">Activity</span>
|
||
<span class="jobs" id="jobs" hidden><span class="spinner"></span><span id="jobsText"></span></span>
|
||
<span class="last" id="lastLog">Ready</span>
|
||
<span class="sub" id="drawerHint">Show ▴</span>
|
||
</div>
|
||
<dialog class="dlg installed-detail" id="installedDetail" aria-labelledby="installedTitle"><button class="store-close" data-close="installedDetail" aria-label="Close app details">×</button><div id="installedDetailBody"></div></dialog>
|
||
<dialog id="appDetail" class="store-dialog" aria-labelledby="detailTitle">
|
||
<button class="store-close" data-close="appDetail" aria-label="Close app details">×</button>
|
||
<div id="detailBody"></div>
|
||
</dialog>
|
||
<dialog id="sourcesDialog" class="store-dialog sources-dialog" aria-labelledby="sourcesTitle">
|
||
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
|
||
<div class="sources-intro"><h1 id="sourcesTitle">Sources</h1><p>Where Discover looks for apps. Turn a source off to hide its apps.</p></div>
|
||
<div id="sourcesList"></div>
|
||
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Add an F-Droid repository by its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
|
||
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
|
||
</dialog>
|
||
|
||
<dialog id="androidImportDlg" aria-labelledby="androidImportTitle" style="background:#1e2329;color:var(--text);border:1px solid var(--muted);border-radius:4px;padding:24px;width:480px;max-width:calc(100vw - 32px)">
|
||
<h2 id="androidImportTitle" style="margin-top:0">Import Android game</h2>
|
||
<p id="androidImportName"></p>
|
||
<div id="androidImportFiles" class="list" style="overflow-wrap:anywhere"></div>
|
||
<p class="sub" id="androidImportNote"></p>
|
||
<p class="sub" id="androidImportWarnings"></p>
|
||
<p id="androidImportState" role="status" aria-live="polite"></p>
|
||
<progress id="androidImportProgress" max="100" hidden style="width:100%" aria-label="Game import progress"></progress>
|
||
<div class="actions">
|
||
<button id="androidImportCancel">Cancel</button>
|
||
<button id="androidImportGo" class="action">Import game</button>
|
||
</div>
|
||
</dialog>
|
||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||
<p id="apkAltReason"></p>
|
||
<div class="list" id="apkAltVersions"></div>
|
||
<p class="sub" id="apkAltNote"></p>
|
||
<div id="apkAltLinks"></div>
|
||
<p class="sub" id="apkAltErrors"></p>
|
||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||
</dialog>
|
||
|
||
<dialog id="modDlg" aria-labelledby="modTitle">
|
||
<h2 id="modTitle">Play in VR with UEVR</h2>
|
||
<p id="modState" class="sub"></p>
|
||
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
|
||
the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
|
||
(about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
|
||
Remove takes them out again.</p>
|
||
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
|
||
UEVR's own menu in the headset has the per-game settings.</p>
|
||
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="modClose">Close</button>
|
||
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
|
||
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
|
||
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
|
||
</dialog>
|
||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||
<h2 id="aboutTitle">About and licences</h2>
|
||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||
licence texts follow, with where to get the source.</p>
|
||
<div id="aboutText" class="about-text">Loading…</div>
|
||
<div class="actions"><button id="aboutClose">Close</button></div>
|
||
</dialog>
|
||
|
||
<dialog id="repDlg" aria-labelledby="repTitle">
|
||
<form method="dialog" id="repForm">
|
||
<h2 id="repTitle">Report an APK</h2>
|
||
<div class="rep-file"><button type="button" class="small" id="repPick">Read details from an .apk file…</button>
|
||
<input type="file" id="repFile" accept=".apk" hidden><span class="sub" id="repFileNote"></span></div>
|
||
<label>App name<input type="text" id="repLabel" placeholder="e.g. AntennaPod" maxlength="120"></label>
|
||
<label>Package<input type="text" id="repPkg" placeholder="e.g. de.danoeh.antennapod" required
|
||
pattern="[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+" maxlength="200" list="repPkgs"></label>
|
||
<datalist id="repPkgs"></datalist>
|
||
<div class="rep-row">
|
||
<label>Version<input type="text" id="repVer" placeholder="e.g. 3.12.2" maxlength="80"></label>
|
||
<label>Where it came from<input type="text" id="repSrc" placeholder="F-Droid, APKMirror, GitHub, own build…" maxlength="300"></label>
|
||
</div>
|
||
<fieldset><legend>Did it work?</legend>
|
||
<label class="opt"><input type="radio" name="repRating" value="works" required> <span class="v-works">Works</span></label>
|
||
<label class="opt"><input type="radio" name="repRating" value="issues"> <span class="v-maybe">Works, with problems</span></label>
|
||
<label class="opt"><input type="radio" name="repRating" value="broken"> <span class="v-no">Doesn't work</span></label>
|
||
</fieldset>
|
||
<fieldset><legend>How did you run it?</legend>
|
||
<label class="opt"><input type="radio" name="repRuntime" value="instance" checked> Own instance (Frame Control)</label>
|
||
<label class="opt"><input type="radio" name="repRuntime" value="lepton-dev"> Lepton Development</label>
|
||
<label class="opt"><input type="radio" name="repRuntime" value="other"> Other</label>
|
||
</fieldset>
|
||
<label>Notes<textarea id="repNotes" maxlength="1000" placeholder="What works, what doesn't, crash messages, settings you changed…"></textarea></label>
|
||
<div class="row rep-actions"><span class="sub" id="repMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="repCancel">Cancel</button>
|
||
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="bugDlg" aria-labelledby="bugTitle">
|
||
<form method="dialog" id="bugForm">
|
||
<h2 id="bugTitle">Report a problem</h2>
|
||
<label class="field">What kind of report?<select id="bugKind">
|
||
<option value="bug">Something's broken</option><option value="idea">An idea</option>
|
||
<option value="question">A question</option><option value="other">Something else</option></select></label>
|
||
<label class="field">Title<input type="text" id="bugTitleIn" maxlength="120" required minlength="5"
|
||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||
<label class="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
|
||
<span class="sub">Optional. Your email address goes with this report only when this is ticked, and is kept as your contact email in Privacy & updates, where you can remove it.</span></label>
|
||
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
|
||
<p class="hint" id="bugReplaces" role="status" hidden></p>
|
||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||
<span class="sub">Frame Control's version, your OS, the Frame's SteamOS build, and a connection summary in fixed words: how connecting went, the kinds of address tried, your ssh version and whether ~/.ssh/config has the headset's entry. No error text, no custom alias name, and never the addresses themselves.</span></label>
|
||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
|
||
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
|
||
<p id="bugWarn">Sent privately to the Frame Control developer. Nothing is published.</p>
|
||
<div class="row rep-actions"><span class="sub" id="bugMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="bugCancel">Cancel</button>
|
||
<button type="button" class="small" id="bugCopy">Copy report</button>
|
||
<button type="submit" class="action small" id="bugSend">Send report</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="titleDlg" aria-labelledby="titleTitle">
|
||
<form method="dialog" id="titleForm">
|
||
<h2 id="titleTitle">Add to the Steam library</h2>
|
||
<div class="sub" id="titleSrc"></div>
|
||
<label>Name<input type="text" id="titleName" maxlength="120" required></label>
|
||
<div class="note" id="titleIdNote"></div>
|
||
<label>Launches<select id="titleExe"></select></label>
|
||
<label>Runtime<select id="titleRt"></select></label>
|
||
<div class="note" id="titleNote"></div>
|
||
<div class="note warn" id="titleWarn"></div>
|
||
<div class="row rep-actions"><span class="sub" id="titleMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="titleCancel">Cancel</button>
|
||
<button type="button" class="small" id="titlePush">Copy to ~/Downloads instead</button>
|
||
<button type="submit" class="action small" id="titleInstall">Install</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="installChooser" class="dlg installed-detail install-chooser" aria-labelledby="installChooserTitle">
|
||
<div class="install-chooser-head"><span class="eyebrow">Add to your Frame</span><button type="button" id="installChooserClose" class="store-close" aria-label="Close Add or install">×</button></div>
|
||
<h2 id="installChooserTitle">What would you like to install?</h2>
|
||
<div class="install-choices"><a href="#appStore"><strong>Browse apps</strong><span>VR, Android games, media and utilities</span></a><a href="#getgames"><strong>Owned Steam games</strong><span>Install straight from your Steam library</span></a><a href="#steamStore"><strong>Find a Steam game</strong><span>Search the store, buy or get, then install</span></a><a href="#linuxApps"><strong>Linux apps</strong><span>Desktop apps from Flathub</span></a><button type="button" id="installChooseFile"><strong>Choose a file</strong><span>Android APK · Windows EXE · game ZIP</span></button><button type="button" id="androidBackupOpen"><strong>Import Android game</strong><span>A SideQuest backup: APK with its OBB game data</span></button></div>
|
||
<input type="file" id="installFileInput" accept=".apk,.zip,.exe" multiple hidden>
|
||
<form id="installLinkForm"><label for="installUrl">Or paste a download or Frame Control install link</label><div class="install-link-row"><input id="installUrl" type="text" inputmode="url" placeholder="https://example.com/game.zip" autocomplete="off" required><button class="action" type="submit">Review link</button></div><p id="installLinkError" class="sub" role="alert" hidden></p></form>
|
||
<p class="sub">Link installs show the title, source and file before you confirm, then use automatic launch and runtime settings. Choose a ZIP or EXE file to pick those settings yourself. Compatibility varies by title.</p>
|
||
</dialog>
|
||
<dialog id="wiDlg" aria-labelledby="wiTitle">
|
||
<h2 id="wiTitle">Install from a website</h2>
|
||
<dl id="wiFacts"></dl>
|
||
<p id="wiWarn">Nothing is downloaded from this link until you click Install.
|
||
Only install software from sites you trust.</p>
|
||
<div class="progress" id="wiProg" hidden><i></i></div>
|
||
<div class="row rep-actions"><span class="sub" id="wiMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="wiCancel">Cancel</button>
|
||
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
|
||
</dialog>
|
||
<dialog id="pwDlg" aria-labelledby="pwTitle">
|
||
<form method="dialog" id="pwForm">
|
||
<h2 id="pwTitle">Restart the Frame?</h2>
|
||
<div class="sub">SteamOS asks for the Developer Mode password for this. It's used once and not saved.</div>
|
||
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Developer Mode password" style="margin-top:12px">
|
||
<div class="row rep-actions"><span class="sub" id="pwMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="pwCancel">Cancel</button>
|
||
<button type="submit" class="action small" id="pwGo">Restart</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
|
||
<h2 id="connTitle">Connection</h2>
|
||
<div class="conn-now"><span class="dot" id="connDot"></span><div class="grow"><b id="connState"></b>
|
||
<div class="sub" id="connWhy"></div></div></div>
|
||
<div class="conn-offer" id="connOffer" hidden></div>
|
||
<h3>Addresses <span class="h3-note">Frame Control tries them all at once; the first in the list wins a tie</span></h3>
|
||
<div class="list" id="connAddrs"></div>
|
||
<form class="addr-add conn-add" id="connAdd">
|
||
<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>
|
||
<p class="sub">Trouble setting up or pairing? <a href="https://github.com/saphid/frame-control/blob/main/docs/frame-doctor.md" target="_blank" rel="noopener noreferrer">Read-only diagnostic guide</a>
|
||
explains how to check a connection without sharing passwords or keys.</p>
|
||
<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>
|
||
<button type="button" class="small desk-only" id="connDevices">All headsets…</button>
|
||
<button type="button" class="small desk-only" id="connSetup" hidden>Set Up Connection…</button>
|
||
<button type="button" class="small" id="connRetryBtn" hidden>Retry now</button>
|
||
<button type="button" class="small" id="connClose" autofocus>Done</button></div>
|
||
</dialog>
|
||
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
|
||
<form method="dialog" id="addDevForm">
|
||
<h2 id="addDevTitle">Add a headset</h2>
|
||
<div class="sub">This opens Set Up Connection in a terminal window. On the headset, first turn on Steam Settings →
|
||
System → Enable Developer Mode, then Developer → Set User Password. Setup finds the headset, adds a key and
|
||
asks for that password once (or for a tap in the headset under Developer → Pair new host).</div>
|
||
<label>SSH alias (how Terminal and the scripts reach it: <code>ssh NAME</code>)<input type="text" id="addAlias" maxlength="64" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"></label>
|
||
<label>Address (optional: an IP or host name; left empty, setup looks for it)<input type="text" id="addHost" maxlength="253" placeholder="e.g. 192.168.1.40 or frame.local"></label>
|
||
<div class="row rep-actions"><span class="sub" id="addMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="addCancel">Cancel</button>
|
||
<button type="submit" class="action small" id="addGo">Set Up…</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="rmDevDlg" class="dlg" aria-labelledby="rmDevTitle">
|
||
<form method="dialog" id="rmDevForm">
|
||
<h2 id="rmDevTitle">Remove this headset?</h2>
|
||
<div class="sub" id="rmDevText"></div>
|
||
<label style="display:flex; gap:8px; align-items:center; color:var(--text)"><input type="checkbox" id="rmDevConfig">
|
||
<span id="rmDevConfigText"></span></label>
|
||
<div class="row rep-actions"><span class="sub" id="rmMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="rmCancel">Cancel</button>
|
||
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="trustDlg" class="dlg" aria-labelledby="trustTitle">
|
||
<form method="dialog" id="trustForm">
|
||
<h2 id="trustTitle">Check the headset's new key</h2>
|
||
<div class="sub" id="trustText"></div>
|
||
<dl class="facts" id="trustKeys"></dl>
|
||
<div class="sub" id="trustHow" hidden>To check it, on the headset open the desktop from the SteamVR dashboard, start
|
||
Konsole and run <code id="trustCmd"></code>. The <code>SHA256:</code> value it prints must match the key above.</div>
|
||
<div class="row rep-actions"><span class="sub" id="trustMsg" role="status"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="trustCancel">Cancel</button>
|
||
<button type="submit" class="small action" id="trustGo" disabled>This is my headset: trust its new key</button></div>
|
||
</form>
|
||
</dialog>
|
||
<div class="toast" id="toast" popover="manual"></div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
// Every /api/ request carries this header. The desktop server takes "1"; the
|
||
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
|
||
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
|
||
const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_stream.Moonlight"],
|
||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||
const HINTS = {
|
||
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
|
||
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
|
||
};
|
||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
|
||
let state = null, stateReceivedAt = null, stateRead = {valid: false, error: null}, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||
|
||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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" : "";
|
||
}
|
||
|
||
function log(msg, kind = "") {
|
||
const line = document.createElement("div");
|
||
line.className = kind;
|
||
line.innerHTML = `<time>${new Date().toLocaleTimeString()}</time> ${esc(msg)}`;
|
||
$("log").prepend(line);
|
||
while ($("log").childElementCount > 200) $("log").lastElementChild.remove();
|
||
$("lastLog").textContent = msg.split("\n")[0];
|
||
$("lastLog").className = "last " + kind;
|
||
}
|
||
let toastTimer;
|
||
function toast(msg, err = false) {
|
||
const el = $("toast");
|
||
el.textContent = msg.split("\n").slice(-1)[0] || msg;
|
||
// 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" : "");
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
|
||
}
|
||
$("bottombar").onclick = () => {
|
||
const open = $("drawer").classList.toggle("open");
|
||
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
||
};
|
||
|
||
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
|
||
const HOST = { computer: "computer" };
|
||
function applyHostWording(h) {
|
||
Object.assign(HOST, h);
|
||
for (const id of ["wirelessDoctorSettings", "wirelessDoctorLink"]) {
|
||
$(id).hidden = !!h.mobile || h.os !== "Linux";
|
||
const link = $(id).tagName === "A" ? $(id) : $(id).querySelector("a");
|
||
link.href = "/wireless-doctor?key=" + encodeURIComponent(UI_KEY);
|
||
}
|
||
window.frameControlCenter?.host(h);
|
||
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
|
||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||
document.body.classList.toggle("mobile", !!h.mobile);
|
||
$("shotsFolder").hidden = !!h.mobile;
|
||
$("recBtn").hidden = $("clips").hidden = !!h.mobile; // recordings are saved to a computer
|
||
$("syncLink").hidden = !!h.mobile; // and so are automatic copies
|
||
$("quest").hidden = !!h.mobile;
|
||
$("metaLib").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame
|
||
if (!h.mobile) $("shotsFolder").textContent = $("videosFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
|
||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||
$("clipFrom").textContent = `Copy the headset's clipboard to this ${h.computer}`;
|
||
$('mobileConnection').hidden = !h.mobile;
|
||
$('desktopConnections').hidden = !!h.mobile;
|
||
if (h.mobile) {
|
||
$('offSetup').textContent = 'Change headset…';
|
||
const setup = window.frameApp?.setUpConnection;
|
||
$('mobileConnectionStatus').textContent = online === false ? 'The Frame is disconnected. Retry or check its address in connection setup.' : 'This page uses the Frame serving this session.';
|
||
$('mobileConnectionSetup').hidden = !setup;
|
||
$('mobileConnectionSetup').onclick = () => window.frameApp?.setUpConnection?.();
|
||
$('mobileConnectionNote').textContent = setup ? 'Connection setup manages the headset address, pairing and SSH access.' : 'To change headsets, open connection setup in the Frame Control app, or open the other Frame’s address in your browser.';
|
||
}
|
||
$("nowMac").hidden = !!h.mobile || h.computer !== "Mac";
|
||
}
|
||
// Images to the phone (the app offers Save Image to Photos, Files and so on).
|
||
function blobBase64(blob) {
|
||
return new Promise((ok, bad) => {
|
||
const r = new FileReader();
|
||
r.onload = () => ok(String(r.result).split(",")[1]);
|
||
r.onerror = () => bad(new Error("couldn't read the image"));
|
||
r.readAsDataURL(blob);
|
||
});
|
||
}
|
||
async function saveToDevice(items) {
|
||
const images = await Promise.all(items.map(async i => ({ name: i.name, data: await blobBase64(i.blob) })));
|
||
return window.frameApp.saveImages(images);
|
||
}
|
||
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
||
|
||
// Bumped when the app moves to another headset: answers to requests made before
|
||
// then are about the old one, so they're dropped rather than shown.
|
||
let devGen = 0;
|
||
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
|
||
const HEADSET_FREE = /^\/api\/(power|devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||
async function api(path, body, device = window.connDevice) {
|
||
const gen = devGen;
|
||
const checkGeneration = () => {
|
||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||
const err = new Error("Switched headsets");
|
||
err.offline = err.stale = true; // leave the panel for the new headset's answer
|
||
throw err;
|
||
}
|
||
};
|
||
// Requests name the headset the page was showing, including GETs that start
|
||
// input helpers, so the server can refuse a stale target before starting work.
|
||
const dev = device ? { "X-Frame-Device": device } : {};
|
||
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY, ...dev} } : {
|
||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY, ...dev}, body: JSON.stringify(body) };
|
||
let r;
|
||
try { r = await fetch(path, opts); }
|
||
catch {
|
||
checkGeneration(); // a late transport failure belongs to the old headset too
|
||
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
|
||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||
}
|
||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||
checkGeneration();
|
||
if (!r.ok) {
|
||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||
err.apk = data.apk;
|
||
err.offline = !!data.offline;
|
||
if (err.offline) setOnline(false, err.message);
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
// What a panel shows when its data couldn't be read: a quiet placeholder while
|
||
// the Frame is unreachable (the banner explains), the real error otherwise.
|
||
function failed(el, e) {
|
||
if (e.stale) return; // about the previous headset: leave the panel to the new one
|
||
el.innerHTML = e.offline ? `<div class="wait">Waiting for the Frame</div>` : `<div class="sub">${esc(e.message)}</div>`;
|
||
}
|
||
async function act(label, fn, btn) {
|
||
if (btn) btn.disabled = true;
|
||
log(label + "…");
|
||
try {
|
||
const res = await fn();
|
||
const msg = (res && res.message) || "Done";
|
||
log(msg, "ok"); toast(msg);
|
||
return res;
|
||
} catch (e) {
|
||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||
} finally { if (btn) btn.disabled = false; }
|
||
}
|
||
|
||
function setConn(ok, text) {
|
||
if (window.connPill && window.connPill()) return; // the connector's pill (below) says more
|
||
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
|
||
}
|
||
|
||
// Whether the Frame answers. Any request that couldn't reach it flips this off;
|
||
// the next successful status check flips it back and reloads what failed.
|
||
let online = null;
|
||
function setOnline(ok, why) {
|
||
const was = online;
|
||
online = ok;
|
||
window.frameControlCenter?.connection(ok);
|
||
renderHome();
|
||
if (HOST.mobile) $('mobileConnectionStatus').textContent = ok ? 'Connected to the Frame serving this session.' : 'The Frame is disconnected. Retry or check its address in connection setup.';
|
||
$('offline').hidden = ok;
|
||
if (!ok) {
|
||
desktopBattery.invalidate();
|
||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||
setConn(false, "Offline");
|
||
$("battChip").hidden = true; closeDevPop();
|
||
$("nowState").textContent = "Waiting for the Frame…";
|
||
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
||
if (window.connBanner) window.connBanner();
|
||
} else if (was === false) {
|
||
log("Connected to the Frame again", "ok");
|
||
reloadAll();
|
||
}
|
||
}
|
||
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); loadClips(); }
|
||
$("offRetry").onclick = () => refresh();
|
||
if (window.frameApp && window.frameApp.setUpConnection) {
|
||
$("offSetup").hidden = false;
|
||
$("offSetup").onclick = () => window.frameApp.setUpConnection();
|
||
}
|
||
$("conn").onclick = () => { if (online === false) refresh(); };
|
||
|
||
let refreshing = null, pollTimer = null;
|
||
function schedule() { // every 30 s while connected, every 8 s while not
|
||
clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(() => { if (document.hidden) schedule(); else refresh(); }, online === false ? 8000 : 30000);
|
||
}
|
||
function refresh() {
|
||
if (refreshing) return refreshing;
|
||
$("refreshAll").disabled = true; $("offRetry").disabled = true;
|
||
refreshing = (async () => {
|
||
try {
|
||
state = await api("/api/status");
|
||
stateReceivedAt = Date.now();
|
||
stateRead = {valid: true, error: null};
|
||
render(state);
|
||
setConn(true, state.hostname);
|
||
setOnline(true);
|
||
} catch (e) {
|
||
desktopBattery.invalidate();
|
||
renderPerformance(null);
|
||
if (!e.stale) stateRead = {valid: false, error: e};
|
||
renderHome();
|
||
window.frameControlCenter?.unavailable(e);
|
||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||
} finally {
|
||
$("refreshAll").disabled = false; $("offRetry").disabled = false;
|
||
refreshing = null; schedule();
|
||
}
|
||
})();
|
||
return refreshing;
|
||
}
|
||
async function refreshAfterAudio() {
|
||
// A request already underway may contain pre-command audio. Preserve normal
|
||
// poll deduplication, but drain it before requesting the command's readback.
|
||
if (refreshing) await refreshing;
|
||
return refresh();
|
||
}
|
||
|
||
// ---- our VR HUD and optional third-party catalogue ----
|
||
function renderPerformance(s) {
|
||
const p = s?.performance || {};
|
||
const values = [["Compositor FPS", p.compositorFps, ""], ["Compositor period", p.frameMs, " ms"],
|
||
["Application FPS", p.appFps, ""], ["Render GPU time", p.gpuMs, " ms"],
|
||
["Compositor CPU", p.compositorCpuMs, " ms"], ["System CPU", p.cpuPercent, "%"],
|
||
["GPU clock", p.gpuMHz, " MHz"]];
|
||
$("vrMetrics").innerHTML = values.map(([label, v, unit]) => `<div class="stat"><div class="k">${label}</div><div class="v">${Number.isFinite(v) ? v.toFixed(1) + unit : "Unavailable"}</div></div>`).join("");
|
||
$("vrUpdated").textContent = s ? `Sampled ${new Date(s.time * 1000).toLocaleTimeString()} · refreshes with status` : "No live sample";
|
||
}
|
||
$("hudStart").onclick = () => act("Open HUD", () => api("/api/vr", {action: "hud-start"}), $("hudStart"));
|
||
$("hudStop").onclick = () => act("Close HUD", () => api("/api/vr", {action: "hud-stop"}), $("hudStop"));
|
||
async function loadUtilities() {
|
||
$("vrUtilitiesRefresh").disabled = true;
|
||
try {
|
||
const data = await api("/api/vr/utilities");
|
||
$("vrUtilities").innerHTML = data.utilities.map(u => `<div class="item">
|
||
<div class="grow"><strong>${esc(u.name)}</strong> · ${esc(u.status)} · ${u.free ? "Free" : "Paid"} · ${u.owned == null ? "Ownership unknown" : u.owned ? "Owned" : "Not in library"}
|
||
<div class="sub">${esc(u.note)} <a href="${esc(u.source)}" target="_blank" rel="noopener">Source</a></div>
|
||
${u.report ? `<div class="sub">Report ${esc(u.report.date || "")}: ${esc(u.report.notes || "")} · SteamOS ${esc(u.report.steamos || "unspecified")}</div>` : ""}</div>
|
||
<button class="small" data-utility="${u.id}" ${u.installed || !u.canInstall ? "disabled" : ""}>${u.installed ? "Installed" : "Install (optional)"}</button></div>`).join("");
|
||
} catch(e) { failed($("vrUtilities"), e); }
|
||
finally { $("vrUtilitiesRefresh").disabled = false; }
|
||
}
|
||
$("vrUtilitiesRefresh").onclick = loadUtilities;
|
||
$("vrUtilities").onclick = async e => {
|
||
const b = e.target.closest("[data-utility]");
|
||
if (!b) return;
|
||
await act("Install optional utility", () => api("/api/steam", {action:"install", appid:b.dataset.utility}), b);
|
||
await loadUtilities();
|
||
};
|
||
|
||
// The Frame owns the clock. Poll independently of the selected tab, from the first status check on.
|
||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false, comfortActive = false;
|
||
async function localNotification(message, request = false) {
|
||
try {
|
||
if (window.frameApp?.notify) await window.frameApp.notify(message, request);
|
||
else {
|
||
if (!("Notification" in window)) throw new Error("This browser has no notifications; use the Frame Control app.");
|
||
const permission = request ? await Notification.requestPermission() : Notification.permission;
|
||
if (permission !== "granted") throw new Error("Notifications are off. Enable them in system settings.");
|
||
new Notification("Frame Control", {body: message});
|
||
}
|
||
$("comfortNotificationStatus").hidden = true;
|
||
} catch (e) {
|
||
$("comfortNotificationStatus").hidden = false;
|
||
$("comfortNotificationStatus").textContent = "Notifications aren't available on this device. Check system notification settings, then use Enable / test notifications. Headset reminders continue during an active session.";
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
function renderComfort(s) {
|
||
const firstSnapshot = !comfortHydrated;
|
||
comfortHydrated = true;
|
||
$("sessionStart").disabled = !!s.active;
|
||
for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"])
|
||
$(id).disabled = !!s.active;
|
||
$("sessionCancel").disabled = !s.active;
|
||
if (s.id !== comfortSession) {
|
||
comfortSession = s.id;
|
||
comfortSeen.clear();
|
||
// A finished session's settings (a one-minute test, say) mustn't replace the defaults.
|
||
if (s.active && s.options) for (const [key, value] of Object.entries(s.options)) {
|
||
const el = $(key === "minutes" ? "sessionMinutes" : key);
|
||
if (!el) continue;
|
||
if (el.type === "checkbox") el.checked = value; else el.value = value;
|
||
}
|
||
}
|
||
// When the running session ends, the form goes back to the page's defaults for the next one.
|
||
if (comfortActive && !s.active) for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"]) {
|
||
const el = $(id);
|
||
if (el.type === "checkbox") el.checked = el.defaultChecked; else el.value = el.defaultValue;
|
||
}
|
||
comfortActive = !!s.active;
|
||
let statusText = s.error || (s.active
|
||
? `${Math.ceil(s.remaining / 60)} min until Steam Home · ${s.activity == null ? "activity unknown" : s.activity === 3 ? "headset in standby" : "monitoring"}`
|
||
: "No session running.");
|
||
if (s.active && s.unavailable?.length)
|
||
statusText += " · No readings: " + s.unavailable.join(", ");
|
||
if ($("comfortStatus").textContent !== statusText) $("comfortStatus").textContent = statusText;
|
||
let latest = null;
|
||
for (const e of s.events || []) {
|
||
if (comfortSeen.has(e.id)) continue;
|
||
comfortSeen.add(e.id);
|
||
// Historical events remain visible but never produce a burst on reconnect.
|
||
if (!firstSnapshot && s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) {
|
||
latest = e.message;
|
||
log(e.message); toast(e.message, e.kind === "error");
|
||
localNotification(e.message).catch(() => {}); // the notification status keeps the failure visible
|
||
}
|
||
}
|
||
// Keep only the server's bounded history; a new page seeds it without replay.
|
||
comfortSeen = new Set((s.events || []).map(e => e.id));
|
||
if (latest !== null) $("comfortAnnouncement").textContent = latest;
|
||
const at = t => new Date(t * 1000).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||
const history = (s.events || []).slice(-3).map(e => `${at(e.time)} ${e.message}`).join(" · ");
|
||
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
|
||
}
|
||
async function pollComfort() {
|
||
// Not while the Frame can't be reached: each poll would be another ssh attempt waiting to
|
||
// time out, and the banner already says why. Status checks bring it back (setOnline).
|
||
if (online === false) {
|
||
const message = "Session status appears when the Frame is connected.";
|
||
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
|
||
} else if (!comfortBusy) {
|
||
comfortBusy = true;
|
||
try { renderComfort(await api("/api/comfort", {action: "status"})); }
|
||
catch (e) {
|
||
const message = "Session status unavailable: " + e.message;
|
||
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
|
||
}
|
||
finally { comfortBusy = false; }
|
||
}
|
||
setTimeout(pollComfort, 5000);
|
||
}
|
||
$("comfortForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const result = await act("Start session", () => api("/api/comfort", {
|
||
action: "start", minutes: Number($("sessionMinutes").value), breakMinutes: Number($("breakMinutes").value),
|
||
stillMinutes: Number($("stillMinutes").value), batteryAlert: $("batteryAlert").checked, heatAlert: $("heatAlert").checked,
|
||
}), $("sessionStart"));
|
||
if (result) renderComfort(result);
|
||
};
|
||
$("sessionCancel").onclick = async () => {
|
||
const result = await act("Cancel session", () => api("/api/comfort", {action: "cancel"}), $("sessionCancel"));
|
||
if (result) renderComfort(result);
|
||
};
|
||
$("testNotification").onclick = () => act("Test notification", async () => {
|
||
await localNotification("Comfort notifications are enabled on this device.", true);
|
||
return {message: "Test notification sent. Check your device's notification settings if it didn't appear."};
|
||
});
|
||
// Show a destination now, not at a later hashchange: focus or fullscreen afterwards must act on
|
||
// a visible page while the click still counts as a user gesture (phones open the keyboard only
|
||
// then). pushState fires no hashchange, so the page is shown once; Back still returns here.
|
||
function showNow(to) {
|
||
if (location.hash !== "#" + to) history.pushState(null, "", "#" + to);
|
||
showPage();
|
||
}
|
||
// Home's shortcuts to the things people come to do next.
|
||
// Type on the headset: Home's button and the T key turn the keyboard on and put the cursor in its field.
|
||
function typeOnHeadset() {
|
||
showNow("pad");
|
||
if (pad.state === "off" || pad.state === "error") padStart();
|
||
$("padType").focus({ preventScroll: true });
|
||
}
|
||
document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => {
|
||
const to = b.dataset.go;
|
||
if (to === "pad") return typeOnHeadset();
|
||
showNow(to);
|
||
});
|
||
$("nowSend").onclick = () => $("fileInput").click();
|
||
$("castBtn").onclick = () => {
|
||
showNow("capture");
|
||
setView("headset");
|
||
if (!live) toggleLive(true);
|
||
$("viewer").scrollIntoView({behavior: "smooth", block: "center"});
|
||
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
|
||
$("viewer").requestFullscreen?.().catch(() => {});
|
||
};
|
||
|
||
// ---- Desktop-only low-battery comfort: consumes existing status, never polls ----
|
||
class DesktopBatteryAlert {
|
||
constructor() {
|
||
this.pending = null;
|
||
this.latched = false;
|
||
this.lastAlert = -Infinity;
|
||
}
|
||
invalidate() { this.pending = null; }
|
||
sample(b, now) {
|
||
// Require actual drain as well as driver state: plugged-in idle can report Discharging.
|
||
const valid = Number.isFinite(b?.percent) && b.percent >= 0 && b.percent <= 100 &&
|
||
["Charging", "Discharging", "Full", "Not charging"].includes(b.status);
|
||
const low = valid && b.percent <= 15 && b.status === "Discharging" &&
|
||
Number.isFinite(b.watts) && b.watts < -0.5;
|
||
const recovered = valid && b.percent >= 20;
|
||
const condition = low ? "low" : recovered ? "recovered" : null;
|
||
if (!condition) { this.invalidate(); return false; }
|
||
if (!this.pending || this.pending.condition !== condition || now - this.pending.at > 90000 || now < this.pending.at) {
|
||
this.pending = {condition, at: now};
|
||
return false;
|
||
}
|
||
if (now - this.pending.at < 20000) return false;
|
||
if (recovered) this.latched = false;
|
||
if (!low || this.latched || now - this.lastAlert < 600000) return false;
|
||
this.latched = true;
|
||
this.lastAlert = now; // latch even when system delivery fails; never retry on each refresh
|
||
return true;
|
||
}
|
||
}
|
||
const desktopBattery = new DesktopBatteryAlert();
|
||
$("desktopBatteryAlert").onchange = () => {
|
||
desktopBattery.invalidate();
|
||
$("desktopBatteryStatus").textContent = $("desktopBatteryAlert").checked
|
||
? "On. Waiting for two fresh status readings confirming low charge and battery drain."
|
||
: "Off.";
|
||
};
|
||
function desktopBatterySample(b) {
|
||
if (!$("desktopBatteryAlert").checked || HOST.mobile || !window.frameApp?.notify || document.hidden ||
|
||
(comfortActive && $("batteryAlert").checked)) {
|
||
desktopBattery.invalidate();
|
||
return;
|
||
}
|
||
if (!desktopBattery.sample(b, performance.now())) return;
|
||
const message = `Frame battery is low (${b.percent}%) and draining. Consider charging or taking a break.`;
|
||
$("desktopBatteryStatus").textContent = message;
|
||
toast(message);
|
||
// Desktop bridge only: never prompt or send a phone/browser push from a status refresh.
|
||
const generation = devGen;
|
||
window.frameApp.notify(message, false).catch(() => {
|
||
if (generation !== devGen || !$("desktopBatteryAlert").checked) return;
|
||
$("desktopBatteryStatus").textContent = message + " System notification delivery was not confirmed. Check system settings or use Enable / test notifications below.";
|
||
});
|
||
}
|
||
document.addEventListener("visibilitychange", () => desktopBattery.invalidate());
|
||
// ---- battery ----
|
||
function battery(b, power) {
|
||
const pct = b?.percent;
|
||
const full = b?.status === "Full";
|
||
const charging = !full && b?.status === "Charging";
|
||
// A weak charger can lose to the load: plugged in but still draining.
|
||
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
|
||
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
|
||
const low = !charging && !full && pct != null && pct <= 15;
|
||
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
||
|
||
let label, cls, detail = [];
|
||
if (!b) { label = "Battery unknown"; cls = "draining"; }
|
||
else if (full) { label = "Fully charged"; cls = "full"; }
|
||
else if (charging) {
|
||
label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging";
|
||
if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`);
|
||
} else if (b.status === "Not charging" || idle) { label = "Not charging"; cls = "draining"; }
|
||
else if (b.status !== "Discharging") { label = "Charging state unavailable"; cls = "draining"; }
|
||
else {
|
||
label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery";
|
||
cls = low ? "low" : "draining";
|
||
if (b?.watts < 0) detail.push(`Using ${Math.abs(b.watts).toFixed(1)} W`);
|
||
if (b?.timeToEmpty > 0 && b.timeToEmpty < 86400) detail.push(`About ${dur(b.timeToEmpty)} left`);
|
||
}
|
||
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
|
||
if (b?.tempC != null) detail.push(`battery ${b.tempC.toFixed(0)}°C`);
|
||
$("battState").textContent = label;
|
||
$("battState").className = "state " + cls;
|
||
$("battDetail").textContent = detail.join(" · ") || " ";
|
||
|
||
const bolt = charging ? `<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden="true"><path d="M6 0L0 8h4l-1 6 7-8H6z" fill="#a4d007"/></svg>` : "";
|
||
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
|
||
$("battChip").innerHTML = pct == null ? "—" :
|
||
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
|
||
$("battChip").title = `${label}. 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);
|
||
renderHome(s);
|
||
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
|
||
battery(s.battery, s.power);
|
||
desktopBatterySample(s.battery);
|
||
$("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;
|
||
if (h) { $("sDisk").textContent = gb(h.free); meter($("mDisk"), 100 * (1 - h.free / h.total), 80, 92); }
|
||
const m = s.memory;
|
||
if (m) { const used = m.total - m.available; $("sMem").textContent = `${gb(used)} / ${gb(m.total)}`; meter($("mMem"), 100 * used / m.total, 75, 90); }
|
||
$("sTemp").textContent = s.temp == null ? "—" : `${s.temp.toFixed(0)}°C`;
|
||
$("dTemp").textContent = s.temp == null ? "" : s.temp > 75 ? "Running hot" : s.temp > 60 ? "Warm" : "Normal";
|
||
$("sWifi").textContent = s.wifi ? `${s.wifi.signal ?? "?"}%` : "—";
|
||
$("dWifi").textContent = s.wifi?.ssid ?? "Not on Wi-Fi";
|
||
$("sUp").textContent = s.uptime ? dur(s.uptime) : "—";
|
||
$("dUp").textContent = "since boot";
|
||
$("sOs").textContent = s.os.version ?? "—";
|
||
$("dOs").textContent = s.os.build ? `build ${s.os.build}` : "";
|
||
|
||
const svc = [["SteamVR", s.services.steamvr], ["Desktop", s.services.desktop],
|
||
["Lepton", s.services.lepton], ["RDP", s.services.rdp]];
|
||
$("nowState").textContent = s.services.steamvr ? "In VR: SteamVR is running." :
|
||
"SteamVR isn't running, so there's no headset view until it starts.";
|
||
$("services").innerHTML = svc.map(([n, on]) => `<span class="chip"><span class="dot ${on ? "on" : ""}"></span>${esc(n)}</span>`).join("");
|
||
|
||
if (s.volume && !volTimer) setVolume(Math.round(s.volume.level * 100), s.volume.muted);
|
||
|
||
const games = s.games.filter(g => /^\d+$/.test(g.appid)); // appid goes into image URLs
|
||
$("gameCount").textContent = `(${games.length})`;
|
||
$("games").innerHTML = games.length ? games.map(g => `
|
||
<div class="capsule" data-appid="${g.appid}">
|
||
<div class="fallback">${esc(g.name)}</div>
|
||
<div class="over">
|
||
<div class="name">${esc(g.name)}</div>
|
||
<div class="size">${gb(g.size)}</div>
|
||
<button class="play small" data-launch="${g.appid}" data-name="${esc(g.name)}">▶ Play</button>
|
||
<button class="small danger" data-force-quit-app="${g.appid}" data-name="${esc(g.name)}" title="Quit it if it has stopped responding">Force quit…</button>
|
||
</div>
|
||
</div>`).join("") : `<div class="sub">No games installed.</div>`;
|
||
// Tall library art, falling back to the wide header, then to the name card.
|
||
$("games").querySelectorAll(".capsule").forEach(c => {
|
||
const id = c.dataset.appid;
|
||
const tryArt = (urls) => {
|
||
if (!urls.length) return;
|
||
const img = new Image();
|
||
img.onload = () => { c.style.backgroundImage = `url("${urls[0]}")`; c.querySelector(".fallback").hidden = true; };
|
||
img.onerror = () => tryArt(urls.slice(1));
|
||
img.src = urls[0];
|
||
};
|
||
tryArt([`${CDN}/${id}/library_600x900.jpg`, `${CDN}/${id}/header.jpg`]);
|
||
});
|
||
|
||
$("flatpaks").innerHTML = s.flatpaks.length ? s.flatpaks.map(f => `
|
||
<div class="item">
|
||
<div class="grow"><div class="t">${esc(f.name)}</div><div class="s">${esc(f.id)} · ${esc(f.version || "")}</div></div>
|
||
<span class="tag">${esc(f.installation)}</span>
|
||
${f.installation === "user" ? `<button class="small danger" data-uninstall="${esc(f.id)}" data-name="${esc(f.name)}">Remove</button>` : ""}
|
||
</div>`).join("") : `<div class="sub">No Flatpak apps.</div>`;
|
||
const have = new Set(s.flatpaks.map(f => f.id));
|
||
$("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id))
|
||
.map(([n, id]) => installing.has(id) ? `<button class="small" disabled>Installing ${esc(n)}…</button>`
|
||
: `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join("");
|
||
}
|
||
|
||
// ---- view ----
|
||
function setView(v) {
|
||
const changed = v !== view;
|
||
view = v;
|
||
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
||
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
|
||
$("eyeSeg").style.display = v === "flat" ? "none" : ""; // the Desktop view has no eyes to pick
|
||
$("viewHint").textContent = HINTS[v];
|
||
$("panelPick").hidden = v !== "flat";
|
||
if (v === "flat") watchPanels(); else clearTimeout(desk.timer);
|
||
if (live && changed) { toggleLive(false); toggleLive(true); }
|
||
ctrlShow();
|
||
}
|
||
function setEye(e) {
|
||
eye = e;
|
||
document.querySelectorAll("[data-eye]").forEach(b => b.classList.toggle("on", b.dataset.eye === e));
|
||
if (lastImg) draw();
|
||
}
|
||
// ---- zoom and pan (1 = fit the whole frame in the viewer) ----
|
||
let zoom = 1, panX = 0, panY = 0;
|
||
function setZoom(z, cx, cy) {
|
||
const v = $("viewer").getBoundingClientRect();
|
||
z = Math.min(8, Math.max(1, z));
|
||
// Keep the point under the cursor (or the centre) fixed while zooming.
|
||
cx ??= v.width / 2; cy ??= v.height / 2;
|
||
panX = cx - (cx - panX) * z / zoom; panY = cy - (cy - panY) * z / zoom;
|
||
zoom = z;
|
||
if (zoom === 1) panX = panY = 0;
|
||
// Don't let the image slide out of view.
|
||
panX = Math.min(0, Math.max(v.width - v.width * zoom, panX));
|
||
panY = Math.min(0, Math.max(v.height - v.height * zoom, panY));
|
||
$("canvas").style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||
$("zoomFit").textContent = zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`;
|
||
$("viewer").classList.toggle("zoomed", zoom > 1);
|
||
}
|
||
$("zoomIn").onclick = () => setZoom(zoom * 1.5);
|
||
$("zoomOut").onclick = () => setZoom(zoom / 1.5);
|
||
$("zoomFit").onclick = () => setZoom(1);
|
||
// Full screen, or on phones (no element full screen there) the viewer filling the window.
|
||
$("fullBtn").onclick = () => {
|
||
if (document.fullscreenElement) return document.exitFullscreen();
|
||
if ($("viewer").requestFullscreen && !TOUCH) return $("viewer").requestFullscreen().catch(() => $("viewer").classList.toggle("immersive"));
|
||
$("viewer").classList.toggle("immersive");
|
||
setZoom(1);
|
||
};
|
||
document.addEventListener("fullscreenchange", () => setZoom(1));
|
||
$("viewer").addEventListener("wheel", e => {
|
||
if ($("canvas").hidden || ctrl.on) return;
|
||
e.preventDefault();
|
||
const v = $("viewer").getBoundingClientRect();
|
||
setZoom(zoom * Math.exp(-e.deltaY * 0.002), e.clientX - v.left, e.clientY - v.top);
|
||
}, { passive: false });
|
||
$("canvas").addEventListener("dblclick", e => {
|
||
if (ctrl.on) return;
|
||
const v = $("viewer").getBoundingClientRect();
|
||
setZoom(zoom > 1 ? 1 : 2.5, e.clientX - v.left, e.clientY - v.top);
|
||
});
|
||
$("canvas").addEventListener("pointerdown", e => {
|
||
if (zoom === 1 || ctrl.on) return;
|
||
const start = { x: e.clientX - panX, y: e.clientY - panY };
|
||
$("viewer").classList.add("panning");
|
||
$("canvas").setPointerCapture(e.pointerId);
|
||
const move = ev => { panX = ev.clientX - start.x; panY = ev.clientY - start.y; setZoom(zoom); };
|
||
const up = () => { $("viewer").classList.remove("panning"); $("canvas").removeEventListener("pointermove", move); };
|
||
$("canvas").addEventListener("pointermove", move);
|
||
$("canvas").addEventListener("pointerup", up, { once: true });
|
||
});
|
||
document.addEventListener("keydown", e => {
|
||
if (page !== "capture" || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
|
||
if (ctrl.on && document.activeElement === $("viewer")) return; // keys are going to the Frame
|
||
if (e.key === "c") return $("ctrlBtn").click();
|
||
if ($("canvas").hidden) return;
|
||
if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5);
|
||
else if (e.key === "-") setZoom(zoom / 1.5);
|
||
else if (e.key === "0") setZoom(1);
|
||
else if (e.key === "f") $("fullBtn").click();
|
||
});
|
||
|
||
function draw() {
|
||
const img = lastImg, c = $("canvas");
|
||
// Side-by-side stereo captures: crop to the left half for "one eye".
|
||
const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
|
||
const crop = sbs && eye === "left";
|
||
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
|
||
if (c.width !== sw || c.height !== sh) {
|
||
c.width = sw; c.height = sh;
|
||
$("viewer").style.aspectRatio = `${sw} / ${sh}`; // one eye is taller than wide
|
||
setZoom(1);
|
||
}
|
||
const ctx = c.getContext("2d");
|
||
ctx.drawImage(img, 0, 0, sw, sh, 0, 0, sw, sh);
|
||
$("asleep").hidden = lastSource !== "steamvr" || !isBlank(ctx, sw, sh);
|
||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false;
|
||
$("srcBadge").hidden = false;
|
||
$("srcBadge").textContent = SOURCE_LABEL[lastSource] + (crop ? " · left eye" : "");
|
||
}
|
||
// A sleeping headset renders a flat near-black frame; sample a grid to spot it.
|
||
function isBlank(ctx, w, h) {
|
||
const px = ctx.getImageData(0, 0, w, h).data;
|
||
let min = 255, max = 0;
|
||
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
|
||
const i = (Math.floor((y + .5) * h / 16) * w + Math.floor((x + .5) * w / 16)) * 4;
|
||
const l = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||
min = Math.min(min, l); max = Math.max(max, l);
|
||
}
|
||
return max - min < 6;
|
||
}
|
||
async function capture() {
|
||
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
|
||
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||
let url = null;
|
||
try {
|
||
let url_ = "/api/screenshot?view=headset";
|
||
let shotPanel = null;
|
||
if (view === "flat") {
|
||
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
|
||
if (!shotPanel) throw new Error(noPanelWhy());
|
||
url_ = `/api/screenshot?view=panel&window=${shotPanel.window}&display=${encodeURIComponent(shotPanel.display)}`;
|
||
}
|
||
const r = await fetch(url_, { headers: {"X-Frame-UI": UI_KEY} });
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||
const source = r.headers.get("X-Capture-Source") || "gamescope";
|
||
url = URL.createObjectURL(await r.blob());
|
||
const img = new Image();
|
||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||
if (gen !== viewGen) return true;
|
||
lastImg = img; lastSource = source; lastShot = null;
|
||
draw();
|
||
desk.shown = source === "panel" ? panelKey(shotPanel) : null;
|
||
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
||
ctrlShow();
|
||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
||
return true;
|
||
} catch (e) {
|
||
log("Capture failed: " + e.message, "e");
|
||
if (!live) toast("Capture failed: " + e.message, true);
|
||
return false;
|
||
} finally {
|
||
if (url) URL.revokeObjectURL(url);
|
||
$("viewer").classList.remove("busy");
|
||
}
|
||
}
|
||
let liveFailures = 0, liveGen = 0;
|
||
async function liveLoop(gen = liveGen) {
|
||
if (!live || gen !== liveGen) return;
|
||
const ok = await capture();
|
||
if (gen !== liveGen) return; // Live was restarted (say, as video) while this capture ran
|
||
liveFailures = ok ? 0 : liveFailures + 1;
|
||
if (liveFailures >= 5) {
|
||
toggleLive(false);
|
||
toast(view === "flat" ? "Live stopped: couldn't capture the panel." : "Live stopped after 5 failed captures. Is the headset on?", true);
|
||
return;
|
||
}
|
||
if (live) liveTimer = setTimeout(() => liveLoop(gen), view === "headset" ? 300 : 700);
|
||
}
|
||
function toggleLive(on) {
|
||
live = on;
|
||
liveGen++;
|
||
liveFailures = 0;
|
||
$("liveBtn").classList.toggle("on", live);
|
||
$("liveBadge").hidden = !live;
|
||
clearTimeout(liveTimer);
|
||
stopVideo();
|
||
if (!live) return;
|
||
if ("VideoDecoder" in window && (view === "headset" || deskPanel())) {
|
||
const started = startVideo();
|
||
const ctl = video.ctl;
|
||
started.catch(e => {
|
||
if (!live || ctl !== video.ctl || e.name === "AbortError") return;
|
||
log("Live video failed: " + e.message, "e");
|
||
toast("Live video failed, using captures instead: " + e.message, true);
|
||
stopVideo();
|
||
liveLoop();
|
||
});
|
||
} else liveLoop();
|
||
}
|
||
|
||
// ---- live video of the headset view ----
|
||
// The server relays raw H.264 (Annex B) from SteamVR's headset-view device.
|
||
// Every frame starts with an access unit delimiter (NAL type 9), which is how
|
||
// the stream is cut into frames for WebCodecs.
|
||
const STREAM_QUERY = "h=720&fps=30", STREAM_FPS = 30;
|
||
const video = { ctl: null, dec: null, gen: 0 };
|
||
function startCode(b, i) { return b[i] === 0 && b[i + 1] === 0 && b[i + 2] === 1; }
|
||
function nalTypes(au) {
|
||
const types = [];
|
||
for (let i = 0; i + 3 < au.length; i++) if (startCode(au, i)) { types.push(au[i + 3] & 0x1f); i += 3; }
|
||
return types;
|
||
}
|
||
async function startVideo() {
|
||
const gen = ++video.gen, ctl = new AbortController();
|
||
video.ctl = ctl;
|
||
$("eyeSeg").style.visibility = "hidden";
|
||
if (!lastImg) $("viewer").classList.add("busy");
|
||
let frames = 0, shown = 0, second = performance.now(), needKey = true, ts = 0;
|
||
const c = $("canvas"), ctx = c.getContext("2d");
|
||
const dec = video.dec = new VideoDecoder({
|
||
output: frame => {
|
||
if (gen !== video.gen) return frame.close();
|
||
if (c.width !== frame.displayWidth || c.height !== frame.displayHeight) {
|
||
c.width = frame.displayWidth; c.height = frame.displayHeight;
|
||
$("viewer").style.aspectRatio = `${c.width} / ${c.height}`;
|
||
setZoom(1);
|
||
}
|
||
ctx.drawImage(frame, 0, 0);
|
||
frame.close();
|
||
if (!shown++) {
|
||
viewGen++; // a capture still in flight mustn't replace the video
|
||
lastImg = null; lastSource = "video"; lastShot = null;
|
||
desk.shown = panelKey(panel); ctrlShow();
|
||
$("viewer").classList.remove("busy");
|
||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
|
||
$("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false;
|
||
}
|
||
frames++;
|
||
const now = performance.now();
|
||
if (now - second >= 1000) {
|
||
$("stamp").textContent = `${Math.round(frames * 1000 / (now - second))} fps`;
|
||
frames = 0; second = now;
|
||
}
|
||
},
|
||
error: e => log("Video decoder: " + e.message, "e"),
|
||
});
|
||
const panel = view === "flat" ? deskPanel() : null;
|
||
desk.shown = null; // until this stream's first picture
|
||
video.label = panel ? `Desktop · ${panel.name || "panel"}` : "Headset view";
|
||
const query = panel ? `src=panel&window=${panel.window}&display=${encodeURIComponent(panel.display)}&${STREAM_QUERY}` : STREAM_QUERY;
|
||
const r = await fetch(`/api/stream?${query}`, { headers: {"X-Frame-UI": UI_KEY}, signal: ctl.signal });
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||
const reader = r.body.getReader();
|
||
let buf = new Uint8Array(0), scan = 0, auStart = -1;
|
||
const onAU = au => {
|
||
const types = nalTypes(au), key = types.includes(5);
|
||
if (dec.state === "unconfigured") {
|
||
const sps = types.indexOf(7);
|
||
if (sps < 0) return;
|
||
let i = 0, n = -1; // find the SPS bytes: profile, constraints, level follow its header
|
||
for (; i + 3 < au.length; i++) if (startCode(au, i) && ++n === sps) break;
|
||
if (i + 6 >= au.length) return;
|
||
const hex = [au[i + 4], au[i + 5], au[i + 6]].map(b => b.toString(16).padStart(2, "0")).join("");
|
||
dec.configure({ codec: `avc1.${hex}`, optimizeForLatency: true });
|
||
}
|
||
// If decoding falls behind, drop frames until the next keyframe rather than lag.
|
||
if (dec.decodeQueueSize > 3) needKey = true;
|
||
if (needKey && !key) return;
|
||
needKey = false;
|
||
dec.decode(new EncodedVideoChunk({ type: key ? "key" : "delta", timestamp: ts, data: au }));
|
||
ts += 1e6 / STREAM_FPS;
|
||
};
|
||
for (;;) {
|
||
const { value, done } = await reader.read();
|
||
if (done || gen !== video.gen) break;
|
||
const next = new Uint8Array(buf.length + value.length);
|
||
next.set(buf); next.set(value, buf.length);
|
||
buf = next;
|
||
if (buf.length > 8 << 20) throw new Error("the stream isn't split into frames");
|
||
for (; scan + 3 < buf.length; scan++) {
|
||
if (!startCode(buf, scan) || (buf[scan + 3] & 0x1f) !== 9) continue;
|
||
if (auStart >= 0) onAU(buf.subarray(auStart, scan));
|
||
auStart = scan;
|
||
scan += 3;
|
||
}
|
||
if (auStart > 0) { buf = buf.slice(auStart); scan -= auStart; auStart = 0; }
|
||
}
|
||
if (gen === video.gen && live) throw new Error(shown ? "the stream ended" : "no video arrived");
|
||
}
|
||
function stopVideo() {
|
||
video.gen++;
|
||
if (video.ctl) video.ctl.abort();
|
||
if (video.dec && video.dec.state !== "closed") video.dec.close();
|
||
video.ctl = video.dec = null;
|
||
$("viewer").classList.remove("busy");
|
||
if (lastSource === "video") { $("srcBadge").textContent = `${video.label || "Headset view"} · video (stopped)`; $("stamp").hidden = true; }
|
||
$("eyeSeg").style.visibility = view === "headset" ? "visible" : "hidden";
|
||
}
|
||
$("shotBtn").onclick = () => capture();
|
||
$("liveBtn").onclick = () => toggleLive(!live);
|
||
$("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : $("canvas").toBlob(b => {
|
||
if (!b) return toast("Couldn't encode the image", true);
|
||
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
|
||
}, "image/png");
|
||
const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob
|
||
: new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"))));
|
||
$("copyBtn").onclick = copyViewer;
|
||
// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame).
|
||
$("canvas").oncontextmenu = e => {
|
||
if (ctrl.on || $("saveBtn").disabled) return;
|
||
e.preventDefault();
|
||
showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]);
|
||
};
|
||
// An image onto the clipboard. The app does it natively (JPEG too); a browser
|
||
// takes PNG only, and the blob is handed over as a promise so the click still counts.
|
||
async function copyImage(blob) {
|
||
if (window.frameApp?.writeImage) {
|
||
if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image");
|
||
return { message: "Copied the image" };
|
||
}
|
||
if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here");
|
||
await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]);
|
||
return { message: "Copied the image" };
|
||
}
|
||
async function pngBlob(blob) {
|
||
if (blob.type === "image/png") return blob;
|
||
const bmp = await createImageBitmap(blob);
|
||
const c = document.createElement("canvas");
|
||
c.width = bmp.width; c.height = bmp.height;
|
||
c.getContext("2d").drawImage(bmp, 0, 0);
|
||
bmp.close();
|
||
return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"));
|
||
}
|
||
function download(blob, name) {
|
||
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
|
||
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
|
||
|
||
// ---- Desktop view: the app panel in use in the headset (or one picked to watch) ----
|
||
// gamescope says which panel has focus: the one the wearer last used. That's the one
|
||
// Control reaches, so the view follows it unless you pick another to watch.
|
||
// Panels are "display/window": window ids are per X display, and the Frame has two.
|
||
// desk.shown is the panel whose picture is on screen now; Control only acts on that one.
|
||
// desk.why is the headset's own reason when it lists no panels; desk.error, why listing them failed.
|
||
const desk = { panels: [], focus: null, pick: "", timer: null, loaded: false, shown: null, why: "", error: "" };
|
||
const panelKey = p => p ? `${p.display}/${p.window}` : null;
|
||
// Why the Desktop view has no panel to show: the headset's own reason when it gave one.
|
||
function noPanelWhy() {
|
||
if (desk.error) return "Couldn't list the headset's panels: " + desk.error;
|
||
return desk.why || "No app panel is open in the headset. Open the desktop or an app in the headset, then try again.";
|
||
}
|
||
function deskPanel() {
|
||
const want = desk.pick || desk.focus;
|
||
// Nothing in use and nothing picked: show the first panel rather than nothing.
|
||
return desk.panels.find(p => panelKey(p) === want) || (!desk.pick && !desk.focus && desk.panels[0]) || null;
|
||
}
|
||
async function loadPanels() {
|
||
try {
|
||
const before = panelKey(deskPanel());
|
||
const r = await api("/api/panels");
|
||
desk.panels = r.panels || []; desk.loaded = true; desk.error = "";
|
||
desk.why = r.why || "";
|
||
desk.focus = r.focus ? `${r.focus_display}/${r.focus}` : null;
|
||
if (desk.pick && !desk.panels.some(p => panelKey(p) === desk.pick)) desk.pick = "";
|
||
renderPanelPick();
|
||
const now = panelKey(deskPanel());
|
||
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
|
||
ctrlShow();
|
||
} catch (e) {
|
||
if (e.stale) return; // an answer about the previous headset says nothing about this one
|
||
// Polled every few seconds: waiting for the Frame, or the same failure again, isn't news.
|
||
if (!e.offline && e.message !== desk.error) log("Couldn't list the headset's panels: " + e.message, "e");
|
||
desk.error = e.message;
|
||
ctrlShow();
|
||
}
|
||
}
|
||
function watchPanels() {
|
||
clearTimeout(desk.timer);
|
||
if (view !== "flat") return;
|
||
loadPanels().finally(() => { if (view === "flat") desk.timer = setTimeout(watchPanels, document.hidden ? 10000 : 3000); });
|
||
}
|
||
function renderPanelPick() {
|
||
const sel = $("panelPick");
|
||
sel.innerHTML = `<option value="">${desk.focus ? "Panel in use" : "Panel in use (none yet)"}</option>` +
|
||
desk.panels.map(p => `<option value="${esc(panelKey(p))}">${esc(p.name || "Untitled")}${p.focused ? " (in use)" : ""}</option>`).join("");
|
||
sel.value = desk.pick;
|
||
}
|
||
$("panelPick").onchange = () => {
|
||
desk.pick = $("panelPick").value;
|
||
ctrlShow();
|
||
if (live) { toggleLive(false); toggleLive(true); } else if (lastImg || lastSource) capture();
|
||
};
|
||
|
||
// ---- Control: tap or click on the view to use the Frame ----
|
||
// Through gamescope's own input (frame_touch.py). On the Desktop view a tap lands
|
||
// exactly where you tapped; on the headset view, which moves with the wearer's head,
|
||
// the view is a trackpad for the pointer instead.
|
||
const ctrl = { on: false, queue: [], sending: false, state: "off", message: "", move: null, rel: [0, 0], raf: 0,
|
||
held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
|
||
const EVDEV = { Escape: 1, Minus: 12, Equal: 13, Backspace: 14, Tab: 15, BracketLeft: 26, BracketRight: 27, Enter: 28,
|
||
ControlLeft: 29, Semicolon: 39, Quote: 40, Backquote: 41, ShiftLeft: 42, Backslash: 43, Comma: 51, Period: 52,
|
||
Slash: 53, ShiftRight: 54, NumpadMultiply: 55, AltLeft: 56, Space: 57, CapsLock: 58, F11: 87, F12: 88,
|
||
IntlBackslash: 86, NumpadEnter: 96, ControlRight: 97, NumpadDivide: 98, AltRight: 100, Home: 102, ArrowUp: 103,
|
||
PageUp: 104, ArrowLeft: 105, ArrowRight: 106, End: 107, ArrowDown: 108, PageDown: 109, Insert: 110, Delete: 111,
|
||
MetaLeft: 125, MetaRight: 126, ContextMenu: 127, NumpadSubtract: 74, NumpadAdd: 78, NumpadDecimal: 83 };
|
||
[..."qwertyuiop"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 16 + i);
|
||
[..."asdfghjkl"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 30 + i);
|
||
[..."zxcvbnm"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 44 + i);
|
||
[..."1234567890"].forEach((c, i) => EVDEV["Digit" + c] = 2 + i);
|
||
for (let i = 1; i <= 10; i++) EVDEV["F" + i] = 58 + i;
|
||
[[7, 71], [8, 72], [9, 73], [4, 75], [5, 76], [6, 77], [1, 79], [2, 80], [3, 81], [0, 82]].forEach(([n, k]) => EVDEV["Numpad" + n] = k);
|
||
const CTRL_BUTTONS = ["left", "middle", "right"]; // MouseEvent.button 0, 1, 2
|
||
|
||
function ctrlMode() { return view === "flat" ? "abs" : "rel"; }
|
||
// Where taps go on the Desktop view, or why they can't.
|
||
function ctrlTarget() {
|
||
if (ctrlMode() === "rel") return { ok: true };
|
||
const p = deskPanel();
|
||
if (!p) return { why: desk.loaded || desk.error ? noPanelWhy() : "Finding the headset's panels…" };
|
||
if (!desk.focus) return { why: "Nobody's using a panel in the headset yet. Taps work once one is in use." };
|
||
if (panelKey(p) !== desk.focus) return { why: "Only the panel in use takes taps. Pick “Panel in use” to control it." };
|
||
if (desk.shown !== desk.focus) return { why: live ? "Waiting for the picture of the panel in use…"
|
||
: "The panel in use has changed. Press Capture or Live to see it first." };
|
||
return { ok: true, panel: p };
|
||
}
|
||
function ctrlShow() {
|
||
const v = $("viewer"), b = $("ctrlBadge");
|
||
$("ctrlBtn").classList.toggle("on", ctrl.on);
|
||
v.classList.toggle("control", ctrl.on);
|
||
v.classList.toggle("rel", ctrl.on && ctrlMode() === "rel");
|
||
$("ctrlKeys").hidden = !ctrl.on;
|
||
b.hidden = !ctrl.on;
|
||
if (!ctrl.on) return;
|
||
const t = ctrlTarget();
|
||
let text = ctrlMode() === "abs" ? `Tap or click to use ${t.panel ? t.panel.name || "the panel" : "the panel"}`
|
||
: "Drag to move the pointer, tap to click, two fingers to scroll";
|
||
let warn = false;
|
||
if (!t.ok) { text = t.why; warn = true; }
|
||
else if (ctrl.state === "error") { text = ctrl.message || "Control isn't working"; warn = true; }
|
||
else if (ctrl.state === "starting") text = "Connecting…";
|
||
b.textContent = text;
|
||
b.classList.toggle("warn", warn);
|
||
}
|
||
function ctrlToggle(on) {
|
||
if (!on) ctrlRelease();
|
||
ctrl.on = on;
|
||
if (on) {
|
||
setZoom(1);
|
||
ctrlPoll(0);
|
||
if (view === "flat" && !desk.loaded) watchPanels();
|
||
if (!TOUCH) $("viewer").focus({ preventScroll: true });
|
||
}
|
||
if (window.frameApp && window.frameApp.captureKeys) window.frameApp.captureKeys(on && document.activeElement === $("viewer"));
|
||
ctrlShow();
|
||
}
|
||
$("ctrlBtn").onclick = () => ctrlToggle(!ctrl.on);
|
||
// Until it's ready, check on the Frame side every half second (for up to 15 s).
|
||
async function ctrlPoll(n, gen = devGen) {
|
||
if (!ctrl.on || gen !== devGen) return;
|
||
try {
|
||
const r = await api(n ? "/api/touch" : "/api/touch?start=1");
|
||
if (gen !== devGen) return;
|
||
ctrl.state = r.state; ctrl.message = r.message || "";
|
||
} catch (e) { if (gen !== devGen) return; ctrl.state = "error"; ctrl.message = e.message; }
|
||
if (!ctrl.on) return; // turned off while connecting
|
||
ctrlShow();
|
||
if (ctrl.state !== "ready" && ctrl.state !== "error" && n < 30) return setTimeout(() => ctrlPoll(n + 1, gen), 500);
|
||
if (ctrl.state === "ready") return ctrlFlush();
|
||
if (ctrl.state !== "error") {
|
||
ctrl.state = "error"; ctrl.message = "The Frame didn't answer. Turn Control off and on to try again.";
|
||
ctrlShow();
|
||
}
|
||
}
|
||
$("viewer").tabIndex = 0;
|
||
$("viewer").addEventListener("focus", () => { if (ctrl.on && window.frameApp?.captureKeys) window.frameApp.captureKeys(true); });
|
||
$("viewer").addEventListener("blur", () => { ctrlRelease(); if (window.frameApp?.captureKeys) window.frameApp.captureKeys(false); });
|
||
document.addEventListener("visibilitychange", () => { if (document.hidden) ctrlRelease(); });
|
||
window.addEventListener("blur", () => ctrlRelease()); // another app took the keyboard
|
||
|
||
function ctrlReset() {
|
||
clearTimeout(ctrl.retry); cancelAnimationFrame(ctrl.raf);
|
||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||
ctrl.on = false; ctrl.queue = []; ctrl.sending = false; ctrl.state = 'off'; ctrl.message = '';
|
||
ctrl.move = null; ctrl.rel = [0,0]; ctrl.raf = 0; ctrl.retryAt = 0;
|
||
ctrl.held.clear(); ctrl.keys.clear(); ctrl.pointers.clear(); ctrl.g = null;
|
||
desk.panels = []; desk.focus = null; desk.shown = null; desk.pick = ''; desk.loaded = false; desk.error = '';
|
||
window.frameApp?.captureKeys?.(false);
|
||
ctrlShow();
|
||
}
|
||
const isMoveEvent = e => ("fx" in e || "dx" in e) && !("button" in e) && !("key" in e);
|
||
const isRelease = e => ("button" in e || "key" in e) && e.down === false;
|
||
// While it connects, keep clicks and keys, each with the last position before it, so a
|
||
// tap still lands where it was made; lone moves are stale by then.
|
||
function ctrlKeepable(events) {
|
||
const out = [];
|
||
let move = null;
|
||
for (const e of events) {
|
||
if (isMoveEvent(e)) { if ("fx" in e) move = e; continue; }
|
||
if (move && "button" in e && e.down !== false) out.push(move);
|
||
move = null;
|
||
out.push(e);
|
||
}
|
||
if (move) out.push(move); // the last position: a click queued after it needs it
|
||
return out;
|
||
}
|
||
function ctrlSend(events) {
|
||
ctrl.queue.push(...events.map(e => isMoveEvent(e) ? e : ctrlAim(e)).filter(Boolean));
|
||
// Keep order; if the Frame is slow to answer, drop pointer moves first, then the oldest
|
||
// presses and scrolls, never a release.
|
||
if (ctrl.queue.length > 300) {
|
||
const q = ctrlKeepable(ctrl.queue), keep = new Set();
|
||
q.forEach((e, i) => { if (isRelease(e) || i >= q.length - 200) keep.add(i); });
|
||
// A kept press keeps the position before it, so it lands where it was made.
|
||
for (const i of [...keep]) if (i > 0 && "button" in q[i] && q[i].down !== false && "fx" in q[i - 1]) keep.add(i - 1);
|
||
ctrl.queue = q.filter((e, i) => keep.has(i));
|
||
}
|
||
ctrlFlush();
|
||
}
|
||
async function ctrlFlush() {
|
||
if (ctrl.sending || !ctrl.queue.length) return;
|
||
// Backing off after a failure: new input waits for the same retry.
|
||
const wait = (ctrl.retryAt || 0) - Date.now();
|
||
if (wait > 0) {
|
||
clearTimeout(ctrl.retry);
|
||
ctrl.retry = setTimeout(ctrlFlush, wait);
|
||
return;
|
||
}
|
||
ctrl.sending = true;
|
||
const gen = devGen;
|
||
// Up to 200 events, and at most about 100 characters of text: the Frame types a batch
|
||
// key by key before reading the next, and a release mustn't wait behind a long paste.
|
||
let n = 0, chars = 0;
|
||
while (n < ctrl.queue.length && n < 200 && (n === 0 || chars + (ctrl.queue[n].text?.length || 0) <= 100)) {
|
||
chars += ctrl.queue[n].text?.length || 0;
|
||
n++;
|
||
}
|
||
const batch = ctrl.queue.splice(0, n);
|
||
let again = 0;
|
||
try {
|
||
const r = await api("/api/touch", { events: batch });
|
||
if (gen !== devGen) return;
|
||
ctrl.state = r.state; ctrl.message = r.message || "";
|
||
if (r.stale && typeof loadPanels === "function") loadPanels(); // focus moved on: catch up now
|
||
if (!r.sent && r.state === "error") {
|
||
// Broken: presses would be stale by the time it's back; releases still matter.
|
||
ctrl.queue.unshift(...batch.filter(isRelease));
|
||
again = 2000;
|
||
} else if (!r.sent) {
|
||
ctrl.queue.unshift(...ctrlKeepable(batch)); // connecting: send them once it's ready
|
||
again = 300;
|
||
}
|
||
} catch (e) {
|
||
if (gen !== devGen) return;
|
||
ctrl.state = "error"; ctrl.message = e.message;
|
||
ctrl.queue.unshift(...batch.filter(isRelease));
|
||
again = 1000; // try the releases again in a moment, not in a tight loop
|
||
} finally {
|
||
if (gen !== devGen) return;
|
||
ctrl.sending = false;
|
||
ctrlShow();
|
||
clearTimeout(ctrl.retry);
|
||
ctrl.retryAt = again ? Date.now() + again : 0;
|
||
if (again) { if (ctrl.queue.length) ctrl.retry = setTimeout(ctrlFlush, again); }
|
||
else if (ctrl.queue.length) ctrlFlush();
|
||
}
|
||
}
|
||
// Pointer moves: at most one per animation frame.
|
||
function ctrlMoveTo(fx, fy) {
|
||
const p = ctrlTarget().panel;
|
||
if (!p) return;
|
||
ctrl.move = { fx, fy, window: p.window, display: p.display };
|
||
ctrlSchedule();
|
||
}
|
||
function ctrlMoveBy(dx, dy) { ctrl.rel[0] += dx; ctrl.rel[1] += dy; ctrlSchedule(); }
|
||
function ctrlSchedule() { if (!ctrl.raf) ctrl.raf = requestAnimationFrame(ctrlFlushMoves); }
|
||
function ctrlFlushMoves() {
|
||
ctrl.raf = 0;
|
||
const out = [];
|
||
if (ctrl.move) { out.push(ctrl.move); ctrl.move = null; }
|
||
if (ctrl.rel[0] || ctrl.rel[1]) { out.push({ dx: Math.round(ctrl.rel[0]), dy: Math.round(ctrl.rel[1]) }); ctrl.rel = [0, 0]; }
|
||
if (out.length) ctrlSend(out);
|
||
}
|
||
// On the Desktop view, presses name the panel they're for, so the Frame drops them if
|
||
// focus has moved on (releases always go).
|
||
// A press with no panel to aim at (the Desktop view lost its target) isn't sent at all.
|
||
function ctrlAim(e) {
|
||
if (ctrlMode() !== "abs" || e.down === false) return e;
|
||
const p = ctrlTarget().panel;
|
||
return p ? { ...e, window: p.window, display: p.display } : null;
|
||
}
|
||
function ctrlButton(name, down) {
|
||
ctrlFlushMoves(); // the click lands where the pointer was sent
|
||
if (down) ctrl.held.add(name); else ctrl.held.delete(name);
|
||
ctrlSend([ctrlAim({ button: name, down })]);
|
||
}
|
||
function ctrlKeyEvent(code, down) {
|
||
ctrlFlushMoves();
|
||
if (down) ctrl.keys.add(code); else ctrl.keys.delete(code);
|
||
ctrlSend([ctrlAim({ key: code, down })]);
|
||
}
|
||
function ctrlClick(name = "left") { ctrlButton(name, true); ctrlButton(name, false); }
|
||
function ctrlRelease() {
|
||
for (const b of [...ctrl.held]) ctrlButton(b, false);
|
||
for (const k of [...ctrl.keys]) ctrlKeyEvent(k, false);
|
||
if (ctrl.g && ctrl.g.timer) clearTimeout(ctrl.g.timer);
|
||
ctrl.pointers.clear(); ctrl.g = null;
|
||
}
|
||
// Where a pointer event falls on the panel, as fractions (the canvas box includes any letterboxing).
|
||
function ctrlFraction(e) {
|
||
const c = $("canvas"), r = c.getBoundingClientRect();
|
||
const scale = Math.min(r.width / c.width, r.height / c.height);
|
||
const w = c.width * scale, h = c.height * scale;
|
||
const x = (e.clientX - r.left - (r.width - w) / 2) / w, y = (e.clientY - r.top - (r.height - h) / 2) / h;
|
||
return x >= 0 && x <= 1 && y >= 0 && y <= 1 ? [x, y] : null;
|
||
}
|
||
|
||
// Mouse (and pen) on a computer: the view is the Frame's screen.
|
||
function ctrlMouse(e) {
|
||
if (ctrlMode() === "abs") {
|
||
const f = ctrlFraction(e);
|
||
if (f) ctrlMoveTo(...f);
|
||
} else if (e.type === "pointermove" && (e.movementX || e.movementY)) {
|
||
ctrlMoveBy(e.movementX * 1.5, e.movementY * 1.5);
|
||
}
|
||
}
|
||
// Touch: tap to click, press and hold to right-click, two fingers to scroll. Dragging is a
|
||
// mouse drag on the Desktop view (sliders, selecting text) and moves the pointer on the headset view.
|
||
const TAP_MOVE = 8, HOLD_MS = 550, TOUCH_SPEED = 1.6, SCROLL_SPEED = 2;
|
||
function ctrlTouchDown(e) {
|
||
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (ctrl.pointers.size === 1) {
|
||
const g = ctrl.g = { kind: "maybe", x: e.clientX, y: e.clientY, e, target: ctrlAimedAt() };
|
||
g.timer = setTimeout(() => {
|
||
if (ctrl.g !== g || g.kind !== "maybe" || !ctrlSameTarget(g)) return;
|
||
g.kind = "held";
|
||
if (ctrlMode() === "abs") {
|
||
const f = ctrlFraction(g.e);
|
||
if (!f) return; // on the bars around the picture: nothing to press
|
||
ctrlMoveTo(...f);
|
||
}
|
||
ctrlClick("right");
|
||
navigator.vibrate?.(10);
|
||
}, HOLD_MS);
|
||
} else if (ctrl.pointers.size === 2) {
|
||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||
if (ctrl.held.has("left")) ctrlButton("left", false);
|
||
ctrl.g = { kind: "scroll", ...centroid() };
|
||
}
|
||
}
|
||
// The panel a gesture started on; if that stops being the one in use, the gesture
|
||
// does nothing more (except let go of what it holds).
|
||
function ctrlAimedAt() { return ctrlMode() === "abs" ? panelKey(ctrlTarget().panel) : "headset"; }
|
||
function ctrlSameTarget(g) { return g.target && g.target === ctrlAimedAt(); }
|
||
function centroid() {
|
||
const ps = [...ctrl.pointers.values()];
|
||
return { x: ps.reduce((a, p) => a + p.x, 0) / ps.length, y: ps.reduce((a, p) => a + p.y, 0) / ps.length };
|
||
}
|
||
function ctrlTouchMove(e) {
|
||
const prev = ctrl.pointers.get(e.pointerId);
|
||
if (!prev || !ctrl.g) return;
|
||
const g = ctrl.g, dx = e.clientX - prev.x, dy = e.clientY - prev.y;
|
||
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (g.kind === "scroll") {
|
||
const c = centroid();
|
||
// Content follows the fingers, as on the phone itself.
|
||
ctrlSend([{ scroll: [-(c.x - g.x) * SCROLL_SPEED, -(c.y - g.y) * SCROLL_SPEED] }]);
|
||
g.x = c.x; g.y = c.y;
|
||
return;
|
||
}
|
||
if (g.kind === "maybe" && Math.hypot(e.clientX - g.x, e.clientY - g.y) > TAP_MOVE) {
|
||
clearTimeout(g.timer);
|
||
if (!ctrlSameTarget(g)) { g.kind = "done"; return; }
|
||
if (ctrlMode() === "abs") {
|
||
const f = ctrlFraction(g.e);
|
||
if (f) { ctrlMoveTo(...f); ctrlButton("left", true); }
|
||
g.kind = "drag";
|
||
} else g.kind = "move";
|
||
}
|
||
if (g.kind === "drag") { const f = ctrlFraction(e); if (f) ctrlMoveTo(...f); }
|
||
else if (g.kind === "move") ctrlMoveBy(dx * TOUCH_SPEED, dy * TOUCH_SPEED);
|
||
}
|
||
function ctrlTouchUp(e) {
|
||
const g = ctrl.g;
|
||
ctrl.pointers.delete(e.pointerId);
|
||
if (!g) return;
|
||
if (g.kind === "scroll") g.kind = "done"; // the finger left behind doesn't jump the scroll or drag
|
||
if (g.kind === "maybe" && !ctrlSameTarget(g)) g.kind = "done";
|
||
if (g.kind === "maybe" && ctrl.pointers.size === 0) {
|
||
clearTimeout(g.timer);
|
||
if (ctrlMode() === "abs") { const f = ctrlFraction(g.e); if (!f) { ctrl.g = null; return; } ctrlMoveTo(...f); }
|
||
ctrlClick("left");
|
||
} else if (g.kind === "drag") ctrlButton("left", false);
|
||
if (ctrl.pointers.size === 0) ctrl.g = null;
|
||
}
|
||
// The system took the touch (a swipe home, a call): no tap, and let go of anything held.
|
||
function ctrlTouchCancel(e) {
|
||
ctrl.pointers.delete(e.pointerId);
|
||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||
if (ctrl.held.has("left")) ctrlButton("left", false);
|
||
if (ctrl.g) ctrl.g.kind = "done";
|
||
if (ctrl.pointers.size === 0) ctrl.g = null;
|
||
}
|
||
const canvasEl = $("canvas");
|
||
canvasEl.addEventListener("pointerdown", e => {
|
||
if (!ctrl.on) return;
|
||
e.preventDefault();
|
||
$("viewer").focus({ preventScroll: true });
|
||
try { canvasEl.setPointerCapture(e.pointerId); } catch { /* a pointer that's already gone */ }
|
||
if (!ctrlTarget().ok) return ctrlShow();
|
||
if (e.pointerType === "touch") return ctrlTouchDown(e);
|
||
if (ctrlMode() === "abs" && !ctrlFraction(e)) return; // on the bars around the picture
|
||
ctrlMouse(e);
|
||
ctrlButton(CTRL_BUTTONS[e.button] || "left", true);
|
||
});
|
||
canvasEl.addEventListener("pointermove", e => {
|
||
if (!ctrl.on || !ctrlTarget().ok) return;
|
||
if (e.pointerType === "touch") return ctrlTouchMove(e);
|
||
ctrlMouse(e);
|
||
});
|
||
for (const type of ["pointerup", "pointercancel"]) canvasEl.addEventListener(type, e => {
|
||
if (!ctrl.on) return;
|
||
if (e.pointerType === "touch") return type === "pointercancel" ? ctrlTouchCancel(e) : ctrlTouchUp(e);
|
||
if (type === "pointercancel") return [...ctrl.held].forEach(b => ctrlButton(b, false)); // no button is named
|
||
const name = CTRL_BUTTONS[e.button] || "left";
|
||
if (ctrl.held.has(name)) ctrlButton(name, false);
|
||
});
|
||
canvasEl.addEventListener("contextmenu", e => { if (ctrl.on) e.preventDefault(); });
|
||
$("viewer").addEventListener("wheel", e => {
|
||
if (!ctrl.on || !ctrlTarget().ok) return;
|
||
e.preventDefault();
|
||
const unit = e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 800 : 1;
|
||
ctrlSend([{ scroll: [e.deltaX * unit, e.deltaY * unit] }]);
|
||
}, { passive: false });
|
||
// Keys while the view has focus go to the Frame (⌘ as Ctrl on a Mac); click elsewhere to stop.
|
||
function ctrlKey(e, down) {
|
||
if (!ctrl.on || document.activeElement !== $("viewer") || (down && !ctrlTarget().ok)) return;
|
||
let code = EVDEV[e.code];
|
||
if (IS_MAC && (e.code === "MetaLeft" || e.code === "MetaRight")) code = 29;
|
||
if (!code) return;
|
||
e.preventDefault();
|
||
e.stopPropagation(); // it's for the Frame, not Frame Control's own shortcuts
|
||
if (down && e.repeat && [29, 42, 54, 56, 97, 100, 125, 126].includes(code)) return;
|
||
if (!down && !ctrl.keys.has(code)) return;
|
||
ctrlKeyEvent(code, down);
|
||
}
|
||
$("viewer").addEventListener("keydown", e => ctrlKey(e, true));
|
||
$("viewer").addEventListener("keyup", e => ctrlKey(e, false));
|
||
|
||
// Phone keyboard: the field types plain text; the buttons send single keys and clicks.
|
||
function ctrlTap(code) { ctrlKeyEvent(code, true); ctrlKeyEvent(code, false); }
|
||
let ctrlWarned = false;
|
||
function ctrlText(text) {
|
||
const plain = text.replace(/[^\x20-\x7e\n\t]/g, "");
|
||
if (plain !== text && !ctrlWarned) {
|
||
ctrlWarned = true;
|
||
toast("Control types plain letters, numbers and symbols. For accents and emoji, use Keyboard and trackpad below.");
|
||
}
|
||
// Small pieces: the Frame types them key by key, and a release queued behind mustn't wait long.
|
||
for (const part of plain.match(/[\s\S]{1,100}/g) || []) ctrlSend([{ text: part }]);
|
||
}
|
||
$("ctrlType").addEventListener("beforeinput", e => {
|
||
if (e.isComposing) return;
|
||
e.preventDefault();
|
||
if (!ctrlTarget().ok) return ctrlShow();
|
||
if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") return ctrlTap(28);
|
||
if (e.inputType === "deleteContentBackward") return ctrlTap(14);
|
||
if (e.inputType === "deleteContentForward") return ctrlTap(111);
|
||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||
if (text) ctrlText(text);
|
||
});
|
||
$("ctrlType").addEventListener("compositionend", e => { if (e.data) ctrlText(e.data); $("ctrlType").value = ""; });
|
||
$("ctrlKeys").addEventListener("click", e => {
|
||
const k = e.target.closest("[data-ctrl-key]"), c = e.target.closest("[data-ctrl-click]");
|
||
if (!ctrlTarget().ok) return ctrlShow();
|
||
if (k) ctrlTap(+k.dataset.ctrlKey);
|
||
if (c) ctrlClick(c.dataset.ctrlClick);
|
||
});
|
||
|
||
// ---- controls ----
|
||
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); loadRunning().catch(() => {}); }; // titles too: the Frame may have been asleep at start
|
||
document.addEventListener("keydown", e => {
|
||
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
|
||
});
|
||
document.body.addEventListener("click", async (e) => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
if (b.dataset.launch) act(`Launch ${b.dataset.name}`, () => api("/api/launch", { appid: b.dataset.launch }), b);
|
||
else if (b.dataset.forceQuitApp) forceQuitFrom(g => String(g.appid) === b.dataset.forceQuitApp, b.dataset.name, b);
|
||
else if (b.dataset.uninstall) {
|
||
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
|
||
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
|
||
} else if (b.dataset.quick) installFlatpak(b.dataset.quick);
|
||
else if (b.dataset.open) {
|
||
const what = b.dataset.open, label = b.textContent.trim();
|
||
// On the phone, power needs the password here, and the rest opens other apps.
|
||
if (HOST.mobile && POWER[what]) return askPower(what);
|
||
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
|
||
if (HOST.mobile && window.frameApp?.open) return act(label, () => window.frameApp.open(what), b);
|
||
act(label, () => api("/api/open", { what }), b);
|
||
}
|
||
});
|
||
// Installs run as server jobs: start one, then poll until it's done. The
|
||
// bottom bar counts what's running; `installing` keeps re-rendered lists from
|
||
// offering Install again for something already on its way.
|
||
// Android installs wait their turn in the server's install queue (queue_job in
|
||
// server.py); `queued` holds the ones still waiting.
|
||
const installing = new Set(), queued = new Set();
|
||
let jobsRunning = 0;
|
||
function showJobs() {
|
||
const waiting = queued.size, running = jobsRunning - waiting;
|
||
$("jobs").hidden = !jobsRunning;
|
||
$("jobsText").textContent = [running ? `${running} install${running === 1 ? "" : "s"} running` : "",
|
||
waiting ? `${waiting} queued` : ""].filter(Boolean).join(", ");
|
||
renderHome();
|
||
}
|
||
async function runJob(label, key, start, onProgress = () => {}) {
|
||
installing.add(key); jobsRunning++; showJobs();
|
||
try {
|
||
const started = await start();
|
||
const { job } = started;
|
||
log(started.queued ? started.message : label + "…");
|
||
if (started.queued) { queued.add(key); showJobs(); }
|
||
if (started.queued != null) loadQueue();
|
||
for (let misses = 0; ;) {
|
||
await new Promise(ok => setTimeout(ok, 1500));
|
||
let j;
|
||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||
if (j.queued !== queued.has(key)) { j.queued ? queued.add(key) : queued.delete(key); showJobs(); }
|
||
onProgress(j);
|
||
if (!j.done) continue;
|
||
if (j.cancelled) { log(`${label}: removed from the install queue`); return null; }
|
||
if (j.error) throw new Error(j.error);
|
||
log(j.message, "ok"); toast(j.message);
|
||
return j.result || {};
|
||
}
|
||
} catch (e) {
|
||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||
} finally { installing.delete(key); queued.delete(key); jobsRunning--; showJobs(); }
|
||
}
|
||
// The install queue as the server has it: also what an earlier visit to this page queued.
|
||
let queueTimer;
|
||
async function loadQueue() {
|
||
clearTimeout(queueTimer);
|
||
let jobs;
|
||
try { ({ jobs } = await api("/api/queue")); } catch { jobs = null; }
|
||
if (jobs) {
|
||
$("installQueue").hidden = !jobs.length;
|
||
$("queueCount").textContent = jobs.length ? `${jobs.length}` : "";
|
||
$("queueList").innerHTML = jobs.map(j => `<div class="item"><div class="grow"><div class="t">${esc(j.label)}</div>
|
||
<div class="s">${esc(j.queued ? j.stage || "Queued" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`)}</div></div>
|
||
${j.queued ? `<button class="small" data-unqueue="${esc(j.job)}">Remove</button>` : `<span class="tag">Installing</span>`}</div>`).join("");
|
||
}
|
||
if (!jobs || jobs.length) queueTimer = setTimeout(loadQueue, 2000);
|
||
}
|
||
$("queueList").addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-unqueue]");
|
||
if (!b) return;
|
||
b.disabled = true;
|
||
try { const d = await api("/api/queue", { action: "remove", job: b.dataset.unqueue }); toast(d.message); }
|
||
catch (err) { toast(err.message, true); }
|
||
loadQueue();
|
||
});
|
||
loadQueue();
|
||
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
|
||
poweroff: ["Shut the Frame down?", "Shut down"] };
|
||
function askPower(what) {
|
||
const [title, verb] = POWER[what], dlg = $("pwDlg");
|
||
$("pwTitle").textContent = title; $("pwGo").textContent = verb;
|
||
$("pwInput").value = ""; $("pwMsg").textContent = "";
|
||
$("pwCancel").onclick = () => dlg.close();
|
||
$("pwForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const password = $("pwInput").value;
|
||
if (!password) return ($("pwMsg").textContent = "Enter the password");
|
||
$("pwGo").disabled = true; $("pwMsg").textContent = "";
|
||
try {
|
||
const res = await api("/api/open", { what, password });
|
||
dlg.close(); log(res.message, "ok"); toast(res.message);
|
||
} catch (err) { $("pwMsg").textContent = err.message; }
|
||
finally { $("pwGo").disabled = false; $("pwInput").value = ""; }
|
||
};
|
||
dlg.showModal();
|
||
$("pwInput").focus();
|
||
}
|
||
$("changeHeadset").onclick = () => window.frameApp?.setUpConnection && window.frameApp.setUpConnection();
|
||
|
||
function installFlatpak(id) {
|
||
if (installing.has(id)) return;
|
||
toast(`Installing ${id}. This can take a few minutes…`);
|
||
const done = runJob(`Install ${id}`, id, () => api("/api/flatpak", { action: "install", id })).then(refresh);
|
||
if (state) render(state); // show it as installing
|
||
return done;
|
||
}
|
||
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) { installFlatpak(id); $("fpId").value = ""; } };
|
||
$("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); });
|
||
|
||
function setVolume(pct, muted) {
|
||
$("vol").value = pct;
|
||
$("vol").style.setProperty("--fill", pct + "%");
|
||
$("volNum").textContent = pct + "%";
|
||
if (muted !== undefined) $("volWaves").style.display = muted ? "none" : "";
|
||
}
|
||
$("vol").oninput = () => {
|
||
setVolume(+$("vol").value);
|
||
clearTimeout(volTimer);
|
||
const dev = window.connDevice; // the headset whose slider this was, even 250 ms later
|
||
volTimer = setTimeout(async () => {
|
||
try { await api("/api/volume", { level: $("vol").value / 100 }, dev); } catch (e) { toast(e.message, true); }
|
||
volTimer = null;
|
||
}, 250);
|
||
};
|
||
$("muteBtn").onclick = () => {
|
||
const muted = !(state?.volume?.muted);
|
||
act(muted ? "Mute" : "Unmute", () => api("/api/volume", { muted }), $("muteBtn")).then(refresh);
|
||
};
|
||
$("clipSend").onclick = () => {
|
||
const text = $("clipText").value;
|
||
if (!text) return toast("Nothing to send", true);
|
||
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
||
};
|
||
// In the app, Electron reads the clipboard; in a browser, the server does.
|
||
async function sendComputerClipboard() {
|
||
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
|
||
const device = window.connDevice; // the headset it was sent to, not one switched to while reading
|
||
const text = await window.frameApp.readClipboard();
|
||
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
|
||
return api("/api/clipboard", { text }, device);
|
||
}
|
||
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
|
||
// The other way. The app (or the iPhone app) writes the clipboard itself; in a browser, the
|
||
// server does, on the computer it runs on. The text is never logged.
|
||
async function copyHeadsetClipboard() {
|
||
if (!window.frameApp?.writeClipboard) return api("/api/clipboard", { toComputer: true });
|
||
const { text, message } = await api("/api/clipboard", { read: true });
|
||
await window.frameApp.writeClipboard(text);
|
||
return { message };
|
||
}
|
||
$("clipFrom").onclick = () => act("Copy the headset's clipboard", copyHeadsetClipboard, $("clipFrom"));
|
||
|
||
// ---- file drop ----
|
||
const drop = $("drop");
|
||
drop.onclick = () => $("fileInput").click();
|
||
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
|
||
$("fileInput").onchange = () => {
|
||
// Chosen from Home: show Tools' file panel, where the progress bar is (phones have no Activity bar).
|
||
if ($("fileInput").files.length && page !== "tools") location.hash = "#transfer";
|
||
sendFiles([...$("fileInput").files]);
|
||
};
|
||
// Files can be dropped anywhere in the window; an overlay says what happens.
|
||
// Dialogs keep their own drops (none take files today), so the overlay stays out of them.
|
||
let dragDepth = 0;
|
||
const carriesFiles = e => [...(e.dataTransfer?.types || [])].includes("Files");
|
||
window.addEventListener("dragenter", e => {
|
||
if (!carriesFiles(e) || document.querySelector("dialog[open]")) return;
|
||
e.preventDefault();
|
||
if (dragDepth++ === 0) { $("dropzone").hidden = false; drop.classList.add("over"); }
|
||
});
|
||
window.addEventListener("dragover", e => { if (carriesFiles(e)) e.preventDefault(); });
|
||
window.addEventListener("dragleave", e => {
|
||
if (!carriesFiles(e) || dragDepth === 0) return;
|
||
if (--dragDepth === 0) { $("dropzone").hidden = true; drop.classList.remove("over"); }
|
||
});
|
||
window.addEventListener("drop", e => {
|
||
if (!carriesFiles(e)) return;
|
||
e.preventDefault();
|
||
dragDepth = 0; $("dropzone").hidden = true; drop.classList.remove("over");
|
||
if (document.querySelector("dialog[open]")) return;
|
||
// Folders only show up as directory entries, and only during the drop event itself.
|
||
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
|
||
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||
sendFiles([...e.dataTransfer.files], dirs);
|
||
});
|
||
function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) {
|
||
return new Promise((resolve, reject) => {
|
||
if (signal?.aborted) return reject(new Error("Import cancelled"));
|
||
const xhr = new XMLHttpRequest();
|
||
const abort = () => xhr.abort();
|
||
const cleanup = () => signal?.removeEventListener("abort", abort);
|
||
xhr.open("POST", "/api/upload");
|
||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||
if (dev) xhr.setRequestHeader("X-Frame-Device", dev);
|
||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||
xhr.setRequestHeader("X-Mode", mode);
|
||
if (backupToken) xhr.setRequestHeader("X-Android-Backup", backupToken);
|
||
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||
const bar = $("prog").firstElementChild;
|
||
$("prog").style.display = "block"; bar.style.width = "0";
|
||
xhr.upload.onprogress = e => { if (e.lengthComputable) { bar.style.width = (100 * e.loaded / e.total) + "%"; progress(100 * e.loaded / e.total); } };
|
||
const gen = devGen;
|
||
xhr.onload = () => {
|
||
cleanup();
|
||
$("prog").style.display = "none";
|
||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||
if (gen !== devGen) { // the app switched headsets meanwhile: nothing to offer about this one here
|
||
const err = new Error(data.error || "Switched headsets");
|
||
err.stale = true;
|
||
return xhr.status < 300 ? resolve(data) : reject(err);
|
||
}
|
||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk, dev);
|
||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||
};
|
||
xhr.onerror = () => { cleanup(); $("prog").style.display = "none"; reject(new Error("network error")); };
|
||
xhr.onabort = () => { cleanup(); $("prog").style.display = "none"; reject(new Error("Import cancelled")); };
|
||
signal?.addEventListener("abort", abort, {once:true});
|
||
xhr.send(file);
|
||
});
|
||
}
|
||
async function refreshMedia(selectId) {
|
||
const data = await api("/api/media", { action: "list" });
|
||
const selected = selectId || $("mediaFile").value;
|
||
$("mediaFile").replaceChildren();
|
||
if (!data.files.length) $("mediaFile").add(new Option("Send media to begin", ""));
|
||
for (const file of data.files) $("mediaFile").add(new Option(file.name, file.id));
|
||
if (data.files.some(f => f.id === selected)) $("mediaFile").value = selected;
|
||
const p = data.player;
|
||
$("mediaState").textContent = p.error || `${p.state}${p.file ? ": " + p.file : ""}`;
|
||
return data;
|
||
}
|
||
$("mediaChoose").onclick = () => $("mediaInput").click();
|
||
$("mediaInput").onchange = () => act("Send media", async () => {
|
||
const file = $("mediaInput").files[0];
|
||
if (!file) return;
|
||
const result = await upload(file, "media");
|
||
await refreshMedia(result.id);
|
||
$("mediaInput").value = "";
|
||
return result;
|
||
}, $("mediaChoose"));
|
||
$("mediaRefresh").onclick = () => act("Refresh media", () => refreshMedia(), $("mediaRefresh"));
|
||
$("mediaPlay").onclick = () => act("Play media", async () => {
|
||
const id = $("mediaFile").value;
|
||
if (!id) throw new Error("Send or select a media file first");
|
||
const result = await api("/api/media", { action: "play", id,
|
||
layout: $("mediaLayout").value, theatre: $("mediaTheatre").checked });
|
||
$("mediaState").textContent = "Starting…";
|
||
setTimeout(() => refreshMedia().catch(e => { $("mediaState").textContent = e.message; }), 1500);
|
||
return result;
|
||
}, $("mediaPlay"));
|
||
$("mediaStop").onclick = () => act("Stop media", async () => {
|
||
const result = await api("/api/media", { action: "stop" });
|
||
await refreshMedia();
|
||
return result;
|
||
}, $("mediaStop"));
|
||
|
||
let apkLookup = 0;
|
||
async function checkApkAlternatives(apk, dev = window.connDevice) {
|
||
const lookup = ++apkLookup;
|
||
$("apkAltReason").textContent = apk.blocker;
|
||
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
|
||
$("apkAltVersions").onclick = null;
|
||
for (const id of ["apkAltNote", "apkAltErrors", "apkAltLinks"]) $(id).textContent = "";
|
||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||
const query = new URLSearchParams({package: apk.package});
|
||
if (apk.version_code != null) query.set("code", apk.version_code);
|
||
try {
|
||
const result = await api(`/api/apk-versions?${query}`);
|
||
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result, dev);
|
||
} catch (e) {
|
||
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
||
}
|
||
}
|
||
function showApkAlternatives(reason, result, dev = window.connDevice) {
|
||
$("apkAltReason").textContent = reason;
|
||
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
|
||
$("apkAltErrors").textContent = result.errors.join(" · ");
|
||
$("apkAltLinks").innerHTML = result.links.map(l => `<a href="${esc(l.url)}" target="_blank" rel="noopener noreferrer">${esc(l.source)}</a>`).join(" · ");
|
||
$("apkAltVersions").innerHTML = result.versions.length ? result.versions.map((v, i) =>
|
||
`<div class="item"><div class="grow"><div class="t">${esc(v.version || "?")} <span class="sub">code ${esc(v.version_code)}</span></div>
|
||
<div class="s">${esc(v.source)} · minimum API ${esc(v.min_sdk)} · ${esc(v.abis.join(", ") || "no native code")}</div></div>
|
||
<button class="small" data-version="${i}" ${v.sha256 ? "" : "disabled"}>Install</button></div>`).join("") :
|
||
`<p>No compatible version found in F-Droid. Try the searches below.</p>`;
|
||
$("apkAltVersions").onclick = async e => {
|
||
const b = e.target.closest("[data-version]"); if (!b) return;
|
||
const v = result.versions[+b.dataset.version];
|
||
if (installing.has(result.package)) return;
|
||
b.disabled = true; b.textContent = "Installing…";
|
||
// For the headset the APK was checked against: refused (409) if the app has switched since.
|
||
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
||
action: "install", package: result.package, url: v.url
|
||
}, dev));
|
||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||
await loadAndroid();
|
||
if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); }
|
||
if (res && res.app) await offerTest(res.app);
|
||
};
|
||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||
}
|
||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
||
|
||
// ---- about and licences ----
|
||
document.addEventListener("click", async e => {
|
||
if (!e.target.closest("[data-about]")) return;
|
||
e.preventDefault();
|
||
$("aboutDlg").showModal();
|
||
try {
|
||
const { notices } = await api("/api/licenses");
|
||
$("aboutText").innerHTML = notices.map((n, i) =>
|
||
`<details${i ? "" : " open"}><summary>${esc(n.title)}</summary><pre>${esc(n.text)}</pre></details>`).join("");
|
||
} catch (err) {
|
||
$("aboutText").textContent = `Couldn't load the licences: ${err.message}. They're also in THIRD_PARTY_NOTICES.md and frame/kdeconnect in Frame Control's source.`;
|
||
}
|
||
});
|
||
$("aboutClose").onclick = () => $("aboutDlg").close();
|
||
|
||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||
let fileSendQueue = Promise.resolve();
|
||
function sendFiles(files, dirs = new Set()) {
|
||
const dev = window.connDevice;
|
||
const run = fileSendQueue.then(() => sendFilesOne(files, dirs, dev));
|
||
fileSendQueue = run.catch(e => toast(e.message, true));
|
||
return fileSendQueue;
|
||
}
|
||
async function sendFilesOne(files, dirs, dev) {
|
||
const androidFiles = files.filter((f, i) => !dirs.has(i) && /\.(apk|obb|xapk|apks|apkm)$/i.test(f.name));
|
||
const grouped = androidFiles.length > 1 || androidFiles.some(f => /\.(obb|xapk|apks|apkm)$/i.test(f.name));
|
||
if (grouped && window.connDevice === dev) await importAndroidFiles(androidFiles, dev);
|
||
for (const [i, f] of files.entries()) {
|
||
if (window.connDevice !== dev) {
|
||
toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true);
|
||
break;
|
||
}
|
||
if (grouped && androidFiles.includes(f)) continue;
|
||
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
|
||
if (dirs.has(i)) {
|
||
if (path) {
|
||
if (!await importAndroidFiles([f], dev, true)) await sideload(f, path, true);
|
||
}
|
||
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
|
||
continue;
|
||
}
|
||
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||
if (apk) await importAndroidFiles([f], dev);
|
||
else await act(`Copy ${f.name} to ~/Downloads`, () => upload(f, "push", dev));
|
||
}
|
||
$("fileInput").value = "";
|
||
refresh();
|
||
}
|
||
|
||
$("androidBackupChoose").onclick = () => $("androidBackupInput").click();
|
||
$("androidBackupOpen").onclick = () => { $("installChooser").close(); $("androidBackupInput").click(); };
|
||
$("androidBackupInput").onchange = () => {
|
||
sendFiles([...$("androidBackupInput").files]);
|
||
$("androidBackupInput").value = "";
|
||
};
|
||
async function importAndroidFiles(files, dev, probe = false) {
|
||
const dlg = $("androidImportDlg");
|
||
let token = null, running = false;
|
||
const controller = new AbortController();
|
||
const finished = new Promise(resolve => dlg.addEventListener("close", () => { controller.abort(); resolve(); }, {once:true}));
|
||
$("androidImportName").textContent = "Reading your backup…";
|
||
for (const id of ["androidImportFiles", "androidImportNote", "androidImportWarnings", "androidImportState"]) $(id).textContent = "";
|
||
$("androidImportGo").hidden = false;
|
||
$("androidImportGo").disabled = true;
|
||
$("androidImportGo").textContent = "Import game";
|
||
$("androidImportCancel").disabled = false;
|
||
$("androidImportCancel").textContent = "Cancel";
|
||
$("androidImportCancel").onclick = () => dlg.close();
|
||
dlg.oncancel = e => { if (running) e.preventDefault(); };
|
||
$("androidImportProgress").hidden = true;
|
||
dlg.showModal();
|
||
try {
|
||
let read;
|
||
const paths = files.map(f => window.frameApp?.pathForFile?.(f) || "");
|
||
if (paths.every(Boolean)) {
|
||
read = await api("/api/android/backup", {action:"inspect", paths, probe}, dev);
|
||
if (read.android === false) { dlg.close(); await finished; return false; }
|
||
token = read.token;
|
||
} else {
|
||
token = (await api("/api/android/backup", {action:"begin"}, dev)).token;
|
||
for (const [i, file] of files.entries()) {
|
||
if (!dlg.open) return true;
|
||
$("androidImportState").textContent = `Reading file ${i + 1} of ${files.length}: ${file.name}`;
|
||
$("androidImportProgress").hidden = false;
|
||
await upload(file, "android-backup", dev, token, percent => { $("androidImportProgress").value = percent; }, controller.signal);
|
||
}
|
||
read = await api("/api/android/backup", {action:"inspect", token}, dev);
|
||
}
|
||
if (!dlg.open) return true;
|
||
const plan = read.plan;
|
||
$("androidImportName").textContent = `${plan.label}${plan.version ? " · " + plan.version : ""}`;
|
||
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>`
|
||
: `<div class="sub">Game app already installed</div>`) +
|
||
`<div class="sub" style="margin-top:12px">Game data · ${plan.obb.length} file${plan.obb.length === 1 ? "" : "s"}</div>` +
|
||
plan.obb.map(f => `<div>${esc(f.name)} <span class="sub">${gb(f.size)}</span></div>`).join("");
|
||
$("androidImportNote").textContent = plan.obb.length
|
||
? "Frame Control will start the game, copy its data to the right place, and close it. Then you can open it from your Steam library."
|
||
: "No game-data files selected. If your backup includes .obb files, cancel and choose them with the APK. In the desktop app, matching files beside the APK are found automatically.";
|
||
$("androidImportWarnings").textContent = (plan.warnings || []).join(" ");
|
||
$("androidImportState").textContent = "";
|
||
$("androidImportProgress").hidden = true;
|
||
$("androidImportGo").textContent = plan.obb.length ? "Import game & data" : "Install game app";
|
||
$("androidImportGo").disabled = false;
|
||
const chosen = await Promise.race([
|
||
new Promise(resolve => { $("androidImportGo").onclick = () => resolve(true); }),
|
||
finished.then(() => false),
|
||
]);
|
||
if (!chosen) return true;
|
||
if (window.connDevice !== dev) throw new Error("The headset changed. Choose the backup again for this Frame.");
|
||
running = true;
|
||
$("androidImportCancel").disabled = true;
|
||
$("androidImportGo").disabled = true;
|
||
$("androidImportProgress").hidden = false;
|
||
$("androidImportProgress").value = 0;
|
||
const result = await runJob(`Import ${plan.label}`, `backup:${token}`, async () => {
|
||
try {
|
||
const started = await api("/api/android/backup", {action:"install", token, display:$("apkDisplay").value}, dev);
|
||
token = null; // the job owns the uploaded files now
|
||
return started;
|
||
} catch (e) { $("androidImportState").textContent = e.message; throw e; }
|
||
}, j => {
|
||
$("androidImportState").textContent = j.error || j.message || j.stage || "Importing…";
|
||
if (j.percent != null) $("androidImportProgress").value = j.percent;
|
||
});
|
||
$("androidImportState").textContent = result?.message || $("androidImportState").textContent || "Setup did not finish. Import the backup again to retry.";
|
||
if (result?.warnings?.length) $("androidImportWarnings").textContent = result.warnings.join(" ");
|
||
} catch (e) {
|
||
if (controller.signal.aborted) return true;
|
||
$("androidImportState").textContent = e.message;
|
||
log(`Import Android game: ${e.message}`, "e");
|
||
if (e.apk?.blocker) { dlg.close(); checkApkAlternatives(e.apk, dev); }
|
||
} finally {
|
||
running = false;
|
||
$("androidImportProgress").hidden = true;
|
||
$("androidImportGo").hidden = true;
|
||
$("androidImportCancel").disabled = false;
|
||
$("androidImportCancel").textContent = "Close";
|
||
if (token) api("/api/android/backup", {action:"discard", token}, dev).catch(() => {});
|
||
}
|
||
await finished;
|
||
return true;
|
||
}
|
||
|
||
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
|
||
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
|
||
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
|
||
const trim = x => x.replace(/^_+|_+$/g, "");
|
||
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9]+/g, "_")).slice(0, 64));
|
||
if (!s) return s;
|
||
if (/^[0-9]/.test(s)) s = "_" + s.slice(0, 63);
|
||
if (s.length < 2 || RESERVED_IDS.includes(s.toLowerCase())) s += "_game";
|
||
return s;
|
||
}
|
||
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
|
||
function candLabel(c) {
|
||
const kind = c.format === "pe" ? `Windows ${ARCH[c.arch] || c.arch}` : c.format === "elf" ? `Linux ${ARCH[c.arch] || c.arch}` : "script";
|
||
return `${c.path} · ${kind} · ${gb(c.size)}${c.skip ? " · helper?" : ""}`;
|
||
}
|
||
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
|
||
// One at a time, so a second drop doesn't take over the dialog of the first.
|
||
let sideloadQueue = Promise.resolve();
|
||
function sideload(file, path, isDir = false) {
|
||
const dev = window.connDevice; // for the headset it was dropped on, even if it waits its turn
|
||
const run = sideloadQueue.then(() => {
|
||
if (window.connDevice !== dev) return toast(`Switched headsets: didn't add ${file.name}`, true);
|
||
return sideloadOne(file, path, isDir, dev);
|
||
});
|
||
sideloadQueue = run.catch(() => {});
|
||
return run;
|
||
}
|
||
async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
|
||
const canPush = !isDir && file.size > 0;
|
||
log(`Reading ${file.name}…`);
|
||
let r;
|
||
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title", dev); }
|
||
catch (e) {
|
||
log(`${file.name}: ${e.message}`, "e");
|
||
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
|
||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
|
||
return;
|
||
}
|
||
// Its own fresh list, so the dialog can say whether this replaces an installed title.
|
||
let installed = null;
|
||
try { installed = new Set((await api("/api/titles")).titles.map(t => String(t.id).toLowerCase())); } catch {}
|
||
const choice = await confirmTitle(r.plan, canPush, installed);
|
||
if (choice === "push") {
|
||
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
|
||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
|
||
return;
|
||
}
|
||
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
|
||
await installTitle(r.token, choice);
|
||
}
|
||
function confirmTitle(plan, canPush, installed) { // installed: a Set of lower-case ids, or null if unknown
|
||
return new Promise(resolve => {
|
||
const dlg = $("titleDlg");
|
||
$("titlePush").hidden = !canPush;
|
||
$("titleSrc").textContent = `${plan.source} · ${gb(plan.size)}`;
|
||
$("titleName").value = plan.name;
|
||
const idNote = () => { const id = titleId($("titleName").value);
|
||
$("titleIdNote").textContent = !id ? "Needs some letters or digits"
|
||
: !installed ? `Shows in Steam as ${id}. Couldn't check whether it's installed already; if it is, this replaces it`
|
||
: installed.has(id.toLowerCase()) ? `Replaces the installed ${id}, and everything in its folder on the Frame`
|
||
: `Shows in Steam as ${id}`; };
|
||
$("titleName").oninput = idNote; idNote();
|
||
$("titleExe").innerHTML = plan.candidates.map(c =>
|
||
`<option value="${esc(c.path)}"${c.path === plan.target ? " selected" : ""}${c.blocked ? " disabled" : ""}>${esc(candLabel(c))}</option>`).join("");
|
||
const runtimes = () => {
|
||
const c = plan.candidates.find(x => x.path === $("titleExe").value) || {};
|
||
const list = c.runtimes || [];
|
||
$("titleRt").innerHTML = list.map(k => `<option value="${esc(k)}">${esc(plan.runtime_labels[k] || k)}</option>`).join("");
|
||
$("titleRt").disabled = list.length < 2;
|
||
if (c.path === plan.target) {
|
||
$("titleRt").value = plan.runtime;
|
||
$("titleNote").textContent = plan.note + " Device behaviour is inferred from Valve's devkit tools.";
|
||
} else $("titleNote").textContent = "Runtime follows the chosen program.";
|
||
$("titleWarn").textContent = c.skip ? "This looks like an installer or helper, not the game itself." : "";
|
||
};
|
||
$("titleExe").onchange = runtimes; runtimes();
|
||
$("titleMsg").textContent = "";
|
||
// Resolve on this dialog's own close event (it arrives a frame later), so a
|
||
// queued next title can't receive it. Escape closes with the choice left null.
|
||
let choice = null;
|
||
dlg.addEventListener("close", () => resolve(choice), { once: true });
|
||
const finish = v => { choice = v; dlg.close(); };
|
||
$("titleCancel").onclick = () => finish(null);
|
||
$("titlePush").onclick = () => finish("push");
|
||
$("titleForm").onsubmit = e => {
|
||
e.preventDefault();
|
||
if (!titleId($("titleName").value)) return ($("titleMsg").textContent = "Enter a name");
|
||
finish({ name: $("titleName").value.trim(), exe: $("titleExe").value, runtime: $("titleRt").value });
|
||
};
|
||
dlg.showModal();
|
||
$("titleName").focus();
|
||
});
|
||
}
|
||
async function installTitle(token, choice) {
|
||
const label = `Install ${titleId(choice.name)}`;
|
||
const start = await act(label, () => api("/api/titles", { action: "install", token, ...choice }));
|
||
if (!start) return;
|
||
const bar = $("prog").firstElementChild;
|
||
$("prog").style.display = "block"; bar.style.width = "0";
|
||
let stage = "";
|
||
try {
|
||
for (;;) {
|
||
await new Promise(ok => setTimeout(ok, 1000));
|
||
let j;
|
||
try { j = await api(`/api/titles/job?token=${encodeURIComponent(start.job)}`); }
|
||
catch (e) { log(`${label}: ${e.message}`, "e"); return; }
|
||
bar.style.width = (100 * (j.fraction || 0)) + "%";
|
||
if (j.stage && j.stage !== stage && !j.done) { stage = j.stage; log(`${stage}…`); $("lastLog").textContent = stage + "…"; }
|
||
if (j.done) {
|
||
if (j.error) { log(`${label} failed: ${j.error}`, "e"); toast(`${label} failed: ${j.error}`, true); }
|
||
else { log(j.message, "ok"); toast(j.message); }
|
||
break;
|
||
}
|
||
}
|
||
} finally { $("prog").style.display = "none"; loadTitles(); }
|
||
}
|
||
let titlesSeq = 0; // a slower, older request mustn't overwrite a newer list
|
||
async function loadTitles() {
|
||
const seq = ++titlesSeq;
|
||
let list, err;
|
||
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
|
||
if (seq !== titlesSeq) return;
|
||
if (err) return failed($("titleList"), err);
|
||
$("titleCount").textContent = list.length ? `${list.length}` : "";
|
||
$("titleList").innerHTML = list.length ? list.map(t => `
|
||
<div class="item">
|
||
<div class="grow"><div class="t">${esc(t.id)}</div>
|
||
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
|
||
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">Play</button>
|
||
<details class="lib-more"><summary aria-label="More for ${esc(t.id)}">⋯</summary><div class="lib-menu">
|
||
${t.art_missing ? `<button data-title="refresh-art" data-id="${esc(t.id)}">Add artwork<small>Its Steam library tile has none yet</small></button>` : ""}
|
||
<button class="danger" data-title="force-quit" data-id="${esc(t.id)}">Force quit…<small>If it has stopped responding</small></button>
|
||
<button class="danger" data-title="remove" data-id="${esc(t.id)}">Remove…<small>Deletes the game and its files</small></button>
|
||
</div></details>
|
||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||
}
|
||
$("titleList").onclick = async e => {
|
||
const b = e.target.closest("[data-title]"); if (!b) return;
|
||
const id = b.dataset.id;
|
||
b.closest(".lib-more")?.removeAttribute("open");
|
||
if (b.dataset.title === "remove") {
|
||
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||
loadTitles();
|
||
} else if (b.dataset.title === "force-quit") {
|
||
forceQuitFrom(g => g.kind === "devkit" && g.title === id, id, b);
|
||
} else if (b.dataset.title === "refresh-art") {
|
||
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
|
||
loadTitles();
|
||
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
||
};
|
||
loadTitles();
|
||
$("titleAdd").onclick = () => $("fileInput").click();
|
||
|
||
// ---- what's running, and force quit (frame_running.py on the Frame) ----
|
||
let runningGames = [], runningSeq = 0;
|
||
async function loadRunning() {
|
||
const seq = ++runningSeq;
|
||
try {
|
||
const list = (await api("/api/running")).running;
|
||
if (seq === runningSeq) runningGames = list;
|
||
} catch (e) {
|
||
if (seq === runningSeq && !e.stale) runningGames = [];
|
||
throw e;
|
||
} finally { if (seq === runningSeq) renderRunning(); }
|
||
return seq === runningSeq;
|
||
}
|
||
const RUNNING_KIND = { steam: "Steam game", devkit: "Sideloaded game", shortcut: "Non-Steam shortcut", android: "Android app" };
|
||
function renderRunning() {
|
||
$("nowRunning").hidden = !runningGames.length;
|
||
$("runningList").innerHTML = runningGames.map(g => `
|
||
<div class="item"><div class="grow"><div class="t">${esc(g.name)}</div><div class="s">${RUNNING_KIND[g.kind] || "Running"} · running on the Frame</div></div>
|
||
<button class="small danger" data-force-quit="${esc(g.appid)}">Force quit…</button></div>`).join("");
|
||
}
|
||
async function forceQuit(appid, btn) {
|
||
const g = runningGames.find(x => String(x.appid) === String(appid));
|
||
if (!g) return;
|
||
if (!confirm(`Force quit ${g.name}? Unsaved progress will be lost.`)) return;
|
||
await act(`Force quit ${g.name}`, () => api("/api/running", { action: "quit", appid: String(g.appid) }), btn);
|
||
loadRunning().catch(() => {});
|
||
if (g.kind === "android") loadAndroid();
|
||
}
|
||
// From a game's own menu: check it's running first, so a stopped game says so.
|
||
async function forceQuitFrom(match, name, btn) {
|
||
if (btn) btn.disabled = true;
|
||
try { if (!await loadRunning()) return; }
|
||
catch (e) { if (!e.stale) toast(`Couldn't check what's running: ${e.message}`, true); return; }
|
||
finally { if (btn) btn.disabled = false; }
|
||
const g = runningGames.find(match);
|
||
if (!g) { log(`${name} isn't running`); toast(`${name} isn't running`); return; }
|
||
forceQuit(g.appid, btn);
|
||
}
|
||
$("runningList").onclick = e => { const b = e.target.closest("[data-force-quit]"); if (b) forceQuit(b.dataset.forceQuit, b); };
|
||
$("runningCheck").onclick = () => loadRunning().then(() => { if (!runningGames.length) toast("Nothing is running on the Frame"); },
|
||
e => { if (!e.stale) toast(`Couldn't check what's running: ${e.message}`, true); });
|
||
|
||
|
||
// ---- Steam games: install owned games, find and buy on the store ----
|
||
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
|
||
const FCLASS = { 3: "v-works", 2: "v-maybe", 1: "v-no", 0: "f-0" };
|
||
const gm = { src: "owned", owned: null, byId: new Map(), country: null, dl: null, q: "", storeDraft: "",
|
||
// Rating filters per view: most store results aren't rated yet, so show them all there.
|
||
filters: { owned: new Set(["3", "2", "0", "1"]), store: new Set(["3", "2", "0", "1"]) },
|
||
results: [], shown: 0, shownBySource: {owned:0, store:0}, store: [], storeQ: null, poll: null, seq: 0 };
|
||
|
||
async function loadOwned() {
|
||
$("gmReload").disabled = true;
|
||
try {
|
||
const r = await api("/api/steam/owned");
|
||
gm.owned = r.games; gm.country = r.country || gm.country; gm.dl = r.download;
|
||
gm.byId = new Map(gm.owned.map(g => [g.id, g]));
|
||
} catch (e) {
|
||
if (!gm.owned) failed($("gmGrid"), e);
|
||
if (!e.offline) log("Steam library: " + e.message, "e");
|
||
// Keep trying while a download was in progress; one failed poll shouldn't freeze it.
|
||
clearTimeout(gm.poll);
|
||
if (gm.dl) gm.poll = setTimeout(loadOwned, 15000);
|
||
return;
|
||
} finally { $("gmReload").disabled = false; }
|
||
const d = gm.dl, name = d && gm.byId.get(d.appid)?.name;
|
||
$("gmDl").textContent = d ? `${d.paused ? "Paused" : "Downloading"} ${name || d.appid} · ${d.percent ?? 0}%` +
|
||
(d.eta > 0 && !d.paused ? ` · ${dur(d.eta)} left` : "") : "";
|
||
// Keep polling while something downloads so the progress and Installed tags update.
|
||
clearTimeout(gm.poll);
|
||
if (d) gm.poll = setTimeout(loadOwned, document.hidden ? 30000 : 5000);
|
||
renderChips(); filterGames(true); renderHome();
|
||
}
|
||
|
||
function renderChips() {
|
||
const counts = {};
|
||
(gm.src === "owned" ? gm.owned || [] : gm.store).forEach(g => counts[g.frame] = (counts[g.frame] || 0) + 1);
|
||
$("gmChips").innerHTML = [3, 2, 0, 1].map(k => `<button class="chip vchip ${FCLASS[k]}" data-f="${k}"
|
||
aria-pressed="${gm.filters[gm.src].has(String(k))}">${FLABEL[k]} (${(counts[k] || 0).toLocaleString()})</button>`).join("");
|
||
}
|
||
|
||
function filterGames(keepScroll) {
|
||
const y = scroller().scrollTop;
|
||
if (gm.src === "owned") {
|
||
if (!gm.owned) return;
|
||
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
|
||
gm.results = gm.owned.filter(g => !g.installed && gm.filters.owned.has(String(g.frame)) && terms.every(t => g.name.toLowerCase().includes(t)));
|
||
// Installed and downloading first, then recently played, then by name.
|
||
gm.results.sort((a, b) => (b.installed - a.installed) || (b.lastPlayed - a.lastPlayed) || a.sort.localeCompare(b.sort));
|
||
$("gmCount").textContent = `${gm.results.length.toLocaleString()} of ${gm.owned.length.toLocaleString()} owned`;
|
||
} else {
|
||
gm.results = gm.store.filter(g => gm.filters.store.has(String(g.frame)));
|
||
$("gmCount").textContent = gm.storeQ ? `${gm.results.length} of ${gm.store.length} store results` : "";
|
||
}
|
||
const n = Math.max(48, keepScroll ? gm.shown : 0);
|
||
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
|
||
if (!gm.results.length) $("gmGrid").innerHTML = `<div class="sub">${gm.src === "store" && !gm.storeQ
|
||
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}</div>`;
|
||
if (keepScroll) scrollToY(y);
|
||
}
|
||
|
||
function price(p) {
|
||
if (!p) return "Free";
|
||
const f = n => { try { return new Intl.NumberFormat(undefined, { style: "currency", currency: p.currency }).format(n / 100); }
|
||
catch { return `${(n / 100).toFixed(2)} ${esc(p.currency || "")}`; } };
|
||
const fin = p.final ?? p.initial ?? 0;
|
||
return p.initial > fin ? `<s>${f(p.initial)}</s>${f(fin)}` : f(fin);
|
||
}
|
||
|
||
function gameButtons(id, name, free) {
|
||
const g = gm.byId.get(id), d = gm.dl;
|
||
const storeUrl = `https://store.steampowered.com/app/${id}/`;
|
||
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
|
||
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
|
||
if (g && g.installed) return `<span class="tag">Installed</span>` +
|
||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ Play</button>` +
|
||
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
|
||
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
|
||
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
|
||
}
|
||
|
||
function moreGames(count = 48) {
|
||
const next = gm.results.slice(gm.shown, gm.shown + count);
|
||
$("gmGrid").insertAdjacentHTML("beforeend", next.map(g => {
|
||
const own = gm.byId.get(g.id), d = gm.dl && gm.dl.appid === g.id ? gm.dl : null;
|
||
const sub = gm.src === "owned"
|
||
? [g.minutes ? `${(g.minutes / 60).toFixed(g.minutes < 600 ? 1 : 0)} h played` : "Never played", g.vrOnly ? "VR only" : g.vr ? "VR" : "",
|
||
g.installed && g.size ? gb(g.size) : ""].filter(Boolean).join(" · ")
|
||
: `<span class="price">${own ? "In your library" : price(g.price)}</span>${g.metascore ? ` · Metacritic ${esc(g.metascore)}` : ""}`;
|
||
return `<div class="cat-card game-card">
|
||
<img class="art" loading="lazy" src="${CDN}/${g.id}/capsule_231x87.jpg" alt="" onerror="this.style.visibility='hidden'">
|
||
<div class="top"><div style="min-width:0"><div class="t">${esc(g.name)}</div><div class="s">${sub}</div></div>
|
||
<span class="vbadge ${FCLASS[g.frame] ?? FCLASS[0]}">${FLABEL[g.frame] ?? FLABEL[0]}</span></div>
|
||
${d ? `<div class="bar"><i style="width:${d.percent ?? 0}%"></i></div>` : ""}
|
||
<div class="row">${gameButtons(g.id, g.name, gm.src === "store" && !g.price)}</div>
|
||
</div>`;
|
||
}).join(""));
|
||
gm.shown += next.length;
|
||
gm.shownBySource[gm.src] = gm.shown;
|
||
$("gmMore").hidden = gm.shown >= gm.results.length;
|
||
}
|
||
|
||
async function searchStore() {
|
||
const seq = ++gm.seq; // clearing the query invalidates earlier work too
|
||
const q = $("gmQ").value.trim();
|
||
if (!q) { gm.store = []; gm.storeQ = null; renderChips(); return filterGames(); }
|
||
if (!gm.country) await loadOwned(); // the store search needs the account's country
|
||
if (seq !== gm.seq) return;
|
||
if (!gm.country) return toast("Couldn't read the Steam account's country from the Frame", true);
|
||
if (gm.src === 'store') $("gmGrid").innerHTML = `<div class="sub">Searching the Steam store…</div>`;
|
||
try {
|
||
const r = await api(`/api/steam/search?q=${encodeURIComponent(q)}&cc=${gm.country}`);
|
||
if (seq !== gm.seq) return;
|
||
gm.store = r.results; gm.storeQ = q;
|
||
} catch (e) { if (seq === gm.seq && gm.src === 'store') $("gmGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||
gm.shownBySource.store = 0;
|
||
if (gm.src === 'store') { renderChips(); filterGames(); }
|
||
}
|
||
|
||
function setGameSource(src) {
|
||
if (gm.src === 'owned') gm.q = $('gmQ').value;
|
||
else gm.storeDraft = $('gmQ').value;
|
||
gm.shownBySource[gm.src] = gm.shown;
|
||
gm.src = src;
|
||
gm.shown = gm.shownBySource[src] || 0;
|
||
document.querySelectorAll("[data-src]").forEach(b => b.classList.toggle("on", b.dataset.src === src));
|
||
$("gmQ").placeholder = src === "owned" ? "Filter your games…" : "Search the Steam store, then press Enter…";
|
||
$("gmQ").value = src === "owned" ? gm.q : gm.storeDraft;
|
||
renderChips(); filterGames(true);
|
||
}
|
||
|
||
$("gmSrc").onclick = e => { const b = e.target.closest("[data-src]"); if (b) setGameSource(b.dataset.src); };
|
||
let gmTimer;
|
||
$("gmQ").oninput = e => {
|
||
if (gm.src !== 'owned') { gm.storeDraft = e.target.value; return; }
|
||
gm.q = e.target.value;
|
||
clearTimeout(gmTimer); gmTimer = setTimeout(() => { if (gm.src === 'owned') filterGames(); }, 150);
|
||
};
|
||
$("gmQ").addEventListener("keydown", e => { if (e.key === "Enter" && gm.src === "store") searchStore(); });
|
||
$("gmReload").onclick = loadOwned;
|
||
$("gmMore").onclick = () => moreGames();
|
||
$("gmChips").onclick = e => {
|
||
const b = e.target.closest("[data-f]"); if (!b) return;
|
||
const f = gm.filters[gm.src];
|
||
f.has(b.dataset.f) ? f.delete(b.dataset.f) : f.add(b.dataset.f);
|
||
b.setAttribute("aria-pressed", f.has(b.dataset.f)); filterGames();
|
||
};
|
||
document.body.addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-gm]"); if (!b) return;
|
||
const action = b.dataset.gm, appid = b.dataset.id, name = b.dataset.name;
|
||
const res = await act(action === "install" ? `Install ${name}` : `Open ${name} in the headset store`,
|
||
() => api("/api/steam", { action, appid }), b);
|
||
if (action === "install" && res) loadOwned();
|
||
});
|
||
loadOwned();
|
||
|
||
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
|
||
const mod = { appid: null, name: "" };
|
||
function showMod(st, note) {
|
||
const busy = note === null;
|
||
$("modTitle").textContent = `${mod.name} in VR`;
|
||
$("modState").textContent = !st ? "Checking the game on the Frame…"
|
||
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
|
||
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
|
||
: st.injected ? "UEVR is running in the game now. Put on the headset."
|
||
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
|
||
: `UEVR ${st.version} isn't installed for this game yet.`;
|
||
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
|
||
const ok = st && st.eligible && st.prefix;
|
||
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
|
||
$("modRemove").hidden = !st || !st.installed;
|
||
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
|
||
}
|
||
async function modAction(action, label) {
|
||
const appid = mod.appid;
|
||
showMod(mod.st, null);
|
||
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
|
||
if (appid !== mod.appid) return;
|
||
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
|
||
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
|
||
}
|
||
document.body.addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-mod]"); if (!b) return;
|
||
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
|
||
showMod(null, "");
|
||
$("modDlg").showModal();
|
||
const appid = mod.appid;
|
||
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
|
||
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
|
||
});
|
||
$("modInstall").onclick = () => modAction("install", "Install UEVR");
|
||
$("modStart").onclick = () => modAction("start", "Start UEVR");
|
||
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
|
||
$("modClose").onclick = () => $("modDlg").close();
|
||
|
||
// ---- Discover apps ----
|
||
const sourceState = { vr: null, installable: false, source: "", category: "", phone: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
|
||
// The store's categories (ui/apk_sources/_categories.py): what a Frame is good for, not F-Droid's phone-centred list.
|
||
const STORE_CATS = [
|
||
["", "Home", "Android apps for your Frame", "VR games, emulators, video and reading apps that work in a panel. Free, from open sources.", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
|
||
["vr", "VR", "Made for headsets", "OpenXR games and experiences that fill the room, not a panel.", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
|
||
["games", "Games", "Play in a panel", "Puzzle, strategy, card and action games that play well on a big floating screen.", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/><circle cx="15.5" cy="12" r=".6" fill="currentColor"/><circle cx="17" cy="13.6" r=".6" fill="currentColor"/>'],
|
||
["emulators", "Emulators", "Classic games, big screen", "RetroArch, Dolphin, PPSSPP and friends. Bring your own games.", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4"/><path d="M8 10.5h3M9.5 9v3"/><circle cx="15" cy="10.5" r=".8" fill="currentColor"/>'],
|
||
["watch", "Watch & listen", "Your own cinema", "Video players, streaming front-ends, music and podcasts.", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z" fill="currentColor"/><path d="M8 20h8"/>'],
|
||
["read", "Read & learn", "Books, news and more", "E-book and comic readers, news, dictionaries and learning apps.", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
|
||
["create", "Create", "Make something", "Drawing, photo editing, music and recording.", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
|
||
["work", "Work", "Get things done", "Notes, tasks, documents, calendars and calculators.", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
|
||
["web", "Web & chat", "Stay connected", "Browsers, email, messaging and social apps.", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
|
||
["tools", "Tools", "Handy utilities", "File managers, passwords, remote access and developer tools.", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
|
||
];
|
||
const storeCat = id => STORE_CATS.find(c => c[0] === (id || "")) || STORE_CATS[0];
|
||
const catIcon = id => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${storeCat(id)[4]}</svg>`;
|
||
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
|
||
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
|
||
const storeInitial = name => [...String(name || "?")][0].toUpperCase(); // the small icon's stand-in
|
||
const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ");
|
||
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
|
||
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
|
||
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
|
||
const GLYPH_VR = `<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><rect x="5" y="15" width="38" height="20" rx="8"/><circle cx="17" cy="25" r="3.5"/><circle cx="31" cy="25" r="3.5"/></svg>`;
|
||
const GLYPH_APP = `<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><rect x="8" y="8" width="13" height="13" rx="3.5"/><rect x="27" y="8" width="13" height="13" rx="3.5"/><rect x="8" y="27" width="13" height="13" rx="3.5"/><rect x="27" y="27" width="13" height="13" rx="3.5"/></svg>`;
|
||
function storeArt(o, cls="", eager=false, path=null, alt=null, extra="") {
|
||
// Best artwork first: a banner, else phone-style screenshots side by side, else the icon over its own blur.
|
||
const art = o.artwork || {}, label = alt || `${o.name} artwork`, fallback = `<span class="art-glyph">${o.vr ? GLYPH_VR : GLYPH_APP}</span>`;
|
||
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
|
||
if (!path && !valid(art.banner) && (art.screenshots || []).some(valid)) {
|
||
const shots = art.screenshots.filter(valid).slice(0, 3);
|
||
return `<span class="store-art shots ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${fallback}${shots.map(u => storeImage(u, "", eager)).join("")}${extra}</span>`;
|
||
}
|
||
if (!path && !valid(art.banner) && valid(art.icon)) {
|
||
return `<span class="store-art icon-art ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${fallback}<img class="art-backdrop" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async"><img class="art-mark" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async">${extra}</span>`;
|
||
}
|
||
const image = path || art.banner || art.screenshots?.[0];
|
||
const under = !path && valid(art.icon)
|
||
? `<img class="art-wash" src="${esc(art.icon)}" alt="" loading="lazy" decoding="async"><img class="art-icon" src="${esc(art.icon)}" alt="" loading="lazy" decoding="async">`
|
||
: fallback;
|
||
return `<span class="store-art ${cls}${image ? "" : " no-image"}" style="--hue:${storeHue(o.name)}">${under}${storeImage(image, label, eager)}${extra}</span>`;
|
||
}
|
||
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
|
||
document.addEventListener("error", e => {
|
||
if (!e.target.matches?.(".store-art img, .store-icon img")) return;
|
||
const img = e.target;
|
||
if (!img.dataset.retried) { // the proxy fetches from the source on first use, which can time out once
|
||
img.dataset.retried = "1"; setTimeout(() => { img.src = img.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return;
|
||
}
|
||
const art = img.closest(".store-art"); img.remove();
|
||
if (art && !art.querySelector("img")) {
|
||
art.classList.remove("icon-art", "shots");
|
||
if (!art.querySelector(".art-glyph")) art.insertAdjacentHTML("afterbegin", `<span class="art-glyph">${GLYPH_APP}</span>`);
|
||
}
|
||
}, true);
|
||
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
|
||
function sourceDescription(s) {
|
||
const kinds = {fdroid:"Free and open-source Android apps", itch:"Games from independent developers", github:"Releases straight from the developers",
|
||
sidequest:"VR games and apps. Its terms don't allow searching it from here, so browse it on its website."};
|
||
return s.description || kinds[s.kind] || "Apps from this repository";
|
||
}
|
||
// GitHub's optional access token: only whether one is set is ever shown, never its value.
|
||
function sourceToken(s) {
|
||
const t=s.token;if(!t)return "";
|
||
const note=t.rejected ? "GitHub rejected this token, so searches continue without it. Replace or clear it." : t.from==="env" ? "Using the token from FRAME_GITHUB_TOKEN." : t.set ? "A token is saved." : "Optional: a token raises GitHub's limit of 60 requests an hour.";
|
||
return `<form class="source-token" data-token="${esc(s.id)}"><input name="token" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(storeName(s.name))} access token" placeholder="${t.set ? "Replace access token" : "Access token"}"><button class="small">Save</button>${t.from==="saved" ? `<button type="button" class="small" data-clear-token="${esc(s.id)}">Clear</button>` : ""}</form><small>${esc(note)}</small>`;
|
||
}
|
||
async function loadSources() {
|
||
try {
|
||
const {sources} = await api("/api/sources"); sourceState.sources = sources;
|
||
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled && !s.page_only).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
|
||
$("sourceFilter").value = sourceState.source;
|
||
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div>${s.page_only ? `<a class="source-web" href="${esc(sourcePage(s.url))}" target="_blank" rel="noopener" title="Its apps can't be searched from here">Website ↗</a>` : `<input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}>`}</div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
|
||
} catch (e) { $("sourceError").textContent = e.message; }
|
||
}
|
||
// Keep installation confidence explicit, including apps that have not been checked yet.
|
||
const RUNS = { works: ["✓ Works on the Frame", "works"], ready: ["Should install", "ready"], unknown: ["Not checked yet", "unknown"], blocked: ["May not run", "blocked"], warning: ["Reported not working", "warning"], issues: ["Reported issues", "issues"] };
|
||
function storeRuns(o) {
|
||
const [text, cls] = RUNS[o.verdict?.tone] || RUNS.unknown;
|
||
const report=o.compat_report, extra=o.verdict?.tone==='blocked' && ['no','maybe'].includes(report?.verdict);
|
||
return `<span class="store-compat-badges"><span class="runs ${cls}" title="${esc(o.verdict?.label || "Not yet checked on the Frame")}">${text}</span>${extra ? `<span class="runs warning">${report.verdict==='no' ? 'Reported not working' : 'Reported issues'}</span>` : ''}</span>`;
|
||
}
|
||
function storePickEligible(o) { return o.verdict?.tone !== 'blocked' && o.compat_report?.verdict !== 'no'; }
|
||
function storeReportDetails(o) {
|
||
const report=o.compat_report;
|
||
return report ? `<div class="detail-verdict ${report.verdict==='works' ? 'works' : 'warning'}"><strong>Saved compatibility reports</strong>${report.lines.map(line=>`<p>${esc(line)}</p>`).join('')}<p>Reports can cover other versions or runtimes. You can still try installing unless this build has a compatibility blocker.</p></div>` : '';
|
||
}
|
||
const storeKind = o => o.vr ? "VR" : o.vr === false ? "Flat-screen app" : "Android app";
|
||
function storeCard(app) {
|
||
const i=sourceState.apps.indexOf(app), o=app.offers[0], c=app.category || o.category;
|
||
return `<button class="store-card" data-app="${i}" aria-label="${esc(app.name)}: ${esc(o.verdict?.label || "not yet checked on the Frame")}">${storeArt(o,"",false,null,null,o.vr ? '<span class="store-vr">VR</span>' : "")}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "From " + storeName(o.source_name))}</span><span class="store-card-foot"><span class="store-cat">${esc(o.phone_only ? "Phone app" : c ? storeCat(c)[1] : storeKind(o))}</span>${storeRuns(o)}</span></span></button>`;
|
||
}
|
||
function storeRow(title, subtitle, apps, category) {
|
||
return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p>${category !== undefined ? `<button class="see-all" data-cat="${esc(category)}">See all ›</button>` : ""}</div><div class="store-shelf">${apps.slice(0,12).map(storeCard).join("")}</div></section>` : "";
|
||
}
|
||
function storeEmpty(message) {
|
||
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>${message ? "Couldn't load apps" : "A little further to explore."}</h2><p>${message ? esc(message) : "No apps found this time. Try a different name, another category, or turn on phone-only apps."}</p><button class="action" id="storeReset">${message ? "Try again" : "Back to the store"}</button></div>`;
|
||
}
|
||
function renderCats() {
|
||
if (!$("storeCats").children.length) // built once, so a keyboard user keeps focus on the category they chose
|
||
$("storeCats").innerHTML = STORE_CATS.map(([id, label]) => `<button data-cat="${id}" aria-pressed="false">${catIcon(id)}<span>${esc(label)}</span></button>`).join("");
|
||
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
|
||
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
|
||
$("storeHeading").hidden = !sourceState.category;
|
||
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>`;
|
||
}
|
||
function renderStore() {
|
||
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
|
||
renderCats();
|
||
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${q ? ` for “${q}”` : ""}${sourceState.category ? ` in ${storeCat(sourceState.category)[1]}` : ""}`;
|
||
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
|
||
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
|
||
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
|
||
const ok=apps.filter(a=>storePickEligible(a.offers[0])), shown=new Set();
|
||
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
|
||
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
|
||
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
|
||
const o=featured.offers[0];
|
||
shown.add(featured);
|
||
const take=(list)=>{ const out=list.filter(a=>!shown.has(a)).slice(0,12); out.forEach(a=>shown.add(a)); return out; };
|
||
const rows=STORE_CATS.slice(1).map(([id,label,eyebrow])=>storeRow(label,eyebrow,take(ok.filter(a=>a.category===id)),id)).join("");
|
||
const recent=take([...ok].filter(a=>a.offers[0].updated && a.offers[0].artwork?.icon).sort((a,b)=>String(b.offers[0].updated).localeCompare(String(a.offers[0].updated))));
|
||
const canInstall=o.downloadable && o.verdict?.tone !== 'blocked';
|
||
$("searchGrid").innerHTML=`<div class="store-hero" data-app="${apps.indexOf(featured)}">${storeArt(o,"",true)}<div class="hero-copy"><span class="hero-meta">${o.vr ? '<span class="store-vr">VR</span>' : ""}<span class="store-eyebrow">Featured</span></span><strong>${esc(featured.name)}</strong><span class="hero-sum">${esc(storeText(featured.summary))}</span><span class="hero-runs">${storeRuns(o)}</span><span class="hero-actions">${canInstall ? `<button class="action" data-hero-install="${apps.indexOf(featured)}">Install on the Frame</button>` : ""}<button class="hero-details" data-app="${apps.indexOf(featured)}">Details</button></span></div></div>` + rows + storeRow("Recently updated","Fresh from their creators",recent);
|
||
}
|
||
function storeChips() { renderCats(); $("storeFits").checked = sourceState.installable; $("storePhone").checked = sourceState.phone; }
|
||
function storeCategory(id) { sourceState.category = id || ""; $("sourceQ").value = ""; storeChips(); searchSources(); document.getElementById("android")?.scrollIntoView({block:"start", behavior:"smooth"}); }
|
||
async function searchSources(quiet) {
|
||
const request=++sourceState.request;
|
||
$("searchGrid").setAttribute("aria-busy","true");
|
||
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
|
||
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable),category:sourceState.category,phone:String(sourceState.phone)});
|
||
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
|
||
try {
|
||
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
|
||
sourceState.apps=result.apps;
|
||
const unavailable=result.sources.filter(s=>s.status==='error'||s.status==='timed out').map(s=>storeName(s.name));
|
||
const loading=result.sources.filter(s=>s.status==='loading').map(s=>storeName(s.name));
|
||
const notes=[];
|
||
if(loading.length)notes.push(`Still fetching ${loading.join(" and ")}; more results will appear in a moment.`);
|
||
if(unavailable.length)notes.push(`${unavailable.join(" and ")} ${unavailable.length===1 ? "isn't" : "aren't"} responding right now. You can still explore the other sources.`);
|
||
result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`));
|
||
const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name));
|
||
if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`);
|
||
$("sourceStatus").textContent=notes.join(" ");
|
||
$("sourceStatus").hidden=!notes.length;
|
||
clearTimeout(sourceState.retry);
|
||
if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000);
|
||
renderStore();await loadSources();
|
||
} catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML='<div class="lib-empty"><h2>App shelves unavailable</h2><p>Your installed apps are still in Apps.</p><button data-retry-sources>Retry shelves</button></div>';$("sourceStatus").hidden=false;$("sourceStatus").textContent=e.message; } }
|
||
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
|
||
}
|
||
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
|
||
function detailBadges(o) { return `<span class="store-badge">${storeKind(o)}</span>${storeRuns(o)}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
|
||
// The store's app page. (Not renderDetail: the Devices page declares that further down,
|
||
// and a later function declaration silently replaces an earlier one; issue #68.)
|
||
// True when the page rendered; false when it shows the error instead.
|
||
function renderAppDetail() {
|
||
try { renderAppDetailBody(); return true; }
|
||
catch(e) {
|
||
console.error("App details failed to render", e);
|
||
const name=sourceState.detail?.app?.name;
|
||
$("detailBody").innerHTML=`<div class="store-empty detail-error" role="alert"><h2 id="detailTitle">${name ? `We couldn't show ${esc(name)}` : "We couldn't show this app"}</h2><p>Something went wrong opening its page. Close this and try again, or pick another app.</p><button class="action" data-close="appDetail">Close</button></div>`;
|
||
return false;
|
||
}
|
||
}
|
||
function renderAppDetailBody() {
|
||
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
|
||
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
|
||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">The developer's page ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p>${storeReportDetails(o)}<button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||
updateInstall();
|
||
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
|
||
}
|
||
function updateInstall() {
|
||
const o=currentOffer();if(!o || !$("detailInstall"))return;
|
||
const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall");
|
||
if (!job && o.package && androidApps.some(app => app.package === o.package)) {
|
||
b.disabled=false; b.textContent='View installed app'; $('detailProgress').hidden=true;
|
||
$('detailInstallNote').textContent='Already on this Frame. Open its details to launch it or manage its settings.';
|
||
$('detailOffers').querySelectorAll('input').forEach(input=>input.disabled=false);
|
||
return;
|
||
}
|
||
b.disabled=!!(job && !job.done) || !!job?.addingData || (blocked && !job?.result) || (!o.downloadable && !page);
|
||
b.textContent=job?.result?.game_data ? (job.addingData ? 'Adding game data…' : 'Add game data') : job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on the Frame') : 'Get from the creator ↗';
|
||
$("detailProgress").hidden=!job || job.done;
|
||
if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value");
|
||
$("detailInstallNote").textContent=job?.result?.game_data ? (job.dataError || 'Installed. It also needs its game data: open it once on the Frame, then choose Add game data.') : job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Installing. You can close this and keep browsing.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
|
||
$("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done);
|
||
}
|
||
async function openStoreApp(index, install=false) {
|
||
const app=sourceState.apps[index];if(!app)return;
|
||
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
|
||
const shown=renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
|
||
if(shown) {
|
||
// The hero's Install starts in the details, which show its progress. A running or
|
||
// finished job only opens its details; a failed install can be tried again.
|
||
const o=currentOffer(), job=sourceState.jobs.get(`${o.source}:${o.id}`);
|
||
if(install&&(!job||job.done&&!job.result))installStoreApp();
|
||
await loadOfferDetails();
|
||
}
|
||
}
|
||
async function loadOfferDetails() {
|
||
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
|
||
try {
|
||
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
|
||
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
|
||
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
|
||
d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail();
|
||
} catch(e) { const note=$("detailInstallNote");if(d===sourceState.detail && note)note.textContent="Some app details couldn't be refreshed. You can still view this listing."; }
|
||
}
|
||
async function installStoreApp() {
|
||
const gen=devGen, o=currentOffer();if(!o)return;
|
||
const key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key);
|
||
if (!existing && o.package && androidApps.some(app => app.package === o.package)) {
|
||
$('appDetail').close(); openInstalledApp(o.package); return;
|
||
}
|
||
if(existing?.result?.game_data) {
|
||
let error="";existing.addingData=true;existing.dataError="";updateInstall();
|
||
const added=await runJob(`Add game data for ${o.name}`,`${key}:data`,()=>api('/api/sources',{action:'game-data',package:existing.result.package}),j=>{if(gen===devGen)error=j.error||"";});
|
||
if(gen!==devGen)return;
|
||
Object.assign(existing,{addingData:false,dataError:added?"":error||"We couldn't add the game data. Try again."});
|
||
if(added)existing.result={...existing.result,game_data:false};
|
||
updateInstall();return;
|
||
}
|
||
if(existing?.result) { await act(`Open ${o.name}`,()=>api('/api/android',{action:'launch',package:existing.result.package}),$("detailInstall"));return; }
|
||
if(!o.downloadable) { const url=sourcePage(o.page);if(url)window.open(url,'_blank','noopener,noreferrer');return; }
|
||
if(existing&&!existing.done)return;
|
||
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
|
||
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{if(gen!==devGen)return;Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
|
||
if(gen!==devGen)return;
|
||
if(result===null){sourceState.jobs.delete(key);updateInstall();return;} // removed from the install queue
|
||
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
|
||
if(result)loadAndroid();
|
||
}
|
||
async function changeSource(body) {
|
||
$("sourceError").textContent="Saving your sources…";
|
||
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;await loadSources();await searchSources(); }
|
||
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
|
||
}
|
||
let storeSearchTimer;
|
||
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
|
||
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
|
||
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
|
||
$("storeCats").onclick=e=>{const b=e.target.closest('[data-cat]');if(b)storeCategory(b.dataset.cat);};
|
||
$("storeFits").onchange=e=>{sourceState.installable=e.target.checked;searchSources();};
|
||
$("storePhone").onchange=e=>{sourceState.phone=e.target.checked;searchSources();};
|
||
$("searchGrid").onclick=e=>{if(e.target.closest("[data-retry-sources]"))return searchSources();const c=e.target.closest('[data-cat]');if(c)return storeCategory(c.dataset.cat);const h=e.target.closest('[data-hero-install]');if(h){openStoreApp(+h.dataset.heroInstall,true);return;}const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.phone=false;sourceState.category='';sourceState.source='';$('sourceFilter').value='';storeChips();searchSources();}};
|
||
$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
|
||
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
|
||
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
|
||
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
|
||
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});const c=e.target.closest('[data-clear-token]');if(c)changeSource({action:'clear-token',source:c.dataset.clearToken});};
|
||
$("sourcesList").onsubmit=e=>{const f=e.target.closest('[data-token]');if(!f)return;e.preventDefault();const token=f.elements.token.value.trim();f.reset();if(token)changeSource({action:'set-token',source:f.dataset.token,token});};
|
||
async function addSource(body) {
|
||
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
|
||
let error="";
|
||
const result=await runJob('Add repository',`repo:${body.url}`,()=>api('/api/sources',body).catch(e=>{error=e.message;throw e;}),j=>{error=j.error||"";});
|
||
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
|
||
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
|
||
}
|
||
// A login typed into the address moves into the login fields, on the page too, before anything
|
||
// is sent: the address is shown and logged, the login never. It stays there if adding fails.
|
||
// Plain text, not the URL parser: an address it rejects (or a login it can't decode) must
|
||
// still lose its login first. Decoding falls back to the text as typed.
|
||
// Percent-decode what decodes; keep any byte sequence that isn't UTF-8 as typed.
|
||
// One forward pass: each lead byte gives its sequence length (at most 4 bytes).
|
||
function decodeLenient(s) {
|
||
return s.replace(/(?:%[0-9a-f]{2})+/gi, run => {
|
||
const b=run.match(/%[0-9a-f]{2}/gi); let out='', i=0;
|
||
while(i<b.length){
|
||
const v=parseInt(b[i].slice(1),16);
|
||
const n=v<0x80 ? 1 : v>=0xC2&&v<0xE0 ? 2 : v>=0xE0&&v<0xF0 ? 3 : v>=0xF0&&v<0xF5 ? 4 : 0;
|
||
if(n){ try { out+=decodeURIComponent(b.slice(i,i+n).join('')); i+=n; continue; } catch {} }
|
||
out+=b[i]; i++;
|
||
}
|
||
return out;
|
||
});
|
||
}
|
||
// Moves a login typed into the address into the login fields. Returns false for an
|
||
// address the URL parser rejects that may hold a login: it is refused, not sent.
|
||
function moveLogin(form) {
|
||
// Normalise the way URL parsers do: trim C0 controls and spaces, drop tabs and newlines.
|
||
const el=form.elements;
|
||
el.url.value=el.url.value.replace(/^[\u0000- ]+|[\u0000- ]+$/g,'').replace(/[\t\n\r]/g,'');
|
||
let u;
|
||
// A backslash and an '@' together are read differently by browsers and by the
|
||
// server, so the address can't be split safely: refuse it rather than guess.
|
||
if(el.url.value.includes('@') && el.url.value.includes('\\'))return false;
|
||
try { u=new URL(el.url.value); } catch { return !el.url.value.includes('@'); }
|
||
if(!u.username && !u.password)return true;
|
||
const user=decodeLenient(u.username), pass=decodeLenient(u.password);
|
||
u.username=''; u.password=''; el.url.value=u.href;
|
||
if(!el.username.value)el.username.value=user;
|
||
if(!el.password.value)el.password.value=pass;
|
||
el.username.closest('details').open=true;
|
||
return true;
|
||
}
|
||
$("addSource").onsubmit=e=>{
|
||
e.preventDefault();
|
||
if(!moveLogin(e.target)){ $("sourceError").textContent="That address isn't valid. Put the username and password in the login fields below, not in the address."; return; }
|
||
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
|
||
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
|
||
};
|
||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||
function showAndroidTab(tab) {
|
||
const target = tab === 'browse' ? '#appStore' : '#androidLibrary';
|
||
if (location.hash === target) showPage(); else location.hash = target;
|
||
}
|
||
document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick = () => {
|
||
showAndroidTab(button.dataset.storeTab);
|
||
if (button.dataset.storeTab === 'library') loadAndroid();
|
||
});
|
||
storeChips();
|
||
loadSources().then(()=>searchSources());
|
||
|
||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
|
||
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
|
||
let androidApps = [];
|
||
let androidSeq = 0, installedPackage = null;
|
||
const androidPending = new Set();
|
||
|
||
const libState = { filter: "all", q: "" };
|
||
function libCard(a) {
|
||
const pkg = esc(a.package), name = a.label || a.package;
|
||
const o = { name, artwork: a.store_art || {} };
|
||
const pending = androidPending.has(a.package);
|
||
return `<article class="lib-card${a.running ? " running" : ""}" data-pkg="${pkg}">
|
||
${storeIcon(o)}<button class="lib-name" data-app-details="${pkg}"><strong>${esc(name)}</strong><small>${a.running ? "Running" : "Ready to open"}${a.vr && !a.flatscreen ? " · VR" : " · Flat-screen"}</small></button>
|
||
<button class="${a.running ? "stop" : "play"}" data-and="${a.running ? "stop" : "launch"}" data-pkg="${pkg}" data-name="${esc(name)}" ${pending ? 'disabled aria-busy="true"' : ""}>${pending ? "Wait…" : a.running ? "Stop" : "Open"}</button>
|
||
<button class="lib-overflow" data-app-details="${pkg}" aria-label="More for ${esc(name)}">⋯</button></article>`;
|
||
}
|
||
function openInstalledApp(pkg) {
|
||
const a = androidApps.find(app => app.package === pkg);
|
||
if (!a) return;
|
||
installedPackage = pkg;
|
||
const name = a.label || a.package, data = `data-pkg="${esc(pkg)}" data-name="${esc(name)}"`, pending = androidPending.has(pkg);
|
||
$('installedDetailBody').innerHTML = `<span class="eyebrow">Installed on this Frame</span><h2 id="installedTitle">${esc(name)}</h2><p>${a.running ? "Running" : "Ready to open"}${a.vr && !a.flatscreen ? " · VR" : " · Flat-screen"}</p>
|
||
<div class="row"><button class="${a.running ? 'stop' : 'play'}" data-and="${a.running ? 'stop' : 'launch'}" ${data} ${pending ? 'disabled aria-busy="true"' : ''}>${pending ? 'Wait…' : a.running ? 'Stop' : 'Open'}</button><a href="#display" class="workspace-link" data-installed-display>Display settings</a></div>
|
||
${(a.vr_issues || []).length ? `<p class="hint">${esc(a.vr_issues.join(' '))}</p>` : ''}
|
||
<h3>App information</h3><dl class="facts"><dt>Package</dt><dd>${esc(pkg)}</dd><dt>Version</dt><dd>${esc(a.version || 'Unknown')}</dd><dt>Source</dt><dd>${esc(a.source || 'Sideloaded APK')}</dd></dl>
|
||
<h3>Compatibility & maintenance</h3><div class="detail-actions"><button data-and="probe" ${data}>Test on Frame</button><button data-and="rate" ${data} data-ver="${esc(a.version)}" data-src="${esc(a.source || '')}">Report how it worked</button>${a.art_missing ? `<button data-and="refresh-art" ${data}>Add artwork</button>` : ''}<button class="danger" data-and="remove" ${data}>Remove app & data…</button></div>`;
|
||
if (!$('installedDetail').open) $('installedDetail').showModal();
|
||
}
|
||
function renderLibrary() {
|
||
const q = libState.q.trim().toLowerCase(), f = libState.filter;
|
||
const shown = androidApps.filter(a => (f === "all" || (f === "vr" ? a.vr && !a.flatscreen : f === "flat" ? !(a.vr && !a.flatscreen) : a.running))
|
||
&& (!q || `${a.label} ${a.package}`.toLowerCase().includes(q)));
|
||
const vr = androidApps.filter(a => a.vr && !a.flatscreen).length, running = androidApps.filter(a => a.running).length;
|
||
$("libSummary").textContent = `${androidApps.length} installed${running ? ` · ${running} running` : ''}${vr ? ` · ${vr} VR` : ''}`;
|
||
$('libFilterLabel').textContent = f === 'all' ? 'Filters' : ({vr:'VR',flat:'Flat-screen',running:'Running'})[f];
|
||
$("libFilters").querySelectorAll("[data-lib]").forEach(b => b.setAttribute("aria-pressed", b.dataset.lib === f));
|
||
$("andApps").innerHTML = shown.length ? shown.map(libCard).join("")
|
||
: androidApps.length ? `<div class="lib-empty"><h2>Nothing matches</h2><p>Try another filter.</p></div>`
|
||
: `<div class="lib-empty"><h2>No Android apps yet</h2><p>Find VR games, emulators and video apps in the store, or drop an .apk on Send to Frame.</p><button class="action" data-store-go="browse">Open the store</button></div>`;
|
||
}
|
||
$("libFilters").onclick = e => { const b = e.target.closest("[data-lib]"); if (b) { libState.filter = b.dataset.lib; $('libFilterMenu').open = false; renderLibrary(); } };
|
||
$("libQ").oninput = e => { libState.q = e.target.value; renderLibrary(); };
|
||
$("andApps").addEventListener("click", e => {
|
||
if (e.target.closest("[data-store-go]")) showAndroidTab("browse");
|
||
|
||
});
|
||
document.addEventListener("click", e => { document.querySelectorAll(".lib-more[open]").forEach(d => { if (!d.contains(e.target)) d.open = false; }); });
|
||
async function loadAndroid() {
|
||
const seq = ++androidSeq;
|
||
$('andApps').setAttribute('aria-busy', 'true');
|
||
try {
|
||
const result = await api('/api/android');
|
||
if (seq !== androidSeq) return;
|
||
androidApps = result.apps;
|
||
if (metaLib.connected) renderMetaApps(); // "On the Frame" marks in My Meta library
|
||
$('andCount').textContent = String(androidApps.length);
|
||
$('androidLoadError').hidden = true;
|
||
renderLibrary();
|
||
renderHome();
|
||
if ($('appDetail').open) updateInstall();
|
||
if ($('installedDetail').open) {
|
||
if (androidApps.some(a => a.package === installedPackage)) openInstalledApp(installedPackage);
|
||
else $('installedDetail').close();
|
||
}
|
||
} catch (e) {
|
||
if (seq !== androidSeq || e.stale) return;
|
||
$('androidLoadError').hidden = false;
|
||
$('androidLoadMessage').textContent = `Couldn't refresh installed apps: ${e.message}`;
|
||
if (!androidApps.length) $('andApps').innerHTML = '<p class="sub">Installed apps will appear after a successful refresh.</p>';
|
||
} finally { if (seq === androidSeq) $('andApps').setAttribute('aria-busy', 'false'); }
|
||
}
|
||
$('androidRetry').onclick = loadAndroid;
|
||
document.body.addEventListener('click', e => {
|
||
const b = e.target.closest('[data-app-details]');
|
||
if (b) openInstalledApp(b.dataset.appDetails);
|
||
if (e.target.closest('[data-installed-display]')) $('installedDetail').close();
|
||
});
|
||
|
||
// ---- Import from my Quest (frame_quest.py): adb on this computer, never on the Frame ----
|
||
const quest = { serial: null };
|
||
function questSize(bytes) {
|
||
if (bytes == null) return "";
|
||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
|
||
if (bytes >= 1048576) return `${Math.round(bytes / 1048576)} MB`;
|
||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||
}
|
||
async function loadQuest() {
|
||
$("questScan").disabled = true; $("questState").textContent = "Looking for your Quest…"; $("questApps").innerHTML = "";
|
||
let d;
|
||
try { d = await api("/api/quest"); }
|
||
catch (e) { $("questState").textContent = e.message; return; }
|
||
finally { $("questScan").disabled = false; }
|
||
quest.serial = d.quest ? d.quest.serial : null;
|
||
$("questCount").textContent = d.quest ? `${d.apps.length} app${d.apps.length === 1 ? "" : "s"}` : "";
|
||
if (!d.quest) { $("questState").textContent = d.guidance || "No Quest found."; return; }
|
||
$("questState").textContent = `${d.quest.model || "Quest"} connected (${d.quest.serial}). Games that use Meta's licence check install with a warning: they may quit or hang on the Frame.`;
|
||
const installed = new Set(androidApps.map(a => a.package));
|
||
$("questApps").innerHTML = d.apps.length ? d.apps.map(a => {
|
||
const bits = [a.split ? `split APK (${a.apks.length} files)` : "single APK"];
|
||
if (a.obb != null) bits.push(`OBB ${questSize(a.obb)}`);
|
||
if (a.data != null) bits.push(`Android/data ${questSize(a.data)}`);
|
||
if (a.meta) bits.push("Meta app: needs Meta's services");
|
||
return `<div class="item"><div class="grow"><div class="t">${esc(a.package)}</div><div class="s">${esc(bits.join(" · "))}</div></div>
|
||
${installed.has(a.package) ? `<span class="tag">On the Frame</span>` : ""}
|
||
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${queued.has("quest:" + a.package) ? "Queued" : installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
|
||
}).join("") : `<div class="sub">No games or apps you installed are on this Quest.</div>`;
|
||
}
|
||
$("questScan").onclick = loadQuest;
|
||
$("questApps").addEventListener("click", e => {
|
||
const b = e.target.closest("[data-quest-import]");
|
||
if (!b) return;
|
||
const pkg = b.dataset.questImport, key = "quest:" + pkg;
|
||
if (installing.has(key)) return;
|
||
b.disabled = true; b.textContent = "Queued";
|
||
const bar = $("questProgress");
|
||
// Several can be queued: the bar and the status line follow whichever is installing.
|
||
runJob(`Import ${pkg} from Quest`, key,
|
||
() => api("/api/quest", { action: "import", package: pkg, serial: quest.serial, data: $("questData").checked, allowDowngrade: $("questOlder").checked }),
|
||
j => { if (j.queued || j.done) return; b.textContent = "Importing…"; bar.hidden = false; bar.dataset.owner = key;
|
||
if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; bar.value = j.percent ?? 0; })
|
||
.then(r => { if (bar.dataset.owner === key) bar.hidden = true; if (r && r.app) { b.textContent = "Imported"; $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
|
||
else { b.disabled = false; b.textContent = "Import";
|
||
if (r !== null) $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } });
|
||
});
|
||
|
||
// ---- My Meta library, Experimental (frame_meta.py): the person's own token, games they own ----
|
||
// The token is only ever in the password box until Connect sends it; the server never sends it back.
|
||
const metaLib = { apps: [], results: new Map(), connected: false };
|
||
const META_VERDICT = { works: ["v-works", "Works on Frame"], maybe: ["v-maybe", "Works with problems"], no: ["v-no", "Doesn't work"] };
|
||
function metaShareNote(s) {
|
||
const box = $("metaShare");
|
||
if (!metaLib.connected || !s) { box.hidden = true; return; }
|
||
box.hidden = false;
|
||
box.innerHTML = s.shared ? "Test results are shared with the compatibility database (Privacy settings)."
|
||
: s.blocked ? `Test results are saved on this computer only (${esc(s.blocked)}).`
|
||
: `Test results are saved on this computer only. <button class="small" id="metaShareOn">Share test results</button>`;
|
||
const on = $("metaShareOn");
|
||
if (on) on.onclick = async () => {
|
||
if (!confirm("Share compatibility results?\n\nYour test results (package, version, whether it ran, and why not) go to "
|
||
+ "Frame Control's shared compatibility database, so everyone sees better verdicts. Never your token, "
|
||
+ "account or game files. You can turn it off in Privacy settings.")) return;
|
||
await setTelemetry({ compat: true });
|
||
loadMeta();
|
||
};
|
||
}
|
||
function renderMeta(d) {
|
||
metaLib.connected = !!d.connected;
|
||
$("metaConnectBox").hidden = metaLib.connected;
|
||
$("metaDisconnect").hidden = $("metaRefresh").hidden = $("metaOlderBox").hidden = !metaLib.connected;
|
||
$("metaState").textContent = metaLib.connected
|
||
? `Connected${d.user && d.user.name ? ` as ${d.user.name}` : ""}.` : "Off. Nothing is sent to Meta until you connect.";
|
||
metaShareNote(d.sharing);
|
||
}
|
||
function renderMetaApps() {
|
||
// Meta lists everything the account owns: games, but also apps and environments.
|
||
$("metaCount").textContent = metaLib.connected ? `${metaLib.apps.length} app${metaLib.apps.length === 1 ? "" : "s"}` : "";
|
||
if (!metaLib.connected) { $("metaApps").innerHTML = ""; return; }
|
||
const installed = new Set(androidApps.map(a => a.package));
|
||
$("metaApps").innerHTML = metaLib.apps.length ? metaLib.apps.map(a => {
|
||
// Sizes first: the line is cut short with "…" in a narrow panel, and the package is the least needed.
|
||
const bits = [a.size ? questSize(a.size) : "", a.data_files ? `OBB ${questSize(a.data_size)}` : "",
|
||
a.version ? `v${a.version}${a.version_code != null ? ` (${a.version_code})` : ""}` : "", a.package || "package unknown"];
|
||
const line = bits.filter(Boolean).join(" · ");
|
||
const problem = a.problem ? `<div class="s v-no" title="${esc(a.problem)}">${esc(a.problem)}</div>` : "";
|
||
// Required game data Frame Control can't place as an OBB (data_plan in frame_meta.py): the game may not run without it.
|
||
const skipped = (a.skipped_files || []).length
|
||
? `<div class="s v-maybe" title="${esc(a.skipped_files.join("\n"))}">Not installed: ${a.skipped_files.length} required data file${a.skipped_files.length === 1 ? "" : "s"} (${esc(a.skipped_files.join(", "))}); the game may not run without ${a.skipped_files.length === 1 ? "it" : "them"}.</div>` : "";
|
||
const v = a.verdict && META_VERDICT[a.verdict.verdict];
|
||
const verdict = v ? `<div class="s ${v[0]}" title="${esc((a.verdict.lines || []).join("\n"))}">Community: ${v[1]}</div>` : "";
|
||
const r = metaLib.results.get(a.package);
|
||
const result = r ? `<div class="s">${esc(r)}</div>` : "";
|
||
const busy = installing.has("meta:" + a.id) ? " disabled" : "", waits = queued.has("meta:" + a.id);
|
||
const on = installed.has(a.package);
|
||
const can = a.package && !a.problem;
|
||
return `<div class="item"><div class="grow"><div class="t">${esc(a.title)}</div><div class="s" title="${esc(line)}">${esc(line)}</div>${problem}${skipped}${verdict}${result}</div>
|
||
${on ? `<span class="tag">On the Frame</span>` : ""}
|
||
${waits ? `<span class="tag">Queued</span>` : ""}
|
||
${can ? `<button class="small" data-meta-install="${esc(a.id)}"${busy}>${on ? "Install again" : "Install"}</button>
|
||
<button class="small" data-meta-install="${esc(a.id)}" data-test="1"${busy} title="Install, start it, watch it for about 30 s and record whether it ran">Install and test</button>` : ""}
|
||
${on ? `<button class="small" data-meta-test="${esc(a.package)}"${busy} title="Start it again, watch it for about 30 s and record whether it ran">Test again</button>` : ""}</div>`;
|
||
}).join("") : `<div class="sub">No Quest apps in this Meta account.</div>`;
|
||
}
|
||
async function loadMeta(refresh = false) {
|
||
let d;
|
||
try { d = await api("/api/meta"); } catch (e) { $("metaState").textContent = e.message; return; }
|
||
renderMeta(d);
|
||
if (!d.connected) { metaLib.apps = []; renderMetaApps(); return; }
|
||
$("metaState").textContent += " Reading your library…";
|
||
try { d = await api(`/api/meta?library=1${refresh ? "&refresh=1" : ""}`); }
|
||
catch (e) { $("metaState").textContent = e.message; return; }
|
||
renderMeta(d);
|
||
metaLib.apps = d.apps || [];
|
||
renderMetaApps();
|
||
}
|
||
// Sign in with Meta: the desktop app only (app/meta-signin.js); a plain browser pastes the token.
|
||
const metaSignInOk = !!(window.frameApp && window.frameApp.meta && window.frameApp.meta.signIn);
|
||
$("metaSignInRow").hidden = !metaSignInOk;
|
||
let metaDisconnecting = false; // from pressing Disconnect until the server has deleted the token
|
||
$("metaAck").onchange = e => {
|
||
$("metaToken").disabled = $("metaConnect").disabled = !e.target.checked;
|
||
$("metaSignIn").disabled = !e.target.checked || metaDisconnecting;
|
||
};
|
||
$("metaSignIn").onclick = async () => {
|
||
if (!$("metaAck").checked || metaDisconnecting) return;
|
||
$("metaSignIn").disabled = true;
|
||
$("metaState").textContent = "Sign in to Meta in the window that opened…";
|
||
try {
|
||
const r = await window.frameApp.meta.signIn(true);
|
||
if (r && r.ok) { toast(r.message || "Connected"); loadMeta(); }
|
||
else { await loadMeta(); $("metaState").textContent = (r && r.error) || "Sign-in didn't finish."; }
|
||
} catch (e) { $("metaState").textContent = e.message; }
|
||
finally { $("metaSignIn").disabled = !$("metaAck").checked || metaDisconnecting; }
|
||
};
|
||
$("metaSignOut").onclick = async () => {
|
||
try { await window.frameApp.meta.signOut(); toast("Signed out of Meta in Frame Control"); }
|
||
catch (e) { toast(e.message, true); }
|
||
};
|
||
$("metaConnect").onclick = async () => {
|
||
const token = $("metaToken").value;
|
||
$("metaConnect").disabled = true;
|
||
try {
|
||
const d = await api("/api/meta", { action: "connect", token, acknowledged: $("metaAck").checked });
|
||
$("metaToken").value = "";
|
||
toast(d.message || "Connected");
|
||
loadMeta();
|
||
} catch (e) { $("metaState").textContent = e.message; }
|
||
finally { $("metaConnect").disabled = !$("metaAck").checked; }
|
||
};
|
||
$("metaDisconnect").onclick = async () => {
|
||
// One Disconnect at a time, guarded from before the confirm until the delete returns.
|
||
if (metaDisconnecting) return;
|
||
metaDisconnecting = true; $("metaDisconnect").disabled = $("metaSignIn").disabled = true;
|
||
if (!confirm("Disconnect your Meta account? Its token is deleted from this computer. Games already installed stay."
|
||
+ (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) {
|
||
metaDisconnecting = false; $("metaDisconnect").disabled = false;
|
||
$("metaSignIn").disabled = !$("metaAck").checked;
|
||
return;
|
||
}
|
||
// No sign-in from here until the token is deleted. Sign-out first: it stops the sign-in
|
||
// window (and any connect it started) before the account is forgotten.
|
||
try {
|
||
if (metaSignInOk) {
|
||
try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); }
|
||
}
|
||
try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); }
|
||
catch (e) { toast(e.message, true); }
|
||
} finally { metaDisconnecting = false; $("metaDisconnect").disabled = false; }
|
||
$("metaAck").checked = false; $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = true;
|
||
metaLib.results.clear(); loadMeta();
|
||
};
|
||
$("metaRefresh").onclick = () => loadMeta(true);
|
||
$("metaApps").addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-meta-install],[data-meta-test]");
|
||
if (!b) return;
|
||
const bar = $("metaProgress");
|
||
if (b.dataset.metaTest) {
|
||
const pkg = b.dataset.metaTest, key = "meta:" + (metaLib.apps.find(a => a.package === pkg) || {}).id;
|
||
if (!confirm("Test it now?\n\nIt opens in the headset for about 30 seconds and records whether it stays up.")) return;
|
||
b.disabled = true;
|
||
const r = await runJob(`Test ${pkg}`, key, () => api("/api/meta", { action: "test", package: pkg }));
|
||
if (r && r.message) metaLib.results.set(pkg, r.message);
|
||
renderMetaApps(); loadReports();
|
||
return;
|
||
}
|
||
const app = metaLib.apps.find(a => a.id === b.dataset.metaInstall), test = !!b.dataset.test;
|
||
if (!app || installing.has("meta:" + app.id)) return;
|
||
if (test && !confirm(`Install and test ${app.title}?\n\nFrame Control starts it in the headset (twice if it has game data to add), `
|
||
+ "watches it for about 30 seconds and records whether it ran.")) return;
|
||
b.disabled = true;
|
||
// Several can be queued: the bar and the status line follow whichever is installing.
|
||
const r = await runJob(`${test ? "Install and test" : "Install"} ${app.title} from Meta`, "meta:" + app.id,
|
||
() => api("/api/meta", { action: "install", app: app.id, test, allowDowngrade: $("metaOlder").checked }),
|
||
j => { renderMetaApps(); if (j.queued || j.done) return; bar.hidden = false; bar.dataset.owner = "meta:" + app.id;
|
||
if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; bar.value = j.percent ?? 0; });
|
||
if (bar.dataset.owner === "meta:" + app.id) bar.hidden = true; // not while another install still shows its progress
|
||
if (r && r.message) { $("metaState").textContent = r.message; metaLib.results.set(app.package, r.test ? r.test.message : r.message); }
|
||
else if (r !== null) $("metaState").textContent = `${app.title} wasn't installed; the reason is in the message log below.`;
|
||
loadAndroid(); renderMetaApps(); if (test) loadReports();
|
||
});
|
||
loadMeta();
|
||
|
||
async function loadCatalog() {
|
||
try { cat.apps = (await api("/api/android/catalog")).apps; }
|
||
catch (e) { $("catGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||
cat.apps.forEach(a => a.hay = [a.n, a.s, a.p, ...a.c].join(" ").toLowerCase());
|
||
const cats = [...new Set(cat.apps.flatMap(a => a.c))].sort();
|
||
$("catCat").innerHTML = `<option value="">All categories</option>` + cats.map(c => `<option>${esc(c)}</option>`).join("");
|
||
$("catCat").value = cat.cat;
|
||
const counts = {}; cat.apps.forEach(a => counts[a.r] = (counts[a.r] || 0) + 1);
|
||
$("catChips").innerHTML = Object.keys(VLABEL).map(k =>
|
||
`<button class="chip vchip v-${k}" data-v="${k}" aria-pressed="${cat.v.has(k)}">${VLABEL[k]} (${(counts[k] || 0).toLocaleString()})</button>`).join("");
|
||
filterCatalog();
|
||
}
|
||
|
||
function filterCatalog() {
|
||
const terms = cat.q.toLowerCase().split(/\s+/).filter(Boolean);
|
||
cat.results = cat.apps.filter(a => cat.v.has(a.r) && (!cat.cat || a.c.includes(cat.cat)) && terms.every(t => a.hay.includes(t)));
|
||
if (terms.length) {
|
||
const t = terms.join(" "), score = a => { const n = a.n.toLowerCase(); return n === t ? 0 : n.startsWith(t) ? 1 : n.includes(t) ? 2 : 3; };
|
||
const order = Object.keys(VLABEL);
|
||
cat.results.sort((x, y) => order.indexOf(x.r) - order.indexOf(y.r) || score(x) - score(y));
|
||
}
|
||
$("catCount").textContent = `${cat.results.length.toLocaleString()} of ${cat.apps.length.toLocaleString()}`;
|
||
$("catGrid").innerHTML = ""; cat.shown = 0; moreCatalog();
|
||
}
|
||
|
||
function moreCatalog() {
|
||
const installed = new Set(androidApps.map(a => a.package));
|
||
const next = cat.results.slice(cat.shown, cat.shown + 48);
|
||
$("catGrid").insertAdjacentHTML("beforeend", next.map(a => `
|
||
<div class="cat-card">
|
||
<div class="top">
|
||
${a.i ? `<img class="and-icon" loading="lazy" src="${esc(a.i)}" alt="">` : `<div class="and-icon"></div>`}
|
||
<div style="min-width:0"><div class="t">${esc(a.n)}</div><div class="s">v${esc(String(a.v || "").replace(/^v/i, ""))} · ${gb(a.z || 0)}</div></div>
|
||
<span class="vbadge v-${a.r}">${VLABEL[a.r]}</span>
|
||
</div>
|
||
<div class="s">${esc(a.s)}</div>
|
||
<div class="why">${esc(a.why[0] || "")}${a.why.length > 1 ? `<span title="${esc(a.why.slice(1).join("\n"))}"> · more</span>` : ""}</div>
|
||
<div class="row">
|
||
${installed.has(a.p) ? `<span class="tag">Installed</span>`
|
||
: installing.has(a.p) ? `<span class="tag">Installing…</span>`
|
||
: a.r === "no" ? "" : `<button class="action small" data-and="install" data-pkg="${esc(a.p)}" data-name="${esc(a.n)}">Install</button>`}
|
||
<button class="small" data-and="rate" data-pkg="${esc(a.p)}" data-ver="${esc(a.v)}" data-name="${esc(a.n)}" data-src="F-Droid" title="Report how it worked">Report</button>
|
||
<a class="small" href="https://f-droid.org/packages/${esc(a.p)}/" target="_blank" rel="noopener">F-Droid</a>
|
||
</div>
|
||
</div>`).join(""));
|
||
cat.shown += next.length;
|
||
$("catMore").hidden = cat.shown >= cat.results.length;
|
||
}
|
||
|
||
$("catMore").onclick = moreCatalog;
|
||
let catTimer;
|
||
$("catQ").oninput = e => { clearTimeout(catTimer); catTimer = setTimeout(() => { cat.q = e.target.value; filterCatalog(); }, 150); };
|
||
$("catCat").onchange = e => { cat.cat = e.target.value; filterCatalog(); };
|
||
$("catChips").onclick = e => {
|
||
const b = e.target.closest("[data-v]"); if (!b) return;
|
||
cat.v.has(b.dataset.v) ? cat.v.delete(b.dataset.v) : cat.v.add(b.dataset.v);
|
||
b.setAttribute("aria-pressed", cat.v.has(b.dataset.v)); filterCatalog();
|
||
};
|
||
document.body.addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-and]"); if (!b) return;
|
||
const pkg = b.dataset.pkg, action = b.dataset.and;
|
||
if (androidPending.has(pkg)) return;
|
||
if (action === 'rate') {
|
||
$('installedDetail').close();
|
||
openReport({package:pkg, version:b.dataset.ver, label:b.dataset.name, source:b.dataset.src});
|
||
return;
|
||
}
|
||
const actionGeneration = devGen;
|
||
androidPending.add(pkg);
|
||
b.disabled = true;
|
||
const originalLabel = b.textContent;
|
||
const primaryAction = action === 'launch' || action === 'stop';
|
||
if (primaryAction) {
|
||
for (const button of new Set([b, ...document.querySelectorAll('[data-and="launch"], [data-and="stop"]')])) {
|
||
if (button.dataset.pkg !== pkg) continue;
|
||
button.disabled = true; button.textContent = 'Wait…'; button.ariaBusy = 'true';
|
||
}
|
||
}
|
||
try {
|
||
if (action === "install") {
|
||
if (installing.has(pkg)) return;
|
||
toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
|
||
const done = runJob(`Install ${b.dataset.name}`, pkg, () => api("/api/android", { action, package: pkg }));
|
||
b.outerHTML = `<span class="tag">Installing…</span>`;
|
||
const res = await done;
|
||
if (res && res.app) await offerTest(res.app);
|
||
} else if (action === "remove") {
|
||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||
} else if (action === "refresh-art") {
|
||
await runJob(`Add Steam artwork for ${b.dataset.name}`, "art-" + pkg, () => api("/api/android", { action, package: pkg }));
|
||
} else if (action === "rate") {
|
||
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
||
return;
|
||
} else {
|
||
if (action === "probe") toast(`Testing ${pkg}: launching it and watching for 20 s…`);
|
||
await act(`${action[0].toUpperCase() + action.slice(1)} ${pkg}`, () => api("/api/android", { action, package: pkg }), primaryAction ? null : b);
|
||
if (action === "probe") { loadCatalog(); loadReports(); }
|
||
}
|
||
await loadAndroid();
|
||
} finally {
|
||
b.disabled = false;
|
||
if (primaryAction) { b.textContent = originalLabel; b.ariaBusy = 'false'; }
|
||
if (actionGeneration === devGen) {
|
||
androidPending.delete(pkg); renderLibrary();
|
||
if ($('installedDetail').open && androidApps.some(a => a.package === installedPackage)) openInstalledApp(installedPackage);
|
||
}
|
||
}
|
||
if (cat.apps) { const y = scroller().scrollTop; filterCatalog(); scrollToY(y); }
|
||
});
|
||
loadAndroid();
|
||
|
||
// ---- Android display (wm size / wm density / font_scale per Lepton instance) ----
|
||
const disp = { list: [], port: null, error: null, seq: 0 };
|
||
const DPI = [120, 640];
|
||
const dims = s => { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? [+m[1], +m[2]] : null; };
|
||
const clampDpi = d => Math.max(DPI[0], Math.min(DPI[1], Math.round(d)));
|
||
function dispLabel(i) {
|
||
const apps = (i.packages || []).map(p => i.labels && i.labels[p] ? `${i.labels[p]} (${p})` : p);
|
||
const dev = i.container === "lepton-dev";
|
||
const what = apps.length ? apps.join(", ") : "no apps installed";
|
||
return `${dev ? "Lepton Development: " : ""}${what} · port ${i.port}`;
|
||
}
|
||
function dispCur() { return disp.list.find(i => i.port === disp.port); }
|
||
// Derived values: the density that looks "default" at the current resolution,
|
||
// and how far the user has moved from it.
|
||
function dispCalc(i) {
|
||
const phys = dims(i.physicalSize), size = dims(i.overrideSize) || phys;
|
||
const physD = i.physicalDensity, curD = i.overrideDensity || physD;
|
||
const factor = phys && size ? size[0] / phys[0] : 1;
|
||
const baseD = physD ? physD * factor : null;
|
||
return { phys, size, physD, curD, factor, baseD, userScale: baseD ? curD / baseD : 1 };
|
||
}
|
||
async function loadDisplays() {
|
||
$("dispReload").disabled = true;
|
||
const seq = ++disp.seq; // an older, slower request mustn't overwrite a newer answer
|
||
let list = [], error = null;
|
||
try {
|
||
list = (await api("/api/android/displays")).instances;
|
||
} catch (e) {
|
||
error = e;
|
||
if (!e.offline) log("Android display: " + e.message, "e");
|
||
} finally { $("dispReload").disabled = false; }
|
||
if (seq !== disp.seq) return;
|
||
disp.list = list; disp.error = error;
|
||
if (!disp.list.some(i => i.port === disp.port)) disp.port = disp.list.length ? disp.list[0].port : null;
|
||
const sel = $("dispSel");
|
||
sel.innerHTML = disp.list.length ? disp.list.map(i => `<option value="${i.port}">${esc(dispLabel(i))}</option>`).join("")
|
||
: `<option value="">${disp.error ? (disp.error.offline ? "Waiting for the Frame" : "Couldn't read Android's display")
|
||
: "No Android app is open"}</option>`;
|
||
sel.disabled = !disp.list.length;
|
||
// Open-an-app advice only fits a successful, empty answer; otherwise say what went wrong.
|
||
$("dispEmpty").textContent = !disp.error ? "Open an Android app in the headset, then Refresh, to change its resolution, UI scale and text size here."
|
||
: disp.error.offline ? "This fills in when the Frame answers." : disp.error.message;
|
||
$("dispEmpty").hidden = !!disp.list.length;
|
||
$("dispCtl").hidden = !disp.list.length;
|
||
if (disp.port) sel.value = String(disp.port);
|
||
renderDisplay();
|
||
}
|
||
function renderDisplay() {
|
||
const i = dispCur();
|
||
const btns = document.querySelectorAll("#display .seg button, #dispDpiSet, #dispReset, #dispDpi");
|
||
btns.forEach(b => b.disabled = !i || !!i.error);
|
||
document.querySelectorAll("#display .seg button").forEach(b => b.classList.remove("on"));
|
||
const set = (id, v, d) => { $(id).textContent = v; $(id + "D").textContent = d || " "; };
|
||
if (!i || i.error) {
|
||
set("dispSize", "—", i ? i.error : ""); set("dispDens", "—"); set("dispFont", "—");
|
||
return;
|
||
}
|
||
const c = dispCalc(i);
|
||
set("dispSize", c.size ? `${c.size[0]}×${c.size[1]}` : "—", i.overrideSize ? `panel is ${i.physicalSize}` : "native");
|
||
set("dispDens", `${c.curD ?? "—"} dpi`, i.overrideDensity ? `default ${i.physicalDensity}` : "default");
|
||
const fs = i.fontScale ?? 1;
|
||
set("dispFont", `${fs === 1 ? "1.0" : fs}×`, i.fontScale == null ? "default" : "custom");
|
||
$("dispDpi").value = c.curD ?? "";
|
||
const res = c.factor === 1 ? "native" : c.size && c.size[0] === 2560 && c.size[1] === 1440 ? "sharp" : "";
|
||
document.querySelectorAll("[data-res]").forEach(b => b.classList.toggle("on", b.dataset.res === res));
|
||
document.querySelectorAll("[data-scale]").forEach(b => b.classList.toggle("on", c.baseD && clampDpi(c.baseD * +b.dataset.scale) === c.curD));
|
||
document.querySelectorAll("[data-font]").forEach(b => b.classList.toggle("on", Math.abs(+b.dataset.font - fs) < 0.001));
|
||
}
|
||
async function applyDisplay(label, change, btn) {
|
||
const i = dispCur(); if (!i) return;
|
||
const res = await act(`${label} (port ${i.port})`, () => api("/api/android/display", { port: i.port, ...change }), btn);
|
||
if (res && res.display) { Object.assign(i, res.display); renderDisplay(); }
|
||
}
|
||
$("dispReload").onclick = loadDisplays;
|
||
$("dispSel").onchange = e => { disp.port = +e.target.value || null; renderDisplay(); };
|
||
$("dispRes").onclick = e => {
|
||
const b = e.target.closest("[data-res]"), i = dispCur(); if (!b || !i) return;
|
||
const c = dispCalc(i); if (!c.phys || !c.physD) return toast("Unknown panel size", true);
|
||
// Keep the user's relative UI scale while changing how many pixels there are.
|
||
if (b.dataset.res === "native") {
|
||
const d = clampDpi(c.physD * c.userScale);
|
||
applyDisplay("Native resolution", { size: "reset", density: d === c.physD ? "reset" : d }, b);
|
||
} else {
|
||
applyDisplay("Sharp resolution", { size: "2560x1440", density: clampDpi(c.physD * (2560 / c.phys[0]) * c.userScale) }, b);
|
||
}
|
||
};
|
||
$("dispScale").onclick = e => {
|
||
const b = e.target.closest("[data-scale]"), i = dispCur(); if (!b || !i) return;
|
||
const c = dispCalc(i); if (!c.baseD) return toast("Unknown default density", true);
|
||
const d = clampDpi(c.baseD * +b.dataset.scale);
|
||
applyDisplay(`UI scale ${b.textContent}`, { density: c.factor === 1 && d === c.physD ? "reset" : d }, b);
|
||
};
|
||
$("dispDpiSet").onclick = () => {
|
||
const d = Number($("dispDpi").value);
|
||
if (!Number.isInteger(d) || d < DPI[0] || d > DPI[1]) return toast(`Density must be a whole number ${DPI[0]}–${DPI[1]}`, true);
|
||
applyDisplay(`Density ${d} dpi`, { density: d }, $("dispDpiSet"));
|
||
};
|
||
$("dispDpi").addEventListener("keydown", e => { if (e.key === "Enter") $("dispDpiSet").click(); });
|
||
$("dispText").onclick = e => {
|
||
const b = e.target.closest("[data-font]"); if (!b) return;
|
||
const f = +b.dataset.font;
|
||
applyDisplay(`Text size ${f}×`, { fontScale: f === 1 ? "reset" : f }, b);
|
||
};
|
||
$("dispReset").onclick = () => applyDisplay("Reset Android display", { size: "reset", density: "reset", fontScale: "reset" }, $("dispReset"));
|
||
loadDisplays();
|
||
|
||
|
||
// ---- compatibility reports: any APK, did it work ----
|
||
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
|
||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||
let repShared = false;
|
||
function setRepHint() {
|
||
$("repHint").textContent = repShared
|
||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||
: telemetry.compat && !telemetry.blocked
|
||
? "Reports change the verdicts you see and are shared with Frame Control's compatibility database (Privacy settings). Any APK can be reported, including ones not on F-Droid."
|
||
: "Reports are saved on this computer and change the verdicts you see. Turn on Share compatibility results in Privacy settings to add them to the shared database. Any APK can be reported, including ones not on F-Droid.";
|
||
}
|
||
const REPORTS_SHOWN = 5;
|
||
let repsAll = false;
|
||
$("repMore").onclick = () => { repsAll = true; loadReports(); };
|
||
async function loadReports() {
|
||
let reps, shared;
|
||
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||
repShared = shared; setRepHint();
|
||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||
$("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
|
||
$("repMore").textContent = `Show all ${reps.length}`;
|
||
$("repList").innerHTML = reps.length ? (repsAll ? reps : reps.slice(0, REPORTS_SHOWN)).map(r => {
|
||
const k = r.rating || r.result;
|
||
return `<div class="item rep-item"><div class="grow">
|
||
<div class="t">${esc(r.name)} <span class="sub">v${esc(String(r.version || "?").replace(/^v/i, ""))}</span></div>
|
||
<div class="s">${esc(r.package)} · ${esc((r.date || "").slice(0, 10))} · ${esc(r.via === "user" ? "you" : r.via || "")}${r.runtime ? " · " + esc(r.runtime) : ""}${r.source ? " · " + esc(r.source) : ""}</div>
|
||
${r.notes ? `<div class="s">${esc(r.notes)}</div>` : ""}</div>
|
||
<span class="vbadge v-${RCLASS[k] || "no"}">${esc(RLABEL[k] || k || "")}</span></div>`;
|
||
}).join("") : `<div class="sub">No reports yet.</div>`;
|
||
}
|
||
function openReport(p = {}) {
|
||
const f = $("repForm");
|
||
f.reset();
|
||
$("repPkg").value = p.package || ""; $("repVer").value = p.version || "";
|
||
$("repLabel").value = p.label || ""; $("repSrc").value = p.source || "";
|
||
$("repMsg").textContent = ""; $("repFileNote").textContent = "";
|
||
const known = new Map();
|
||
androidApps.forEach(a => known.set(a.package, a.label));
|
||
(cat.apps || []).slice(0, 4000).forEach(a => known.has(a.p) || known.set(a.p, a.n));
|
||
$("repPkgs").innerHTML = [...known].map(([p, n]) => `<option value="${esc(p)}">${esc(n)}</option>`).join("");
|
||
$("repDlg").showModal();
|
||
(p.package ? document.querySelector('input[name="repRating"]') : $("repPkg")).focus();
|
||
}
|
||
$("repNew").onclick = () => openReport();
|
||
$("repCancel").onclick = () => $("repDlg").close();
|
||
$("repPkg").onchange = () => {
|
||
const pkg = $("repPkg").value.trim();
|
||
const a = androidApps.find(x => x.package === pkg), c = cat.apps && cat.apps.find(x => x.p === pkg);
|
||
if (!$("repLabel").value) $("repLabel").value = (a && a.label) || (c && c.n) || "";
|
||
if (!$("repVer").value) $("repVer").value = (a && a.version) || (c && c.v) || "";
|
||
};
|
||
$("repPick").onclick = () => $("repFile").click();
|
||
$("repFile").onchange = async () => {
|
||
const file = $("repFile").files[0]; if (!file) return;
|
||
$("repFileNote").textContent = "Reading…";
|
||
try {
|
||
const { apk } = await upload(file, "apkinfo");
|
||
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
|
||
if (!$("repSrc").value) $("repSrc").value = file.name;
|
||
if (apk.blocker) checkApkAlternatives(apk);
|
||
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
|
||
} catch (e) { $("repFileNote").textContent = e.message; }
|
||
$("repFile").value = "";
|
||
};
|
||
$("repForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const rating = document.querySelector('input[name="repRating"]:checked');
|
||
if (!$("repPkg").checkValidity()) return ($("repMsg").textContent = "Enter the package name, e.g. org.example.app");
|
||
if (!rating) return ($("repMsg").textContent = "Choose whether it worked");
|
||
const body = { action: "report", package: $("repPkg").value.trim(), version: $("repVer").value.trim(),
|
||
label: $("repLabel").value.trim(), source: $("repSrc").value.trim(), notes: $("repNotes").value.trim(),
|
||
rating: rating.value, runtime: document.querySelector('input[name="repRuntime"]:checked').value };
|
||
$("repSave").disabled = true;
|
||
try {
|
||
const res = await api("/api/android", body);
|
||
$("repDlg").close(); toast(res.message); log(res.message, "ok");
|
||
loadReports(); loadCatalog();
|
||
} catch (err) { $("repMsg").textContent = err.message; }
|
||
finally { $("repSave").disabled = false; }
|
||
};
|
||
loadReports();
|
||
api("/api/host").then(applyHostWording).catch(() => {});
|
||
|
||
// ---- Steam screenshots from the headset ----
|
||
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
|
||
// that finds a new shot fetches only that one's thumbnail.
|
||
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
|
||
const STEAMVR_APPID = "250820";
|
||
const SHOTS_POLL_MS = 8000;
|
||
function shotApp(appid) {
|
||
if (appid === STEAMVR_APPID) return "SteamVR";
|
||
const g = state?.games?.find(x => x.appid === appid);
|
||
return g ? g.name : `App ${appid}`;
|
||
}
|
||
async function shotBlob(id, thumb) {
|
||
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
|
||
{ headers: {"X-Frame-UI": UI_KEY} });
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||
return r.blob();
|
||
}
|
||
function shotThumb(id) {
|
||
const thumbs = shots.thumbs; // this headset's: a late failure must not touch the next one's
|
||
if (!thumbs.has(id)) {
|
||
const p = shotBlob(id, true).then(b => URL.createObjectURL(b));
|
||
p.catch(() => { if (thumbs.get(id) === p) thumbs.delete(id); }); // try again next time
|
||
thumbs.set(id, p);
|
||
}
|
||
return thumbs.get(id);
|
||
}
|
||
// quiet: a background check. It keeps what's shown if the Frame can't be read,
|
||
// and redraws only when the shots (or whether they're saved here) changed.
|
||
function loadShots(quiet) {
|
||
if (shots.loading) {
|
||
// A check already on its way will do for another check; Refresh, or a save
|
||
// that just finished, reads again after it so the answer is a fresh one.
|
||
const again = () => loadShots();
|
||
return quiet === true ? shots.loading : shots.loading.then(again, again);
|
||
}
|
||
const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; });
|
||
return shots.loading = p;
|
||
}
|
||
// Forget the shots of a headset we've switched away from, and ignore its answers still on their way.
|
||
function resetShots() {
|
||
closeMenu(); // its items were about the other headset's shot
|
||
shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null;
|
||
for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {});
|
||
shots.thumbs = new Map();
|
||
}
|
||
async function readShots(quiet) {
|
||
const gen = shots.gen;
|
||
if (!quiet) $("shotsRefresh").disabled = true;
|
||
let got;
|
||
try {
|
||
got = await api("/api/shots");
|
||
} catch (e) {
|
||
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
|
||
return;
|
||
} finally { $("shotsRefresh").disabled = false; }
|
||
if (gen !== shots.gen) return;
|
||
({ shots: shots.list, folder: shots.folder } = got);
|
||
const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)]));
|
||
if (sig !== shots.sig) { shots.sig = sig; drawShots(); }
|
||
fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails
|
||
}
|
||
// Thumbnails one at a time over the shared SSH connection. One that failed is
|
||
// tried again on Refresh, not by every background check.
|
||
async function fillThumbs(quiet) {
|
||
const run = ++shots.fill;
|
||
// Refresh puts every failed one back in line first, so a background pass that takes over carries them all on.
|
||
if (!quiet) for (const img of document.querySelectorAll("#shotGrid img[data-failed]")) delete img.dataset.failed;
|
||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) {
|
||
const s = shots.list[+img.dataset.shot];
|
||
let url;
|
||
try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; }
|
||
if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over
|
||
if (url) img.src = url;
|
||
}
|
||
}
|
||
// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip).
|
||
function shotTime(t) {
|
||
return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||
}
|
||
function drawShots() {
|
||
const ids = new Set(shots.list.map(s => s.id));
|
||
for (const [id, p] of shots.thumbs) {
|
||
if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); }
|
||
}
|
||
const unsaved = shots.list.filter(s => !s.saved).length;
|
||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card" data-card="${i}">
|
||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer (right-click for more)">
|
||
<div class="row"><div class="grow">
|
||
<div class="t">${esc(shotApp(s.appid))}</div>
|
||
<div class="s" title="${esc(new Date(s.time * 1000).toLocaleString())}">${esc(shotTime(s.time))}</div></div>
|
||
<button class="small" data-shot-copy="${i}" title="Copy the image to the clipboard">Copy</button>
|
||
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||
</div></div>`).join("")
|
||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||
}
|
||
async function openShot(s) {
|
||
if (live) toggleLive(false);
|
||
const gen = ++viewGen;
|
||
$("viewer").classList.add("busy");
|
||
let url = null;
|
||
try {
|
||
const blob = await shotBlob(s.id, false);
|
||
url = URL.createObjectURL(blob);
|
||
const img = new Image();
|
||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||
if (gen !== viewGen) return;
|
||
lastImg = img; lastSource = "shot"; lastShot = { blob, file: s.file };
|
||
desk.shown = null; // a screenshot, not a panel: Control has nothing to act on here
|
||
draw();
|
||
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
|
||
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
|
||
$("saveBtn").disabled = $("copyBtn").disabled = false;
|
||
location.hash = "view";
|
||
showPage();
|
||
} catch (e) {
|
||
if (gen === viewGen) toast("Couldn't open the screenshot: " + e.message, true);
|
||
} finally {
|
||
if (url) URL.revokeObjectURL(url);
|
||
if (gen === viewGen) $("viewer").classList.remove("busy");
|
||
}
|
||
}
|
||
async function saveShots(list, btn) {
|
||
if (!list.length) return;
|
||
if (HOST.mobile && savesToDevice()) {
|
||
return act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||
async () => saveToDevice(await Promise.all(list.map(async s => ({ blob: await shotBlob(s.id, false), name: s.file })))), btn);
|
||
}
|
||
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||
if (res) loadShots();
|
||
}
|
||
const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn);
|
||
$("shotGrid").onclick = e => {
|
||
const img = e.target.closest("img[data-shot]");
|
||
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||
const c = e.target.closest("[data-shot-copy]");
|
||
if (c) return copyShot(shots.list[+c.dataset.shotCopy], c);
|
||
const b = e.target.closest("[data-shot-save]");
|
||
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||
};
|
||
// A small right-click menu. items: [label, fn] pairs, null for a divider.
|
||
// back: the keyboard closed it, so focus returns to where it was.
|
||
function closeMenu(back) {
|
||
const menu = document.querySelector(".ctx-menu");
|
||
if (!menu) return;
|
||
menu.remove();
|
||
if (back === true && menu.opener?.isConnected) menu.opener.focus();
|
||
}
|
||
function showMenu(e, items) {
|
||
closeMenu();
|
||
const menu = document.createElement("div");
|
||
menu.className = "ctx-menu";
|
||
menu.setAttribute("role", "menu");
|
||
menu.opener = document.activeElement;
|
||
for (const it of items) {
|
||
if (!it) { menu.append(document.createElement("hr")); continue; }
|
||
const b = document.createElement("button");
|
||
b.textContent = it[0];
|
||
b.setAttribute("role", "menuitem");
|
||
b.onclick = () => { closeMenu(); it[1](); };
|
||
menu.append(b);
|
||
}
|
||
(document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer
|
||
const r = menu.getBoundingClientRect();
|
||
menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px";
|
||
menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px";
|
||
menu.querySelector("button").focus();
|
||
}
|
||
document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true);
|
||
document.addEventListener("keydown", e => {
|
||
const menu = document.querySelector(".ctx-menu");
|
||
if (!menu) return;
|
||
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); return closeMenu(true); } // Tab doesn't wander off behind it
|
||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||
e.preventDefault(); // arrows move through the menu, not the page
|
||
const items = [...menu.querySelectorAll("button")];
|
||
const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1;
|
||
items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus();
|
||
});
|
||
addEventListener("blur", closeMenu);
|
||
document.addEventListener("fullscreenchange", closeMenu);
|
||
addEventListener("scroll", closeMenu, true);
|
||
// Right-click a screenshot for everything it can do.
|
||
$("shotGrid").oncontextmenu = e => {
|
||
const card = e.target.closest("[data-card]");
|
||
if (!card) return;
|
||
e.preventDefault();
|
||
const s = shots.list[+card.dataset.card];
|
||
const items = [
|
||
["Open in viewer", () => openShot(s)],
|
||
["Copy image", () => copyShot(s)],
|
||
];
|
||
if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]);
|
||
else {
|
||
const sep = shots.folder.includes("\\") ? "\\" : "/";
|
||
items.push(null);
|
||
items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`,
|
||
() => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]);
|
||
items.push(["Copy file path", () => act("Copy file path", async () => {
|
||
await copyText(shots.folder + sep + s.file);
|
||
return { message: "Copied the file path" };
|
||
})]);
|
||
}
|
||
items.push(null, ["Copy file name", () => act("Copy file name", async () => {
|
||
await copyText(s.file);
|
||
return { message: "Copied " + s.file };
|
||
})]);
|
||
showMenu(e, items);
|
||
};
|
||
function copyText(text) {
|
||
if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here");
|
||
return navigator.clipboard.writeText(text);
|
||
}
|
||
$("shotsRefresh").onclick = () => loadShots();
|
||
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
|
||
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
|
||
// Watch for new shots: a cheap listing over the shared SSH connection while the
|
||
// window is visible and the Frame is reachable, and again on coming back to it.
|
||
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
|
||
|
||
// ---- Copying new screenshots and clips here on their own (ui/frame_autosync.py) ----
|
||
const autoSync = { s: null, copied: null };
|
||
const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
||
function renderAutoSync(s) {
|
||
autoSync.s = s;
|
||
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
|
||
$("syncResume").hidden = !s.paused;
|
||
const on = s.screenshots || s.clips, lines = [];
|
||
if (s.paused) lines.push(s.paused);
|
||
else if (s.error) lines.push(`Last problem: ${s.error}`);
|
||
if (s.running || s.pending) lines.push("Copying…");
|
||
if (s.copied_time) lines.push(`Last copied ${s.copied} file${s.copied === 1 ? "" : "s"} on ${syncWhen(s.copied_time)}.`);
|
||
if (on && s.checked) lines.push(`Last checked ${syncWhen(s.checked)}.`);
|
||
if (on && s.waiting) lines.push(`${s.waiting} clip${s.waiting === 1 ? " is" : "s are"} still being written; copied once Steam finishes.`);
|
||
if (on && s.since) lines.push(`Copying what's new since ${syncWhen(s.since)}.`);
|
||
$("syncStatus").textContent = lines.length ? lines.join(" ") : "Off.";
|
||
for (const [id, key, what] of [["shotsSyncNote", "screenshots", "New screenshots"], ["clipsSyncNote", "clips", "New Steam clips"]]) {
|
||
$(id).hidden = !s[key] || !!HOST.mobile;
|
||
$(id).innerHTML = s.paused ? `${esc(s.paused)} <a href="#autoSync">Settings</a>`
|
||
: `${what} are copied to this ${esc(HOST.computer)} automatically${s.copied_time ? `; last copy ${esc(syncWhen(s.copied_time))}` : ""}. <a href="#autoSync">Change</a>`;
|
||
}
|
||
// Something new arrived here: show it as saved.
|
||
if (autoSync.copied !== null && s.copied_time !== autoSync.copied) { loadShots(true); if (!HOST.mobile) loadClips(); }
|
||
autoSync.copied = s.copied_time;
|
||
}
|
||
async function loadAutoSync() {
|
||
try { renderAutoSync(await api("/api/autosync")); } catch {} // not on the iPhone app's server
|
||
}
|
||
async function setAutoSync(body, label) {
|
||
try { renderAutoSync(await api("/api/autosync", body)); }
|
||
catch (e) { toast(`${label} failed: ${e.message}`, true); loadAutoSync(); }
|
||
}
|
||
$("syncShots").onchange = e => setAutoSync({ action: "settings", screenshots: e.target.checked }, "Saving");
|
||
$("syncClips").onchange = e => setAutoSync({ action: "settings", clips: e.target.checked }, "Saving");
|
||
$("syncNow").onclick = async e => { const r = await act("Copy everything now", () => api("/api/autosync", { action: "now" }), e.currentTarget); if (r) renderAutoSync(r); };
|
||
$("syncResume").onclick = async e => { const r = await act("Resume copying", () => api("/api/autosync", { action: "resume" }), e.currentTarget); if (r) renderAutoSync(r); };
|
||
loadAutoSync();
|
||
setInterval(() => { const s = autoSync.s; if (!document.hidden && s && (s.screenshots || s.clips || s.pending || s.running)) loadAutoSync(); }, 20000);
|
||
|
||
// ---- Recording the headset view, and Steam's own clips (server.py, frame_clips.py) ----
|
||
// A recording runs on the Frame until Stop; the server then copies the .mp4 here
|
||
// and the page follows it by polling /api/record.
|
||
const rec = { state: "idle", timer: null };
|
||
const clock = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
|
||
function showRec(s) {
|
||
const was = rec.state;
|
||
rec.state = s.state;
|
||
const busy = ["starting", "recording", "stopping", "saving"].includes(s.state);
|
||
const b = $("recBtn");
|
||
b.textContent = s.state === "recording" ? "Stop" : "Record";
|
||
b.classList.toggle("on", s.state === "recording");
|
||
b.disabled = busy && s.state !== "recording";
|
||
const line = $("recStatus");
|
||
line.textContent = s.state === "recording" ? `Recording ${clock(s.elapsed || 0)} (stops by itself at ${clock(s.limit)})` +
|
||
(s.audio ? "" : ". No sound: the Frame's audio couldn't be read, so this is video only.")
|
||
: s.state === "starting" ? "Starting the recording…"
|
||
: s.state === "stopping" ? "Stopping the recording…"
|
||
: s.state === "saving" ? `Copying the recording to this ${HOST.computer}…`
|
||
: s.state === "saved" ? s.message
|
||
: s.state === "failed" ? `Recording failed: ${s.error}` : "";
|
||
line.hidden = !line.textContent;
|
||
if (was !== s.state && was !== "idle" && s.state === "saved") { log(s.message, "ok"); toast(s.message); }
|
||
if (was !== s.state && was !== "idle" && was !== "starting" && s.state === "failed") { log(line.textContent, "e"); toast(line.textContent, true); }
|
||
clearTimeout(rec.timer);
|
||
if (busy) rec.timer = setTimeout(pollRec, 1000);
|
||
}
|
||
async function pollRec() {
|
||
if (HOST.mobile) return;
|
||
clearTimeout(rec.timer);
|
||
let s;
|
||
try { s = await api("/api/record"); }
|
||
catch { if (rec.state !== "idle") rec.timer = setTimeout(pollRec, 3000); return; }
|
||
showRec(s);
|
||
}
|
||
$("recBtn").onclick = async e => {
|
||
const start = rec.state !== "recording";
|
||
const res = await act(start ? "Start recording" : "Stop recording",
|
||
() => api("/api/record", { action: start ? "start" : "stop" }), e.currentTarget);
|
||
if (res) showRec(res); else pollRec();
|
||
};
|
||
|
||
const clipsState = { list: [], gen: 0, exporting: new Set() };
|
||
async function loadClips() {
|
||
if (HOST.mobile) return;
|
||
const gen = clipsState.gen;
|
||
$("clipsRefresh").disabled = true;
|
||
let got;
|
||
try { got = await api("/api/clips"); }
|
||
catch (e) { if (gen === clipsState.gen) failed($("clipList"), e); return; }
|
||
finally { $("clipsRefresh").disabled = false; }
|
||
if (gen !== clipsState.gen) return;
|
||
clipsState.list = got.clips;
|
||
drawClips();
|
||
}
|
||
// Forget the clips of a headset we've switched away from.
|
||
function resetClips() {
|
||
clipsState.gen++; clipsState.list = [];
|
||
$("clipCount").textContent = "";
|
||
$("clipList").innerHTML = `<div class="sub">Loading…</div>`;
|
||
}
|
||
const megabytes = n => n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : `${Math.max(1, Math.round(n / 1e6))} MB`;
|
||
function drawClips() {
|
||
const list = clipsState.list;
|
||
$("clipCount").textContent = list.length ? `${list.length} on the Frame` : "";
|
||
$("clipList").innerHTML = list.length ? list.map((c, i) => `<div class="item"><div class="grow">
|
||
<div class="t">${esc(c.appid ? shotApp(c.appid) : "Steam recording")} · ${c.kind === "clip" ? "Clip" : "Background recording"}</div>
|
||
<div class="s" title="${esc(new Date(c.time * 1000).toLocaleString())}">${esc([shotTime(c.time), c.duration ? clock(c.duration) : "",
|
||
megabytes(c.size), c.gaps ? `${c.gaps} pieces missing` : ""].filter(Boolean).join(" · "))}</div></div>
|
||
${c.saved ? `<span class="tag">On ${HOST.computer}</span>` : ""}
|
||
<button class="small" data-clip-export="${i}"${clipsState.exporting.has(c.id) ? " disabled" : ""}>${c.saved ? "Export again" : "Export"}</button>
|
||
</div>`).join("")
|
||
: `<div class="sub">No Steam clips or background recordings on the Frame.</div>`;
|
||
}
|
||
async function exportClip(c, btn) {
|
||
const label = `Export ${c.file}`;
|
||
clipsState.exporting.add(c.id); btn.disabled = true;
|
||
log(label + "…"); toast(label + "…");
|
||
try {
|
||
const { job } = await api("/api/clips/export", { id: c.id });
|
||
for (let misses = 0; ;) {
|
||
await new Promise(ok => setTimeout(ok, 1500));
|
||
let j;
|
||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||
if (!j.done) continue;
|
||
if (j.error) throw new Error(j.error);
|
||
log(j.message, "ok"); toast(j.message);
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||
} finally { clipsState.exporting.delete(c.id); loadClips(); }
|
||
}
|
||
$("clipList").onclick = e => {
|
||
const b = e.target.closest("[data-clip-export]");
|
||
if (b) exportClip(clipsState.list[+b.dataset.clipExport], b);
|
||
};
|
||
$("clipsRefresh").onclick = () => loadClips();
|
||
$("videosFolder").onclick = e => act($("videosFolder").textContent, () => api("/api/open", { what: "videos" }), e.currentTarget);
|
||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
|
||
|
||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||
let panelSeq = 0;
|
||
async function loadPanelSwitcher() {
|
||
const seq = ++panelSeq;
|
||
try {
|
||
const s = await api("/api/panels", {action: "list"});
|
||
if (seq !== panelSeq) return;
|
||
$("panelList").innerHTML = s.panels.length ? s.panels.map(p =>
|
||
`<div class="item"><div class="grow"><div class="t">${esc(p.title)}</div></div>
|
||
<button class="small" data-panel="${esc(p.key)}">Show</button></div>`).join("")
|
||
: '<div class="sub">No open panels.</div>';
|
||
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
|
||
}
|
||
$("panelsRefresh").onclick = loadPanelSwitcher;
|
||
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
|
||
() => api("/api/panels", {action: "open"}), e.currentTarget);
|
||
$("panelList").onclick = e => {
|
||
const b = e.target.closest("[data-panel]"); if (!b) return;
|
||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||
};
|
||
|
||
// ---- Read-only power observation ----
|
||
let powerState = null, powerTimer = null, powerSeq = 0;
|
||
function renderPower(s) {
|
||
powerState = s;
|
||
$('powerStart').disabled = !!s.running;
|
||
$('powerPreset').disabled = !!s.running;
|
||
$('powerStop').disabled = !s.running;
|
||
$('powerProgress').max = s.requested || 1;
|
||
$('powerProgress').value = s.received;
|
||
$('powerProgress').hidden = !s.id;
|
||
const identity = s.headset ? `Headset: ${s.headset.alias}. ` : '';
|
||
$('powerStatus').textContent = s.id ? identity + `${s.received} of ${s.requested} samples received. ` +
|
||
(s.running ? (s.stopped?.startsWith('Stopped') ? 'Stopping…' : 'Observing…') : s.stopped ? `Incomplete: ${s.stopped}` : 'Completed.') : 'No observation yet.';
|
||
document.querySelectorAll('[data-power-format]').forEach(b => b.disabled = !s.id);
|
||
}
|
||
async function loadPower() {
|
||
const seq = ++powerSeq;
|
||
clearTimeout(powerTimer);
|
||
try {
|
||
const s = await api('/api/power');
|
||
if (seq !== powerSeq) return;
|
||
renderPower(s);
|
||
if (s.running && location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 1000);
|
||
} catch (e) {
|
||
if (seq !== powerSeq) return;
|
||
$('powerStatus').textContent = `Observation status unavailable: ${e.message}. Received samples remain on the local server while it is running.`;
|
||
if (location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 3000);
|
||
}
|
||
}
|
||
$('powerStart').onclick = async () => {
|
||
$('powerStart').disabled = true;
|
||
try {
|
||
renderPower(await api('/api/power', {action:'start', preset:$('powerPreset').value}));
|
||
loadPower();
|
||
} catch (e) { $('powerStatus').textContent = e.message; $('powerStart').disabled = !!powerState?.running; }
|
||
};
|
||
$('powerStop').onclick = async () => {
|
||
if (!powerState?.id) return;
|
||
$('powerStop').disabled = true;
|
||
try { renderPower(await api('/api/power', {action:'stop', id:powerState.id})); loadPower(); }
|
||
catch (e) { $('powerStatus').textContent = e.message; $('powerStop').disabled = false; }
|
||
};
|
||
document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async () => {
|
||
if (!powerState?.id) return;
|
||
const id = powerState.id, format = b.dataset.powerFormat;
|
||
b.disabled = true;
|
||
try {
|
||
const r = await fetch(`/api/power/report?id=${encodeURIComponent(id)}&format=${format}`, {headers:{'X-Frame-UI':UI_KEY}});
|
||
if (!r.ok) throw new Error((await r.json()).error || `HTTP ${r.status}`);
|
||
const blob = await r.blob(), a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob); a.download = `frame-power-${id}.${format}`; a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
$('powerSaveStatus').textContent = 'Download requested. Open the saved HTML in your browser to view it offline; it includes JSON and CSV downloads.';
|
||
} catch (e) { $('powerSaveStatus').textContent = `Download failed: ${e.message}`; }
|
||
finally { b.disabled = !powerState?.id; }
|
||
});
|
||
|
||
// ---- Read-only game storage ----
|
||
let storageSeq = 0;
|
||
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
|
||
unavailable:'Unavailable', 'invalid-metadata':'Invalid Steam metadata', partial:'Partial inventory', ok:'Available'}[s] || 'Unknown');
|
||
const storageBytes = n => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? sizeText(n) : 'Unknown';
|
||
function storageCapacity(c) {
|
||
if (!c || c.state !== 'ok') return esc(storageState(c?.state));
|
||
return `${storageBytes(c.free)} free / ${storageBytes(c.total)} total · ${storageBytes(c.used)} used`;
|
||
}
|
||
function renderStorage(r) {
|
||
return `<div class="card"><h3>Headset user filesystem</h3><p>${storageCapacity(r.home)}</p>
|
||
<p class="sub">Filesystem space available to your user; excludes separate system partitions. Reserved space can make used + free less than total.</p></div>` +
|
||
(r.discovery !== 'ok' ? `<p class="sub">Library discovery: ${esc(storageState(r.discovery))}. Additional libraries may be absent from this view.</p>` : '') +
|
||
r.libraries.map(lib => `<div class="card" style="margin-top:16px"><h3 style="overflow-wrap:anywhere">${esc(lib.path)}</h3>
|
||
<p>${storageCapacity(lib.capacity)}</p><p class="sub">Filesystem containing this library path; paths on the same filesystem share this space. Capacities must not be added together.</p>
|
||
<p>${esc(storageState(lib.state))} · Known games/apps: ${storageBytes(lib.gameBytes)} · Known runtimes / tools (by name): ${storageBytes(lib.toolBytes)}</p>
|
||
<p class="sub">Known recorded content: ${storageBytes(lib.recordedBytes)} · ${lib.unknownSizes} unknown sizes · ${lib.issues.length} unreadable / invalid manifests. Other usage is unmeasured.</p>
|
||
<div class="list">${lib.apps.map(app => `<div class="item"><div class="grow">${esc(app.name)}<div class="sub">Steam ${esc(app.appid)} · ${app.kind === 'tool' ? 'Runtime / tool' : 'Game / app'}</div></div><span>${storageBytes(app.bytes)}</span></div>`).join('') || '<p class="sub">No readable Steam manifests found; this does not prove the library is empty.</p>'}</div>
|
||
${lib.issues.map(i => `<p class="sub">${esc(i.file)}: ${esc(storageState(i.state))}</p>`).join('')}</div>`).join('') +
|
||
`<p class="sub">Snapshot received ${esc(new Date().toLocaleTimeString())}. Refresh after installs, downloads or moves.</p>`;
|
||
}
|
||
async function loadStorage() {
|
||
const seq = ++storageSeq, gen = devGen;
|
||
$('storageResult').textContent = 'Reading headset storage…';
|
||
try {
|
||
const result = await api('/api/storage');
|
||
if (seq === storageSeq && gen === devGen) $('storageResult').innerHTML = renderStorage(result);
|
||
} catch (error) {
|
||
if (seq === storageSeq && gen === devGen) $('storageResult').textContent = `Storage unavailable: ${error.message}`;
|
||
}
|
||
}
|
||
$('storageRefresh').onclick = loadStorage;
|
||
|
||
// ---- Backup and recovery kit (ui/frame_backup.py) ----
|
||
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
|
||
function sizeText(n) {
|
||
if (n == null) return "";
|
||
for (const unit of ["bytes", "KB", "MB", "GB"]) {
|
||
if (n < 1000 || unit === "GB") return unit === "bytes" ? `${n} bytes` : `${n.toFixed(1)} ${unit}`;
|
||
n /= 1000;
|
||
}
|
||
}
|
||
const backupKinds = () => [...$("backupKinds").querySelectorAll("input:checked")].map(i => i.value);
|
||
let backupSeq = 0, backupBusy = false;
|
||
async function loadBackups() {
|
||
const seq = ++backupSeq;
|
||
try {
|
||
const r = await api("/api/backup", {action: "list"});
|
||
if (seq !== backupSeq) return;
|
||
$("backupList").innerHTML = r.backups.length ? r.backups.map(b => `<div class="item"><div class="grow">
|
||
<div class="t">${esc(b.name)}</div><div class="s">${b.error ? esc(b.error) :
|
||
esc([b.headset, b.build && `SteamOS build ${b.build}`, `${b.parts} part${b.parts === 1 ? "" : "s"}`, sizeText(b.bytes)].filter(Boolean).join(" · "))}</div></div>
|
||
${b.error ? "" : `<button class="small" data-backup-plan="${esc(b.name)}">Restore…</button>`}</div>`).join("")
|
||
: `<div class="sub">No backups in ${esc(r.folder)} yet.</div>`;
|
||
} catch (e) { if (seq === backupSeq) failed($("backupList"), e); }
|
||
}
|
||
function backupPartRow(p, restore) {
|
||
const detail = restore
|
||
? (p.status === "ok" ? [p.replace ? `replaces ${p.replace} file${p.replace === 1 ? "" : "s"}` : "", p.new ? `${p.new} new` : "",
|
||
p.held && p.held.reference ? `${p.held.reference} kept for reference` : ""].filter(Boolean).join(", ") || "ready"
|
||
: p.reason || p.status)
|
||
: [`${p.files} file${p.files === 1 ? "" : "s"}`, sizeText(p.bytes), p.skipped && p.skipped.length ? `${p.skipped.length} too big, skipped` : ""].filter(Boolean).join(" · ");
|
||
const box = restore ? `<input type="checkbox" data-restore-part="${esc(p.id)}" ${p.status === "ok" ? "checked" : "disabled"} aria-label="Restore ${esc(p.label)}"> ` : "";
|
||
return `<div class="item"><div class="grow"><div class="t">${box}${esc(p.label)}</div><div class="s">${esc(detail)}</div></div></div>`;
|
||
}
|
||
$("backupPreview").onclick = e => act("Check what a backup includes", async () => {
|
||
const r = await api("/api/backup", {action: "preview"}), kinds = backupKinds();
|
||
const rows = r.parts.filter(p => kinds.includes(p.kind)).map(p => backupPartRow(p, false));
|
||
if (kinds.includes("android")) rows.push(...r.android.map(a => `<div class="item"><div class="grow"><div class="t">${esc(a.label)} (Android app data)</div>
|
||
<div class="s">${a.running ? "Open now: close it or it's skipped" : "Saved while closed"}</div></div></div>`));
|
||
if (r.android_error && kinds.includes("android")) rows.push(`<div class="sub">Android apps couldn't be listed: ${esc(r.android_error)}</div>`);
|
||
$("backupSummary").innerHTML = (rows.join("") || '<div class="sub">Nothing to back up for that choice.</div>') +
|
||
`<details><summary>Never copied</summary><ul>${r.excluded.map(x => `<li>${esc(x)}</li>`).join("")}</ul></details>`;
|
||
return {message: `SteamOS build ${r.build || "unknown"}: ${rows.length} part${rows.length === 1 ? "" : "s"} to back up`};
|
||
}, e.currentTarget);
|
||
// Like runJob, without counting as an install in the header.
|
||
async function backupJob(label, start) {
|
||
log(label + "…");
|
||
try {
|
||
const { job } = await start();
|
||
for (let misses = 0; ;) {
|
||
await new Promise(ok => setTimeout(ok, 1500));
|
||
let j;
|
||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||
backupProgress(j);
|
||
if (!j.done) continue;
|
||
if (j.error) throw new Error(j.error);
|
||
log(j.message, "ok"); toast(j.message);
|
||
return j.result || {};
|
||
}
|
||
} catch (e) {
|
||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||
}
|
||
}
|
||
function backupProgress(j) {
|
||
$("backupProgress").textContent = j.done ? "" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`;
|
||
}
|
||
$("backupStart").onclick = async e => {
|
||
const kinds = backupKinds();
|
||
if (!kinds.length) return toast("Choose something to back up", true);
|
||
if (backupBusy) return;
|
||
backupBusy = true; e.currentTarget.disabled = true;
|
||
try {
|
||
const r = await backupJob("Back up the Frame", () => api("/api/backup", {action: "backup", kinds}));
|
||
if (r) {
|
||
$("backupSummary").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
|
||
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}${s.path ? `: ${esc(s.path)}` : ""}</div>
|
||
<div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
|
||
loadBackups();
|
||
}
|
||
} finally { backupBusy = false; $("backupStart").disabled = false; $("backupProgress").textContent = ""; }
|
||
};
|
||
$("backupFolder").onclick = e => act("Open backups folder", () => api("/api/backup", {action: "open"}), e.currentTarget);
|
||
$("backupRefresh").onclick = loadBackups;
|
||
let restoreName = null;
|
||
$("backupList").onclick = async e => {
|
||
const b = e.target.closest("[data-backup-plan]"); if (!b) return;
|
||
restoreName = b.dataset.backupPlan;
|
||
$("restorePlan").innerHTML = '<div class="sub">Checking what can go back…</div>';
|
||
try {
|
||
const r = await api("/api/backup", {action: "plan", name: restoreName});
|
||
if (r.name !== restoreName) return;
|
||
$("restorePlan").innerHTML = `<h3>Restore ${esc(r.name)}</h3>` + r.parts.map(p => backupPartRow(p, true)).join("") +
|
||
`<div class="row" style="margin-top:12px"><button class="small danger" id="restoreStart">Restore selected</button>
|
||
<button class="small" data-backup-open="${esc(r.name)}">Show files</button></div>`;
|
||
$("restorePlan").plan = r;
|
||
} catch (err) { failed($("restorePlan"), err); }
|
||
};
|
||
$("restorePlan").onclick = async e => {
|
||
const open = e.target.closest("[data-backup-open]");
|
||
if (open) return act("Show backup files", () => api("/api/backup", {action: "open", name: open.dataset.backupOpen}), open);
|
||
if (!e.target.closest("#restoreStart") || backupBusy) return;
|
||
const plan = $("restorePlan").plan;
|
||
const parts = [...$("restorePlan").querySelectorAll("[data-restore-part]:checked")].map(i => i.dataset.restorePart);
|
||
if (!plan || !parts.length) return toast("Choose something to restore", true);
|
||
const chosen = plan.parts.filter(p => parts.includes(p.id));
|
||
const replacing = chosen.reduce((n, p) => n + (p.replace || 0), 0);
|
||
if (!confirm(`Restore ${chosen.length} part${chosen.length === 1 ? "" : "s"} from ${plan.name}?\n\n` +
|
||
`${replacing ? `This replaces ${replacing} file${replacing === 1 ? "" : "s"} or app data folder${replacing === 1 ? "" : "s"} on the headset. ` : ""}` +
|
||
"Replaced files are kept on the headset. Close the games and apps you're restoring first.")) return;
|
||
backupBusy = true;
|
||
try {
|
||
const r = await backupJob("Restore from backup", () => api("/api/backup", {action: "restore", name: plan.name, parts, confirm: true}));
|
||
if (r) $("restorePlan").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
|
||
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}</div><div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
|
||
} finally { backupBusy = false; $("backupProgress").textContent = ""; }
|
||
};
|
||
const SESSION_TEXT = {game: "Game Mode: the headset is running normally. A desktop you see is a panel; close it from the Steam dashboard.",
|
||
desktop: "Desktop Mode: the headset started the full desktop instead of Game Mode.",
|
||
unknown: "Couldn't tell which mode the headset is in."};
|
||
$("sessionCheck").onclick = async e => {
|
||
e.currentTarget.disabled = true;
|
||
try {
|
||
const s = await api("/api/backup", {action: "session"});
|
||
$("sessionState").textContent = SESSION_TEXT[s.mode] + (s.selector ? "" : " This SteamOS build has no session switch command; use the manual steps.");
|
||
$("sessionGame").disabled = s.mode !== "desktop" || !s.selector;
|
||
} catch (err) { failed($("sessionState"), err); }
|
||
finally { $("sessionCheck").disabled = false; }
|
||
};
|
||
$("sessionGame").onclick = e => {
|
||
if (!confirm("Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.")) return;
|
||
act("Return to Game Mode", () => api("/api/backup", {action: "gamemode", confirm: true}), e.currentTarget);
|
||
};
|
||
|
||
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
||
let mvSeq = 0, mvTimer = 0, mvPcAsked = false;
|
||
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
||
// How a live stream is doing: frames shown, Mac-to-headset delay, bitrate, and
|
||
// whether the network made it step down (fewer frames, then fewer pixels).
|
||
function mvHealth(st) {
|
||
if (!st) return "";
|
||
const x = st.stats || {}, c = st.controller || {}, bits = [];
|
||
if (x.fps) bits.push(`${Math.round(x.fps)} fps`);
|
||
if (x.total && x.total.p50 != null) bits.push(`${Math.round(x.total.p50)} ms`);
|
||
if (x.mbps) bits.push(`${x.mbps} Mbit/s`);
|
||
if (c.tier > 0) bits.push(`weak link: ${c.fps} fps${c.scale < 1 ? `, ${Math.round(c.scale * 100)}% size` : ""}`);
|
||
return bits.length ? " · " + esc(bits.join(" · ")) : "";
|
||
}
|
||
function mvRow(src, title, sub, w, h, streaming, st) {
|
||
return `<div class="item"><div class="grow"><div class="t">${esc(title)}</div>
|
||
<div class="s">${esc(sub)}${streaming ? ' · <span class="mv-live">in the headset</span>' + mvHealth(st) : ""}</div></div>
|
||
${streaming ? `<button class="small" data-mv="stop" data-src="${esc(streaming)}">Stop</button>`
|
||
: `<button class="small action" data-mv="show" data-src="${esc(src)}" data-title="${esc(title)}" data-w="${w}" data-h="${h}">Show</button>`}
|
||
</div>`;
|
||
}
|
||
async function loadMacView(restart) {
|
||
const seq = ++mvSeq;
|
||
let s, err;
|
||
try { s = await api("/api/macview" + (restart ? "?restart=1" : "")); } catch (e) { err = e; }
|
||
if (seq !== mvSeq || location.hash !== "#macview") return;
|
||
const box = $("macview");
|
||
if (err) { box.hidden = $("mvMain").hidden = false; return failed($("mvWindows"), err); }
|
||
// PC in the headset (Windows, Experimental) is off until turned on here.
|
||
const pc = !!s.host && s.host !== "mac";
|
||
$("mvTitle").textContent = pc ? "PC in the headset" : "Mac in the headset";
|
||
$("mvTag").hidden = $("mvPc").hidden = !pc;
|
||
$("mvPcOn").checked = !!s.enabled;
|
||
if (pc && !s.enabled && clientPreferences.get("pcView") === "1" && !restart && !mvPcAsked) {
|
||
mvPcAsked = true; // once per page: a failure to start shouldn't loop
|
||
return mvPcEnable(true, true);
|
||
}
|
||
if (!s.available) {
|
||
box.hidden = !pc;
|
||
$("mvMain").hidden = true;
|
||
$("mvCount").textContent = "";
|
||
if (pc) { $("mvPcState").textContent = s.reason || ""; $("mvPcState").hidden = !s.reason; }
|
||
return;
|
||
}
|
||
box.hidden = false;
|
||
$("mvMain").hidden = false;
|
||
$("mvPcState").hidden = true;
|
||
$("mvSeparate").closest("label").hidden = pc; // separate virtual displays are a Mac feature
|
||
const live = new Set((s.streams || []).map(x => x.src));
|
||
const byStream = src => (s.streams || []).find(x => x.src === src);
|
||
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
|
||
const perm = [];
|
||
if (!s.screen) perm.push(`Frame Control needs <b>Screen Recording</b> permission to show your windows.`);
|
||
if (!s.accessibility) perm.push(`Allow <b>Accessibility</b> too, so clicks and keys from the headset reach the Mac.`);
|
||
$("mvPerm").hidden = !perm.length;
|
||
$("mvPerm").innerHTML = perm.join(" ") + `<div class="row"><button class="small action" data-mv="perm">Allow…</button>
|
||
<span class="sub">Then press Refresh. macOS may ask you to reopen Frame Control.</span></div>`;
|
||
if (pc) $("mvPerm").hidden = true; // Windows asks for no permission; elevated apps just refuse input
|
||
const liveAs = src => live.has(src) ? src : src.startsWith("window:") && live.has("separate:" + src.slice(7)) ? "separate:" + src.slice(7) : "";
|
||
$("mvDisplays").innerHTML = (s.displays || []).map(d =>
|
||
mvRow(d.src, `Whole screen: ${d.name}`, `${d.w}×${d.h}${d.main ? " · main display" : ""}`, d.w, d.h, liveAs(d.src), byStream(liveAs(d.src)))).join("");
|
||
const wins = s.windows || [];
|
||
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
|
||
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
|
||
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
|
||
: `<div class="sub">${pc && s.windowCaptureReason ? esc(s.windowCaptureReason) : `No windows open on this ${pc ? "PC" : "Mac's current desktop"}.`}</div>`;
|
||
// Keep the numbers fresh while something is in the headset and the card is on screen.
|
||
clearTimeout(mvTimer);
|
||
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
|
||
if (live.size) mvTimer = setTimeout(tick, 3000);
|
||
}
|
||
$("mvRefresh").onclick = () => loadMacView(true);
|
||
// The choice is remembered on this computer; the server starts with it off.
|
||
async function mvPcEnable(on, quiet) {
|
||
if (!quiet) clientPreferences.set("pcView", on ? "1" : "0");
|
||
const turn = () => api("/api/macview", { action: "enable", on });
|
||
if (quiet) { try { await turn(); } catch {} }
|
||
else await act(on ? "Turn on PC in the headset" : "Turn off PC in the headset", async () =>
|
||
({ ...await turn(), message: on ? "PC in the headset is on." : "PC in the headset is off. Nothing is shared." }), $("mvPcOn"));
|
||
loadMacView();
|
||
}
|
||
$("mvPcOn").onchange = e => mvPcEnable(e.target.checked);
|
||
// ---- client preferences: native storage survives changing localhost origins ----
|
||
const clientPreferences = (() => {
|
||
const keys = ['mvSeparate', 'padUsed', 'pcView'], values = {}, changed = new Set();
|
||
const valid = value => value === '0' || value === '1';
|
||
const remember = (key, value) => {
|
||
values[key] = value;
|
||
try { localStorage.setItem(key, value); } catch {} // private browsing/storage denial
|
||
};
|
||
for (const key of keys) {
|
||
try { const value = localStorage.getItem(key); if (valid(value)) values[key] = value; } catch {}
|
||
}
|
||
const native = window.frameApp?.preferences;
|
||
const ready = (async () => {
|
||
if (!native) return;
|
||
try {
|
||
const saved = await native.read();
|
||
for (const key of keys) {
|
||
if (changed.has(key)) continue;
|
||
if (valid(saved?.[key])) remember(key, saved[key]);
|
||
else if (valid(values[key])) { try { await native.write(key, values[key]); } catch {} }
|
||
}
|
||
} catch {} // local values remain usable if the host bridge is unavailable
|
||
})();
|
||
return { ready, get: key => values[key], set(key, value) {
|
||
if (!keys.includes(key) || !valid(value)) return;
|
||
changed.add(key); remember(key, value);
|
||
if (native) ready.then(() => native.write(key, value)).catch(() => {});
|
||
} };
|
||
})();
|
||
$('mvSeparate').disabled = true;
|
||
clientPreferences.ready.then(() => {
|
||
$('mvSeparate').checked = clientPreferences.get('mvSeparate') !== '0';
|
||
$('mvSeparate').disabled = false;
|
||
});
|
||
$('mvSeparate').onchange = e => clientPreferences.set('mvSeparate', e.target.checked ? '1' : '0');
|
||
$("macview").onclick = async e => {
|
||
const b = e.target.closest("[data-mv]"); if (!b) return;
|
||
const src = b.dataset.src;
|
||
if (b.dataset.mv === "show") {
|
||
const as = !$("mvSeparate").closest("label").hidden && $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
|
||
await act(`Show ${b.dataset.title} in the headset`, async () => {
|
||
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
|
||
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
|
||
return { message: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`, ...r };
|
||
}, b);
|
||
} else if (b.dataset.mv === "stop") {
|
||
await act("Stop showing it", () => api("/api/macview", { action: "stop", src }), b);
|
||
} else if (b.dataset.mv === "stopall") {
|
||
await act("Stop everything in the headset", () => api("/api/macview", { action: "stop" }), b);
|
||
} else if (b.dataset.mv === "perm") {
|
||
await act("Ask macOS for permission", () => api("/api/macview", { action: "permissions" }), b);
|
||
}
|
||
setTimeout(loadMacView, 800);
|
||
};
|
||
|
||
// Local Steam helper: never start an action on navigation or a status read.
|
||
let steamPipewireBusy = false, steamPipewireRead = 0;
|
||
async function loadSteamPipewire() {
|
||
if (steamPipewireBusy) return;
|
||
const read = ++steamPipewireRead;
|
||
try {
|
||
const s = await api('/api/steam-pipewire');
|
||
if (read !== steamPipewireRead) return;
|
||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = !s.available;
|
||
$('steamPipewireStatus').textContent = s.reason || '';
|
||
if (s.guidance) $('steamPipewireGuidance').textContent = s.guidance;
|
||
} catch (e) {
|
||
if (read !== steamPipewireRead) return;
|
||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||
$('steamPipewireStatus').textContent = e.message;
|
||
}
|
||
}
|
||
async function steamPipewireAction(action) {
|
||
if (steamPipewireBusy) return;
|
||
steamPipewireBusy = true;
|
||
steamPipewireRead++;
|
||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||
try {
|
||
const r = await api('/api/steam-pipewire', {action, confirmed: true});
|
||
$('steamPipewireStatus').textContent = r.message;
|
||
} catch (e) { $('steamPipewireStatus').textContent = e.message; }
|
||
finally {
|
||
steamPipewireBusy = false;
|
||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = false;
|
||
}
|
||
}
|
||
$('steamPipewireLaunch').onclick = () => steamPipewireAction('launch');
|
||
$('steamPipewireReset').onclick = () => steamPipewireAction('permissions');
|
||
|
||
// ---- shared destinations and intentional secondary workspaces ----
|
||
const PAGES = ["apps", "games", "tools", "settings"];
|
||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", powerReport:"tools", backup:"tools"};
|
||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||
let page = "apps";
|
||
// Reuse the existing live lists and form handlers in their new destinations.
|
||
$('appsInstalled').append($('androidLibrary'));
|
||
$('appsLinux').append($('apps'));
|
||
function primaryPage(next) { return next === 'capture' ? 'tools' : SECONDARY_PAGES.includes(next) ? 'settings' : next; }
|
||
function saveWorkspaceScroll() {
|
||
if (workspace.key) workspace.scroll.set(workspace.key, workspace.y);
|
||
}
|
||
function restoreWorkspaceScroll(key, section) {
|
||
const y = workspace.scroll.get(key);
|
||
requestAnimationFrame(() => {
|
||
// A second navigation can win before the next frame.
|
||
if (workspace.key !== key) return;
|
||
if (section) section.scrollIntoView({behavior:"instant", block:"start"});
|
||
else if (y !== undefined) scrollToY(y);
|
||
else scrollToY(0);
|
||
workspace.y = scroller().scrollTop;
|
||
});
|
||
}
|
||
function selectLibrary(source = workspace.source, scope = workspace.scope, restore = true) {
|
||
if (restore) saveWorkspaceScroll();
|
||
workspace.source = ['all','steam','sideloaded'].includes(source) ? source : 'all';
|
||
workspace.scope = ['installed','owned','store'].includes(scope) ? scope : 'installed';
|
||
const installed = workspace.scope === 'installed', store = workspace.scope === 'store';
|
||
$('library').hidden = !installed || !['all','steam'].includes(workspace.source);
|
||
$('sideloaded').hidden = !installed || !['all','sideloaded'].includes(workspace.source);
|
||
$('librarySources').hidden = !installed;
|
||
$('libraryOwned').hidden = installed || store;
|
||
$('steamDiscover').hidden = !store;
|
||
document.querySelectorAll('[data-library-source]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.librarySource === workspace.source)));
|
||
document.querySelectorAll('[data-scope]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.scope === workspace.scope)));
|
||
if (!installed) {
|
||
$(store ? 'steamDiscover' : 'libraryOwned').append($('getgames'));
|
||
const src = store ? 'store' : 'owned';
|
||
if (gm.src !== src) setGameSource(src);
|
||
}
|
||
$('getgames').hidden = installed;
|
||
$('steamScopeTitle').textContent = store ? 'Steam store' : 'Owned, not installed';
|
||
if (restore) { workspace.key = `games:${workspace.scope}:${workspace.source}`; restoreWorkspaceScroll(workspace.key); }
|
||
}
|
||
function selectApps(view = workspace.apps, restore = true) {
|
||
if (restore) saveWorkspaceScroll();
|
||
workspace.apps = ['installed','browse','linux'].includes(view) ? view : 'installed';
|
||
$('appsInstalled').hidden = workspace.apps !== 'installed';
|
||
$('androidLibrary').hidden = workspace.apps !== 'installed';
|
||
$('appStore').hidden = workspace.apps !== 'browse';
|
||
$('appBrowseHeading').hidden = workspace.apps !== 'browse';
|
||
$('appsLinux').hidden = workspace.apps !== 'linux';
|
||
document.querySelectorAll('[data-app-view]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.appView === workspace.apps)));
|
||
if (restore) {
|
||
if (workspace.apps === 'installed') loadAndroid();
|
||
workspace.key = `apps:${workspace.apps}`; restoreWorkspaceScroll(workspace.key);
|
||
}
|
||
}
|
||
// Compatibility for existing callers that used the old source-based discovery switch.
|
||
function selectDiscover(source = 'android') {
|
||
location.hash = source === 'steam' ? 'steamStore' : 'appStore';
|
||
showPage();
|
||
}
|
||
function showPage(e) {
|
||
saveWorkspaceScroll();
|
||
const id = location.hash.slice(1), previousPage = page;
|
||
const next = PAGES.includes(id) || SECONDARY_PAGES.includes(id) ? id : SECTION_PAGE[id] || 'apps';
|
||
if (next !== page && page === 'capture') {
|
||
viewGen++; $('viewer').classList.remove('busy');
|
||
if (live) toggleLive(false);
|
||
}
|
||
page = next;
|
||
if (id === 'library') workspace.scope = 'installed';
|
||
if (id === 'sideloaded') workspace.source = 'sideloaded', workspace.scope = 'installed';
|
||
if (id === 'getgames') workspace.scope = 'owned';
|
||
if (id === 'steamStore') workspace.scope = 'store';
|
||
if (['discover','android','appStore'].includes(id)) workspace.apps = 'browse';
|
||
if (id === 'androidLibrary') workspace.apps = 'installed';
|
||
if (id === 'linuxApps') workspace.apps = 'linux';
|
||
document.querySelectorAll('.page').forEach(p => p.classList.toggle('on', p.dataset.page === page));
|
||
document.querySelectorAll('#nav a').forEach(a => {
|
||
const selected = a.getAttribute('href') === '#' + primaryPage(page);
|
||
a.classList.toggle('on', selected);
|
||
if (selected) a.setAttribute('aria-current','page'); else a.removeAttribute('aria-current');
|
||
});
|
||
if (page === 'games') selectLibrary(workspace.source, workspace.scope, false);
|
||
// Another computer may have added or removed a title: phones have no Refresh button. Only
|
||
// on navigating here (e: the hashchange); at startup the first load is already on its way.
|
||
if (e && page === 'games' && previousPage !== 'games') { loadTitles(); loadRunning().catch(() => {}); }
|
||
if (page === 'apps') {
|
||
selectApps(workspace.apps, false);
|
||
if ((previousPage !== 'apps' || id === 'androidLibrary') && workspace.apps === 'installed') loadAndroid();
|
||
}
|
||
const tool = id === 'display' ? 'androidSettings' : TOOL_TITLES[id] ? id : null;
|
||
Object.keys(TOOL_TITLES).forEach(key => { $(key).hidden = key !== tool; });
|
||
for (const kind of ['tools','settings']) {
|
||
const activeTool = page === kind ? tool : null;
|
||
$(kind === 'tools' ? 'toolDirectory' : 'settingsDirectory').hidden = !!activeTool;
|
||
$(kind === 'tools' ? 'toolWorkspace' : 'settingsWorkspace').hidden = !activeTool;
|
||
$(kind === 'tools' ? 'toolBack' : 'settingsBack').hidden = !activeTool;
|
||
$(kind === 'tools' ? 'toolTitle' : 'settingsTitle').textContent = TOOL_TITLES[activeTool] || (kind === 'tools' ? 'Tools' : 'Settings');
|
||
$(kind === 'tools' ? 'toolSubtitle' : 'settingsSubtitle').hidden = !!activeTool;
|
||
// Moving the panel again would drop focus inside it (a second showPage follows every shortcut).
|
||
const host = $(kind === 'tools' ? 'toolWorkspace' : 'settingsWorkspace');
|
||
if (activeTool && $(activeTool).parentNode !== host) host.append($(activeTool));
|
||
}
|
||
if (tool === 'steamPipewire') loadSteamPipewire();
|
||
if (tool === 'macview') loadMacView();
|
||
if (tool === 'panels') loadPanelSwitcher();
|
||
if (tool === 'backup') loadBackups();
|
||
if (tool === 'storage') loadStorage();
|
||
if (tool === 'powerReport') loadPower();
|
||
if (tool === 'androidSettings') {
|
||
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
|
||
}
|
||
document.title = `${tool ? TOOL_TITLES[tool] : page === 'home' ? 'Frame activity' : page[0].toUpperCase()+page.slice(1)} · Frame Control`;
|
||
workspace.hash = location.hash;
|
||
workspace.key = page === 'games' ? `games:${workspace.scope}:${workspace.source}` : page === 'apps' ? `apps:${workspace.apps}` : tool ? `${page}:${tool}` : page;
|
||
const section = ['view','shots','device','display'].includes(id) ? $(id) : null;
|
||
restoreWorkspaceScroll(workspace.key, section);
|
||
}
|
||
for (const target of [window, $('scroll')]) target.addEventListener('scroll', () => {
|
||
if (workspace.hash === location.hash) workspace.y = scroller().scrollTop;
|
||
}, {passive:true});
|
||
$('libraryScope').onclick = e => { const b=e.target.closest('[data-scope]'); if (b) selectLibrary(workspace.source,b.dataset.scope); };
|
||
$('librarySources').onclick = e => { const b=e.target.closest('[data-library-source]'); if (b) selectLibrary(b.dataset.librarySource); };
|
||
$('appViews').onclick = e => { const b=e.target.closest('[data-app-view]'); if (b) selectApps(b.dataset.appView); };
|
||
$('homeLog').onclick = () => $('drawer').classList.toggle('open');
|
||
$('closeLog').onclick = () => $('drawer').classList.remove('open');
|
||
function openInstallChooser() {
|
||
$('installLinkError').hidden = true;
|
||
if (!$('installChooser').open) $('installChooser').showModal();
|
||
}
|
||
function installLinkRequest(raw) {
|
||
const value = String(raw || '').trim();
|
||
if (value.length > 4096) throw new Error('That link is too long. Paste a direct download or Frame Control install link.');
|
||
let link;
|
||
try { link = new URL(value); } catch { throw new Error('Paste a complete https:// download link or Frame Control install link.'); }
|
||
let kind = 'url', target = value;
|
||
if (link.protocol === 'frame-control:') {
|
||
const keys = [...new Set(link.searchParams.keys())];
|
||
if (link.hostname !== 'install' || !['','/'].includes(link.pathname) || keys.length !== 1 || !['url','manifest'].includes(keys[0]) || link.searchParams.getAll(keys[0]).length !== 1) throw new Error('This Frame Control install link is not valid.');
|
||
kind = keys[0]; target = link.searchParams.get(kind);
|
||
try { link = new URL(target); } catch { throw new Error('The install link needs a complete download URL.'); }
|
||
}
|
||
if (!target || target.length > 2048 || !['https:','http:'].includes(link.protocol) || link.username || link.password) throw new Error('Use a public download URL without a username or password.');
|
||
return {kind,target}; // The server still validates scheme, hosts, redirects and file type.
|
||
}
|
||
$('installChooserClose').onclick = () => $('installChooser').close();
|
||
$('installChooser').onclick = e => {
|
||
const link = e.target.closest('a[href]');
|
||
if (!link) return;
|
||
$('installChooser').close();
|
||
// Sub-tabs keep the route in place. Reapply its explicit destination even
|
||
// when choosing the same link would otherwise produce no hashchange.
|
||
if (location.hash === link.getAttribute('href')) { e.preventDefault(); showPage(); }
|
||
};
|
||
document.querySelectorAll('[data-open-install]').forEach(b => b.onclick = openInstallChooser);
|
||
$('installChooseFile').onclick = () => $('installFileInput').click();
|
||
$('installFileInput').onchange = () => {
|
||
const files = [...$('installFileInput').files];
|
||
if (!files.length) return;
|
||
$('installChooser').close(); location.hash = 'transfer';
|
||
sendFiles(files); $('installFileInput').value = '';
|
||
};
|
||
$('installLinkForm').onsubmit = e => {
|
||
e.preventDefault();
|
||
try {
|
||
const request = installLinkRequest($('installUrl').value);
|
||
if (wi.queue.length >= 5) throw new Error('Finish or close an install link before adding another.');
|
||
wi.queue.push(request); $('installChooser').close(); wiNext();
|
||
} catch (error) { $('installLinkError').hidden = false; $('installLinkError').textContent = error.message; }
|
||
};
|
||
function renderHome(sample = state) {
|
||
const valid = sample && stateRead.valid && online !== false;
|
||
$('homeTitle').textContent = valid ? 'Ready when you are' : online === false ? 'Reconnect your Frame' : "Let's connect";
|
||
$('homeReadiness').textContent = valid ? `${sample.hostname || 'Steam Frame'} connected${sample.battery?.percent != null ? ` · ${sample.battery.percent}% battery` : ''}` : online === false ? 'Check the connection to see current activity and open apps.' : 'Checking your headset…';
|
||
const running = valid ? androidApps.filter(a => a.running) : [];
|
||
$('homeActivity').textContent = running.length ? `${running.length} app${running.length===1 ? '' : 's'} running` : valid ? 'Choose what to open next' : 'Current activity unavailable';
|
||
$('homeActivityNote').textContent = running.length ? 'Android apps currently running on this Frame.' : valid ? 'Open Apps or Games to choose what to use next.' : 'Connect your Frame to read its current state.';
|
||
$('homeRunning').innerHTML = running.map(a => `<button data-app-details="${esc(a.package)}">${esc(a.label || a.package)} <small>View app →</small></button>`).join('');
|
||
const d = valid ? gm.dl : null, taskCount = jobsRunning + (d ? 1 : 0);
|
||
$('homeJobs').textContent = taskCount ? `${taskCount} task${taskCount===1 ? '' : 's'} in progress` : 'No active tasks';
|
||
$('homeDownload').textContent = valid && d ? `${d.paused ? 'Paused' : 'Downloading'} ${gm.byId.get(d.appid)?.name || d.appid} · ${d.percent ?? 0}%` : 'Install progress stays here while you explore.';
|
||
}
|
||
|
||
window.addEventListener("hashchange", showPage);
|
||
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||
|
||
// ---- keyboard and trackpad: events go to the Frame through gamescope (ui/frame_touch.py); accents and emoji through KDE Connect ----
|
||
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
|
||
const PAD_STATES = {
|
||
off: "", starting: "Connecting…", installing: "Setting up (first time only)…",
|
||
pairing: "Pairing with KDE Connect…", waiting: "Waiting for queued text…", ready: "On",
|
||
};
|
||
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
|
||
const PAD_SPECIAL = { Backspace: 1, Tab: 2, ArrowLeft: 4, ArrowUp: 5, ArrowRight: 6, ArrowDown: 7, PageUp: 8,
|
||
PageDown: 9, Home: 10, End: 11, Enter: 12, Delete: 13, Escape: 14,
|
||
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, 21 + i])) };
|
||
const IS_MAC = /^Mac/.test(navigator.platform);
|
||
const TOUCH = matchMedia("(hover: none)").matches;
|
||
|
||
function padShow(status) {
|
||
pad.state = status.state || "off";
|
||
const ready = pad.state === "ready";
|
||
const busy = !ready && pad.state !== "off" && pad.state !== "error";
|
||
$("padState").textContent = pad.state === "error" ? "" : PAD_STATES[pad.state] || "";
|
||
$("padOn").hidden = ready || busy;
|
||
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
|
||
$("padArea").classList.toggle("off", !ready);
|
||
$("padBody").hidden = pad.state === "off";
|
||
$("padIntro").hidden = pad.state !== "off";
|
||
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach the Frame."
|
||
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
|
||
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
|
||
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
|
||
: "Click here to use your mouse and keyboard on the Frame (Esc to stop), or drag like a trackpad.";
|
||
clearTimeout(pad.poll);
|
||
if (ready && pad.queue.length) padFlush();
|
||
if (busy || status.queued) {
|
||
const gen = devGen;
|
||
pad.poll = setTimeout(async () => {
|
||
const status = await api('/api/input').catch(e => ({state:'error', message:e.message}));
|
||
if (gen === devGen) padShow(status);
|
||
}, 1000);
|
||
}
|
||
}
|
||
async function padStart() {
|
||
clientPreferences.set('padUsed', '1');
|
||
const gen = devGen;
|
||
padShow({ state: 'starting' });
|
||
const status = await api('/api/input?start=1').catch(e => ({state:'error', message:e.message}));
|
||
if (gen === devGen) padShow(status);
|
||
}
|
||
$("padOn").onclick = padStart;
|
||
|
||
// Queue events and send them one request at a time, merging pointer moves made meanwhile.
|
||
const isMove = e => Object.keys(e).every(k => k === "dx" || k === "dy");
|
||
function padSend(event) {
|
||
// While it reconnects, keys and clicks wait in the queue; pointer moves would be stale.
|
||
// A button release is always kept, so a drag never leaves the button held on the Frame.
|
||
const room = pad.queue.length < 200 || event.singlerelease;
|
||
const reconnecting = ["starting", "pairing", "installing", "waiting"].includes(pad.state) && room;
|
||
// Even after an error: a button pressed on the Frame must be let go once it's back.
|
||
const release = event.singlerelease && pad.state !== "off";
|
||
if (pad.state !== "ready" && !(reconnecting && !isMove(event)) && !release) return;
|
||
const last = pad.queue[pad.queue.length - 1];
|
||
if (last && isMove(last) && isMove(event)) { last.dx = (last.dx || 0) + (event.dx || 0); last.dy = (last.dy || 0) + (event.dy || 0); }
|
||
else pad.queue.push(event);
|
||
padFlush();
|
||
}
|
||
// Events that didn't reach the Frame (the link had dropped, and is reconnecting): keep
|
||
// the keys and clicks for when it's back, but not pointer moves, which would be stale.
|
||
// Button releases are kept whatever the queue's length.
|
||
function padKeep(events) {
|
||
pad.queue = [...events, ...pad.queue].filter(e => !isMove(e)).filter((e, i) => i < 200 || e.singlerelease);
|
||
}
|
||
function padReset() {
|
||
clearTimeout(pad.poll); pad.poll = null;
|
||
pad.queue = []; pad.sending = false; pad.held = false; pad.captured = false;
|
||
touches.clear(); gesture = null; padType.value = ''; padType.blur();
|
||
window.frameApp?.captureKeys?.(false);
|
||
if (document.pointerLockElement === $('padArea')) document.exitPointerLock();
|
||
$('padArea').classList.remove('captured');
|
||
padShow({state:'off'});
|
||
}
|
||
async function padFlush() {
|
||
if (pad.sending || !pad.queue.length || pad.state !== "ready") return;
|
||
pad.sending = true;
|
||
const gen = devGen;
|
||
const events = pad.queue.splice(0, 200);
|
||
try {
|
||
const status = await api("/api/input", { events });
|
||
if (gen !== devGen) return;
|
||
if (!status.sent) padKeep(events);
|
||
if (status.state !== "ready" || status.queued) padShow(status);
|
||
} catch (e) {
|
||
if (gen !== devGen) return;
|
||
padKeep(events);
|
||
padShow({ state: "error", message: e.message });
|
||
} finally {
|
||
if (gen === devGen) {
|
||
pad.sending = false;
|
||
if (pad.queue.length) padFlush();
|
||
}
|
||
}
|
||
}
|
||
function padKey(e) {
|
||
const mods = { ...(e.ctrlKey || (IS_MAC && e.metaKey) ? { ctrl: true } : {}), ...(e.altKey ? { alt: true } : {}),
|
||
...(!IS_MAC && e.metaKey ? { super: true } : {}) };
|
||
if (PAD_SPECIAL[e.key]) return { specialKey: PAD_SPECIAL[e.key], ...mods, ...(e.shiftKey ? { shift: true } : {}) };
|
||
if (e.key.length === 1) return { key: e.key, ...mods }; // already shifted, so no shift flag
|
||
return null;
|
||
}
|
||
|
||
// Touch: one finger moves, a quick tap clicks; two fingers scroll, or right-click when tapped.
|
||
const PAD_SPEED = 1.6, PAD_SCROLL_STEP = 18;
|
||
const touches = new Map();
|
||
let gesture = null;
|
||
$("padArea").addEventListener("pointerdown", e => {
|
||
if (pad.state === "off" || pad.state === "error") { padStart(); return; }
|
||
if (pad.state !== "ready") return;
|
||
if (e.pointerType === "mouse") {
|
||
if (pad.captured) return; // mousedown and mouseup below send the clicks
|
||
// Lock the pointer to the pad and pass the whole mouse and keyboard through; where
|
||
// that isn't allowed, the mouse drags like a finger on a trackpad instead.
|
||
if (e.button === 0 && !pad.noLock && $("padArea").requestPointerLock) { $("padArea").requestPointerLock(); return; }
|
||
// Only the left button drags and taps like a finger; the others are clicks of their own.
|
||
if (e.button === 1 || e.button === 2) { padSend(e.button === 2 ? { rightclick: true } : { middleclick: true }); return; }
|
||
if (e.button !== 0) return;
|
||
}
|
||
try { $("padArea").setPointerCapture(e.pointerId); } catch {} // keeps the drag if the finger leaves the pad
|
||
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (touches.size === 1) gesture = { start: performance.now(), moved: 0, fingers: 1, scroll: 0 };
|
||
else if (gesture) gesture.fingers = Math.max(gesture.fingers, touches.size);
|
||
e.preventDefault();
|
||
});
|
||
$("padArea").addEventListener("pointermove", e => {
|
||
if (pad.captured) { padSend({ dx: e.movementX, dy: e.movementY }); return; }
|
||
const t = touches.get(e.pointerId);
|
||
if (!t || !gesture) return;
|
||
const dx = e.clientX - t.x, dy = e.clientY - t.y;
|
||
t.x = e.clientX; t.y = e.clientY;
|
||
gesture.moved += Math.abs(dx) + Math.abs(dy);
|
||
if (touches.size >= 2) {
|
||
// Scroll with the lead finger only, a notch per step, like a laptop trackpad (content follows the fingers).
|
||
if (e.pointerId !== touches.keys().next().value) return;
|
||
gesture.scroll += dy;
|
||
while (Math.abs(gesture.scroll) >= PAD_SCROLL_STEP) {
|
||
const up = gesture.scroll > 0;
|
||
padSend({ scroll: true, dy: up ? 1 : -1 });
|
||
gesture.scroll -= up ? PAD_SCROLL_STEP : -PAD_SCROLL_STEP;
|
||
}
|
||
} else if (gesture.fingers === 1) {
|
||
padSend({ dx: dx * PAD_SPEED, dy: dy * PAD_SPEED });
|
||
}
|
||
});
|
||
function padLift(e) {
|
||
if (!touches.delete(e.pointerId) || touches.size || !gesture) return;
|
||
const quick = performance.now() - gesture.start < 250 && gesture.moved < 10;
|
||
if (quick) padSend(gesture.fingers >= 2 ? { rightclick: true } : { singleclick: true });
|
||
gesture = null;
|
||
}
|
||
$("padArea").addEventListener("pointerup", padLift);
|
||
$("padArea").addEventListener("pointercancel", e => { touches.delete(e.pointerId); if (!touches.size) gesture = null; });
|
||
|
||
// A computer's mouse and keyboard: captured while the pointer is locked to the pad.
|
||
document.addEventListener("pointerlockerror", () => { pad.noLock = true; });
|
||
document.addEventListener("pointerlockchange", () => {
|
||
pad.captured = document.pointerLockElement === $("padArea");
|
||
window.frameApp?.captureKeys?.(pad.captured); // the desktop app's menu shortcuts go to the Frame too
|
||
// Esc or switching windows mid-drag: let go of the button on the Frame as well.
|
||
if (!pad.captured && pad.held) { padSend({ singlerelease: true }); pad.held = false; }
|
||
$("padArea").classList.toggle("captured", pad.captured);
|
||
padShow({ state: pad.state });
|
||
});
|
||
$("padArea").addEventListener("mousedown", e => {
|
||
if (!pad.captured) return;
|
||
if (e.button === 0) { padSend({ singlehold: true }); pad.held = true; }
|
||
else if (e.button === 1) padSend({ middleclick: true });
|
||
else if (e.button === 2) padSend({ rightclick: true });
|
||
});
|
||
$("padArea").addEventListener("mouseup", e => { if (pad.held && e.button === 0) { padSend({ singlerelease: true }); pad.held = false; } });
|
||
$("padArea").addEventListener("contextmenu", e => e.preventDefault());
|
||
$("padArea").addEventListener("wheel", e => {
|
||
if (pad.state !== "ready") return;
|
||
e.preventDefault();
|
||
if (e.deltaY) padSend({ scroll: true, dy: e.deltaY > 0 ? -1 : 1 });
|
||
}, { passive: false });
|
||
// Capture phase, so the page's own shortcuts (tabs 1-4, R, zoom) don't also fire.
|
||
window.addEventListener("keydown", e => {
|
||
if (!pad.captured) return;
|
||
e.preventDefault(); e.stopImmediatePropagation();
|
||
const event = padKey(e);
|
||
if (event) padSend(event);
|
||
}, true);
|
||
|
||
// The text field: each keystroke goes to the Frame. While focused it holds one space,
|
||
// so Backspace always has something to delete; empty otherwise, to show its prompt.
|
||
const padType = $("padType");
|
||
padType.addEventListener("beforeinput", e => {
|
||
if (e.inputType === "insertCompositionText") return; // IME composing; sent on compositionend
|
||
e.preventDefault();
|
||
if (pad.state === "off" || pad.state === "error") padStart(); // what's typed meanwhile waits in the queue
|
||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||
if (/^insert(Text|ReplacementText|FromPaste|FromDrop)$/.test(e.inputType) && text) padText(text);
|
||
else if (e.inputType === "deleteContentBackward") padSend({ specialKey: 1 });
|
||
else if (e.inputType === "deleteWordBackward") padSend({ specialKey: 1, ctrl: true }); // Ctrl+Backspace: a word, on Linux
|
||
else if (e.inputType === "deleteContentForward") padSend({ specialKey: 13 });
|
||
else if (e.inputType === "deleteWordForward") padSend({ specialKey: 13, ctrl: true });
|
||
else if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") padSend({ specialKey: 12 });
|
||
});
|
||
padType.addEventListener("compositionend", e => { if (e.data) padText(e.data); padType.value = " "; padCaret(); });
|
||
// Text in pieces the server takes (500 characters), with Enter for each line break.
|
||
function padText(text) {
|
||
text.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
||
if (i) padSend({ specialKey: 12 });
|
||
const chars = Array.from(line); // whole characters, so an emoji isn't cut in two
|
||
for (let at = 0; at < chars.length; at += 500) padSend({ key: chars.slice(at, at + 500).join("") });
|
||
});
|
||
}
|
||
padType.addEventListener("keydown", e => {
|
||
const special = PAD_SPECIAL[e.key];
|
||
// Text and deletions arrive as beforeinput; this takes the keys that don't edit the field.
|
||
if (!special || e.key === "Backspace" || e.key === "Delete" || e.isComposing) return;
|
||
e.preventDefault();
|
||
padSend(padKey(e));
|
||
});
|
||
// The caret stays after the space, so Backspace always has something to delete.
|
||
const padCaret = () => requestAnimationFrame(() => padType.setSelectionRange(1, 1));
|
||
padType.addEventListener("focus", () => { padType.value = " "; padCaret(); if (pad.state === "off") padStart(); });
|
||
padType.addEventListener("blur", () => { padType.value = ""; });
|
||
padType.addEventListener("input", e => {
|
||
if (e.isComposing) return; // an input method is still composing; compositionend sends it
|
||
if (padType.value !== " ") padType.value = " ";
|
||
padCaret();
|
||
});
|
||
padType.addEventListener("click", padCaret);
|
||
document.querySelector("#pad .pad-keys").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
if (b.dataset.padClick) padSend({ [b.dataset.padClick]: true });
|
||
else if (b.dataset.padKey) padSend({ specialKey: +b.dataset.padKey });
|
||
});
|
||
clientPreferences.ready.then(async () => {
|
||
const gen = devGen;
|
||
const status = await api('/api/input').catch(() => ({state:'off'}));
|
||
if (gen !== devGen) return;
|
||
if (status.state === 'off' && clientPreferences.get('padUsed') === '1') padStart();
|
||
else padShow(status);
|
||
});
|
||
showPage();
|
||
document.addEventListener("keydown", e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||
if ((e.key === "t" || e.key === "T") && !(ctrl.on && document.activeElement === $("viewer"))) {
|
||
e.preventDefault(); // or the T lands in the field it focuses, and goes to the Frame
|
||
return typeOnHeadset();
|
||
}
|
||
const n = [...PAGES, 'devices', 'control'][+e.key - 1];
|
||
if (n) location.hash = n;
|
||
});
|
||
|
||
// ---- after an APK install: test it, so the compatibility database learns whether it runs ----
|
||
async function offerTest(m) {
|
||
if (!m.package || !confirm(`${m.label || m.package} is installed. Test it now?\n\nIt opens in the headset for about 20 seconds `
|
||
+ "and records whether it stays up.")) return;
|
||
toast(`Testing ${m.label || m.package}: launching it and watching for 20 s…`);
|
||
await act(`Test ${m.label || m.package}`, () => api("/api/android", { action: "probe", package: m.package }));
|
||
loadReports();
|
||
}
|
||
|
||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
|
||
function renderTelemetry(s) {
|
||
Object.assign(telemetry, s);
|
||
setRepHint();
|
||
$("tUsage").checked = s.usage; $("tCompat").checked = s.compat; $("tDiag").checked = s.diagnostics;
|
||
$("tStatus").textContent = s.blocked ? `Nothing is being sent: ${s.blocked}.`
|
||
: `${s.queued ? s.queued + " waiting to send. " : ""}Your anonymous id is ${s.id.slice(0, 8)}…; it isn't linked to you or this computer.`;
|
||
$("tSent").textContent = s.sent.length ? s.sent.map(e => JSON.stringify({ event: e.event, time: e.timestamp, ...e.properties })).join("\n\n")
|
||
: "Nothing sent yet.";
|
||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||
$("privacyNotice").hidden = !showNotice;
|
||
if (showNotice) privacyNoticeShown = true;
|
||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||
}
|
||
async function loadTelemetry() {
|
||
try { renderTelemetry(await api("/api/telemetry")); } catch {}
|
||
}
|
||
async function setTelemetry(change) {
|
||
try { renderTelemetry(await api("/api/telemetry", change)); }
|
||
catch (e) { toast(`Couldn't save: ${e.message}`, true); loadTelemetry(); }
|
||
}
|
||
$("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
|
||
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
|
||
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
|
||
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
|
||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||
$("noticeMore").onclick = async () => {
|
||
$("privacyNotice").hidden = true;
|
||
await setTelemetry({ compat: true, diagnostics: true });
|
||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||
};
|
||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||
const telemetryLoaded = loadTelemetry();
|
||
|
||
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||
const contact = { email: "", updates: false, followup: false };
|
||
function renderContact(s) {
|
||
Object.assign(contact, s);
|
||
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
|
||
$("cRemove").hidden = !s.email;
|
||
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
|
||
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
|
||
: "Sent privately to the Frame Control maintainer, never shared or published. Updates are occasional release notices; follow-up questions are mainly about problem reports you send.";
|
||
}
|
||
async function saveContact(change) {
|
||
const s = await api("/api/contact", change);
|
||
renderContact(s);
|
||
return s;
|
||
}
|
||
async function loadContact() {
|
||
await telemetryLoaded;
|
||
try { renderContact(await api("/api/contact")); } catch { return; }
|
||
checkContactPrompt();
|
||
}
|
||
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
|
||
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||
async function checkContactPrompt() {
|
||
await telemetryLoaded;
|
||
if (privacyNoticeShown || !$("contactNotice").hidden) return;
|
||
let s;
|
||
try { s = await api("/api/contact"); } catch { return; }
|
||
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
|
||
$("contactNotice").hidden = false;
|
||
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
|
||
}
|
||
$("contactNotice").onsubmit = async e => {
|
||
e.preventDefault();
|
||
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
|
||
$("cpSave").disabled = true;
|
||
try {
|
||
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
|
||
$("contactNotice").hidden = true;
|
||
toast("Thanks! Change or remove it any time in Privacy & updates.");
|
||
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
|
||
finally { $("cpSave").disabled = false; }
|
||
};
|
||
$("cpNo").onclick = async () => {
|
||
try { await api("/api/contact/prompt", { prompt: "dismissed" }); $("contactNotice").hidden = true; }
|
||
catch (err) { $("cpMsg").textContent = `Couldn't save that: ${err.message}. Try again.`; }
|
||
};
|
||
$("contactForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const email = $("cEmail").value.trim();
|
||
if (email && !$("cUpdates").checked && !$("cFollowup").checked) {
|
||
$("cStatus").textContent = "Tick what your email may be used for, or use Remove my email."; return;
|
||
}
|
||
try { await saveContact({ email, updates: $("cUpdates").checked, followup: $("cFollowup").checked });
|
||
toast(email ? "Contact email saved." : "Contact email removed."); }
|
||
catch (err) { toast(`Couldn't save: ${err.message}`, true); }
|
||
};
|
||
$("cRemove").onclick = async () => {
|
||
try { await saveContact({ email: "", updates: false, followup: false }); toast("Contact email removed. Neither choice applies any more."); }
|
||
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
|
||
};
|
||
loadContact();
|
||
function pageEvent(event, properties) {
|
||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||
}
|
||
// Which tabs get used: once per tab per session.
|
||
const tabsSeen = new Set();
|
||
document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => {
|
||
const tab = a.getAttribute("href").slice(1);
|
||
if (!tabsSeen.has(tab)) { tabsSeen.add(tab); pageEvent("tab_viewed", { tab }); }
|
||
}));
|
||
|
||
// ---- report a problem (ui/frame_report.py): sent privately to PostHog, with diagnostics ----
|
||
const bug = { preview: '', generation: 0, consent: null };
|
||
function bugDiagnostics() {
|
||
return $('bugDiag').checked && bug.consent === String($('bugLogs').checked) ? bug.preview : '';
|
||
}
|
||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||
function bugReportText() {
|
||
const email = $("bugFollowup").checked ? $("bugContact").value.trim() : "";
|
||
const diagnostics = bugDiagnostics();
|
||
const body = `Kind: ${$("bugKind").value}${email ? `\nFollow-up questions welcome: ${email}` : ""}\n\n${$("bugText").value.trim()}${diagnostics ? "\n\n---\nDiagnostics:\n```\n" + diagnostics + "\n```" : ""}`;
|
||
return { title: $("bugTitleIn").value.trim(), body };
|
||
}
|
||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||
async function loadBugPreview() {
|
||
const generation = ++bug.generation, includeLogs = $('bugLogs').checked;
|
||
bug.preview = ''; bug.consent = null; // invalidate immediately, before any request
|
||
if (!$('bugDiag').checked) { $('bugDiagText').textContent = 'Nothing: diagnostics are off.'; return; }
|
||
$('bugDiagText').textContent = 'Loading…';
|
||
const current = () => generation === bug.generation && $('bugDiag').checked && includeLogs === $('bugLogs').checked;
|
||
try {
|
||
const result = await api('/api/report/preview', {activity:activityLines(), includeLogs});
|
||
if (!current()) return;
|
||
bug.preview = result.text; bug.consent = String(includeLogs);
|
||
$('bugDiagText').textContent = bug.preview;
|
||
} catch (e) {
|
||
if (current()) $('bugDiagText').textContent = `Couldn't collect diagnostics: ${e.message}`;
|
||
}
|
||
}
|
||
function openBugReport() {
|
||
$("bugForm").reset();
|
||
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
||
$("bugCancel").textContent = "Cancel";
|
||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked.
|
||
$("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
|
||
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
|
||
bugReplaces();
|
||
$("bugDlg").showModal();
|
||
loadBugPreview();
|
||
}
|
||
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
||
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
||
$("bugLogs").onchange = loadBugPreview;
|
||
$("bugFollowup").onchange = () => {
|
||
const on = $("bugFollowup").checked;
|
||
$("bugContact").disabled = !on; $("bugContact").required = on;
|
||
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); }
|
||
bugReplaces();
|
||
};
|
||
// Sending with another address replaces the saved one (ui/frame_contact.py from_report): say so first.
|
||
function bugReplaces() {
|
||
const email = $("bugContact").value.trim(), old = contact.email;
|
||
const replaces = $("bugFollowup").checked && email && old && email.toLowerCase() !== old.toLowerCase();
|
||
$("bugReplaces").hidden = !replaces;
|
||
$("bugReplaces").textContent = !replaces ? "" : `Sending replaces ${old} as your contact email${contact.updates
|
||
? ", and update notices stop until you turn them on again in Privacy & updates" : ""}.`;
|
||
}
|
||
$("bugContact").oninput = bugReplaces;
|
||
$("bugCancel").onclick = () => $("bugDlg").close();
|
||
$("bugCopy").onclick = async () => {
|
||
const { title, body } = bugReportText();
|
||
try { await navigator.clipboard.writeText(`${title}\n\n${body}`); $("bugMsg").textContent = "Copied."; }
|
||
catch { $("bugMsg").textContent = "Couldn't copy; select the text under Show exactly what's included."; }
|
||
};
|
||
$("bugForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
if (!$("bugForm").reportValidity()) return;
|
||
$("bugSend").disabled = true; $("bugMsg").textContent = "Sending…";
|
||
try {
|
||
const res = await api("/api/report", {
|
||
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
||
contactFollowup: $("bugFollowup").checked, contact: $("bugFollowup").checked ? $("bugContact").value : "",
|
||
diagnostics: bugDiagnostics() });
|
||
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
||
$("bugCancel").textContent = "Close";
|
||
log(res.message, "ok");
|
||
} catch (err) {
|
||
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
||
$("bugSend").disabled = false;
|
||
}
|
||
api("/api/contact").then(renderContact).catch(() => {}); // the report may have saved the address
|
||
};
|
||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||
|
||
// ---- updates (the desktop app only: app/updater.js) ----
|
||
const upd = { dismissed: false, offered: null };
|
||
function renderUpdate(s) {
|
||
if (!s) return;
|
||
$("appVersion").textContent = `Version ${s.current}`;
|
||
const r = s.latest;
|
||
const show = r && ["available", "downloading", "ready", "error"].includes(s.status) && !upd.dismissed
|
||
&& !(s.status === "error" && !r);
|
||
$("updateBar").hidden = !show;
|
||
if (!show) return;
|
||
if (s.status === "available" && upd.offered !== r.version) { upd.offered = r.version; pageEvent("update_offered", { to_version: r.version }); }
|
||
const busy = s.status === "downloading" || s.status === "ready";
|
||
$("updateText").innerHTML = s.status === "error"
|
||
? `Updating to ${esc(r.version)} didn't work: ${esc(s.error || "unknown error")}`
|
||
: busy ? `Downloading Frame Control ${esc(r.version)}… It restarts when it's ready.`
|
||
: `<b>Frame Control ${esc(r.version)} is available.</b> You have ${esc(s.current)}.`
|
||
+ (s.canInstall ? "" : ` ${esc(s.why ? "It can't update itself here (" + s.why + ")," : "")} download it from the release page.`);
|
||
$("updateProg").hidden = !busy;
|
||
$("updateProg").firstElementChild.style.width = Math.round((s.progress || 0) * 100) + "%";
|
||
$("updateGo").textContent = s.canInstall ? (s.status === "error" ? "Try again" : "Update and restart") : "Open release page";
|
||
$("updateGo").disabled = busy; $("updateLater").hidden = busy;
|
||
}
|
||
if (window.frameApp && window.frameApp.update) {
|
||
window.frameApp.update.onState(renderUpdate);
|
||
window.frameApp.update.get().then(renderUpdate);
|
||
$("updateCheck").hidden = false;
|
||
$("updateCheck").onclick = async () => {
|
||
const btn = $("updateCheck");
|
||
btn.disabled = true;
|
||
let s;
|
||
try { s = await window.frameApp.update.check(); } finally { btn.disabled = false; }
|
||
upd.dismissed = false; renderUpdate(s);
|
||
if (s && s.status === "none") toast(`You have the newest version (${s.current})`);
|
||
if (s && s.status === "check-failed") toast(`Couldn't check for updates: ${s.error}`, true);
|
||
};
|
||
$("updateGo").onclick = () => {
|
||
pageEvent("update_started", { to_version: upd.offered || "" });
|
||
window.frameApp.update.install();
|
||
};
|
||
$("updateLater").onclick = () => { upd.dismissed = true; $("updateBar").hidden = true; };
|
||
$("updateNotes").onclick = async () => {
|
||
const s = await window.frameApp.update.get();
|
||
if (s && s.latest) window.open(s.latest.page, "_blank");
|
||
};
|
||
}
|
||
|
||
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||
// The app passes each link here. The server checks it and reads the manifest;
|
||
// nothing downloads until the user clicks Install in this dialog.
|
||
const wi = { queue: [], open: false, gen: 0, plan: null, job: null, starting: false };
|
||
function wiSize(n) {
|
||
if (n == null) return "Not given";
|
||
return n >= 1e9 ? gb(n) : n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
|
||
}
|
||
function wiFacts(rows) { $("wiFacts").innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join(""); }
|
||
function wiButtons(cancel, go) {
|
||
$("wiCancel").textContent = cancel[0]; $("wiCancel").disabled = !cancel[1];
|
||
$("wiGo").hidden = !go; $("wiGo").disabled = go !== "on";
|
||
}
|
||
async function wiNext() {
|
||
if (wi.open || !wi.queue.length) return;
|
||
const req = wi.queue.shift(), gen = ++wi.gen;
|
||
wi.open = true; wi.plan = null; wi.job = null;
|
||
let host = "";
|
||
try { host = new URL(req.target).hostname; } catch {}
|
||
wiFacts([["From", host], [req.kind === "manifest" ? "Manifest" : "File", req.target]]);
|
||
$("wiProg").hidden = true; $("wiMsg").textContent = "Checking the link…";
|
||
wiButtons(["Cancel", true], "off");
|
||
$("wiDlg").showModal();
|
||
log(`Install link from ${host}`);
|
||
try {
|
||
const p = await api("/api/webinstall/check", { [req.kind]: req.target });
|
||
if (gen !== wi.gen) return;
|
||
wi.plan = p;
|
||
const from = p.host === p.linkHost ? p.host : `${p.linkHost} (file on ${p.host})`;
|
||
wiFacts([["Title", p.name], ["From", from], ["File", p.file], ["Type", p.kindLabel], ["Size", wiSize(p.size)],
|
||
["SHA-256", p.sha256 ? "Given; checked after downloading" : "Not given; the download can't be checked"]]);
|
||
$("wiMsg").textContent = "";
|
||
wiButtons(["Cancel", true], "on");
|
||
$("wiGo").focus();
|
||
} catch (e) {
|
||
if (gen !== wi.gen) return;
|
||
$("wiMsg").textContent = e.message;
|
||
wiButtons(["Close", true], null);
|
||
log(`Install link refused: ${e.message}`, "e");
|
||
}
|
||
}
|
||
async function wiPoll(gen) {
|
||
if (gen !== wi.gen || !wi.job) return;
|
||
let j;
|
||
try {
|
||
j = await api(`/api/webinstall/job?id=${encodeURIComponent(wi.job)}`);
|
||
} catch (e) {
|
||
$("wiMsg").textContent = e.message;
|
||
return setTimeout(() => wiPoll(gen), 1500);
|
||
}
|
||
if (gen !== wi.gen) return;
|
||
const bar = $("wiProg").firstElementChild;
|
||
$("wiProg").hidden = false;
|
||
if (j.phase === "download") {
|
||
bar.style.width = j.total ? (100 * j.done / j.total) + "%" : "0";
|
||
$("wiMsg").textContent = `Downloading ${wiSize(j.done)}` + (j.total ? ` of ${wiSize(j.total)}` : "");
|
||
wiButtons(["Stop download", true], "off");
|
||
} else if (j.phase === "install") {
|
||
bar.style.width = "100%";
|
||
$("wiMsg").textContent = j.detail || "Installing on the Frame…";
|
||
wiButtons(["Stop download", false], "off");
|
||
} else {
|
||
wi.job = null;
|
||
$("wiProg").hidden = true;
|
||
if (j.phase === "done") {
|
||
log(j.message, "ok"); toast(j.message);
|
||
$("wiDlg").close();
|
||
loadAndroid(); loadTitles(); refresh();
|
||
} else {
|
||
$("wiMsg").textContent = j.error;
|
||
log(`Install from link failed: ${j.error}`, "e"); toast(`Install failed: ${j.error}`, true);
|
||
wiButtons(["Close", true], null);
|
||
}
|
||
return;
|
||
}
|
||
setTimeout(() => wiPoll(gen), 500);
|
||
}
|
||
$("wiGo").onclick = async () => {
|
||
const p = wi.plan, gen = wi.gen;
|
||
if (!p) return;
|
||
wi.plan = null; // one click, one install
|
||
wiButtons(["Cancel", false], "off");
|
||
$("wiMsg").textContent = "Starting…";
|
||
wi.starting = true; // the dialog stays open until the job is known
|
||
try {
|
||
wi.job = (await api("/api/webinstall/start", { id: p.id })).job;
|
||
log(`Installing ${p.name} from ${p.host}…`);
|
||
wiPoll(gen);
|
||
} catch (e) {
|
||
$("wiMsg").textContent = e.message;
|
||
wiButtons(["Close", true], null);
|
||
} finally { wi.starting = false; }
|
||
};
|
||
$("wiCancel").onclick = async () => {
|
||
if (!wi.job) return $("wiDlg").close();
|
||
try { await api("/api/webinstall/cancel", { job: wi.job }); } catch (e) { $("wiMsg").textContent = e.message; }
|
||
};
|
||
// Escape doesn't close the dialog while an install runs; it keeps showing progress.
|
||
$("wiDlg").addEventListener("cancel", e => { if (wi.job || wi.starting) e.preventDefault(); });
|
||
$("wiDlg").addEventListener("close", () => { wi.gen++; wi.open = false; wi.plan = null; wi.job = null; setTimeout(wiNext); });
|
||
if (window.frameApp && window.frameApp.onInstallLink) {
|
||
window.frameApp.onInstallLink(req => { if (wi.queue.length < 5) wi.queue.push(req); wiNext(); });
|
||
}
|
||
|
||
setView("headset");
|
||
refresh().then(() => { loadShots(); loadClips(); pollRec(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
|
||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||
</script>
|
||
<script>
|
||
// ---- the connection and the Devices tab (ui/frame_link.py, ui/frame_devices.py, docs/devices.md) ----
|
||
// The server streams the connection's state (server-sent events, read with fetch so the
|
||
// X-Frame-UI header goes along) every time it changes: which headset, this computer's
|
||
// network, each stage of connecting, and what every address answered.
|
||
const link = { s: null, offset: 0, live: false, phase: null, device: null, rev: -1, lastData: 0, reload: false };
|
||
const dv = { list: [], sel: null, data: null, found: null, editing: null };
|
||
const STAGE_ICON = { done: "✓", failed: "✕", pending: "", active: "", skipped: "–" };
|
||
const KIND_TAG = { lan: "LAN", mdns: "mDNS", tailscale: "Tailscale", manual: "Other" };
|
||
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||
const serverNow = () => Date.now() / 1000 - link.offset;
|
||
function ago(t) {
|
||
if (!t) return "never";
|
||
const s = Math.max(0, serverNow() - t);
|
||
return s < 60 ? "just now" : s < 3600 ? `${Math.round(s / 60)} min ago` : s < 86400 ? `${Math.round(s / 3600)} h ago`
|
||
: `${Math.round(s / 86400)} d ago`;
|
||
}
|
||
function secs(a, b) { return a && b ? `${Math.max(0, b - a).toFixed(1)} s` : a ? `${Math.max(0, serverNow() - a).toFixed(0)} s` : ""; }
|
||
const activeStage = s => (s.stages || []).find(x => x.state === "active") || (s.stages || []).filter(x => x.state === "failed").pop();
|
||
function netLabel(s) {
|
||
const n = s.network;
|
||
if (s.via && s.via.kind === "tailscale") return "Tailscale";
|
||
return n ? n.name : "Checking the network";
|
||
}
|
||
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
|
||
|
||
// 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.
|
||
// Why the last attempt failed, in a few words: the server's short reason, else the stage that failed.
|
||
function failureReason(s) {
|
||
const err = s.error || {};
|
||
if (err.short) return err.short;
|
||
const st = (s.stages || []).find(x => x.id === err.stage);
|
||
return st ? `${st.label} failed` : "Not connected";
|
||
}
|
||
// A sentence or a label as a clause inside another sentence: no closing full stop (the sentence
|
||
// adds its own) and a lower-case first letter, unless the first word is an abbreviation ("SSH").
|
||
function asClause(text) {
|
||
const t = String(text || "").trim().replace(/[.\s]+$/, "");
|
||
return /^[A-Z][a-z]/.test(t) ? t[0].toLowerCase() + t.slice(1) : t;
|
||
}
|
||
|
||
function renderPill() {
|
||
const s = link.s;
|
||
if (!link.live || !s) return false;
|
||
const dev = s.device ? s.device.name : "Frame";
|
||
let dot = "wait", word, via, detail;
|
||
if (s.phase === "connected") {
|
||
dot = "on"; word = "Connected"; via = routeLabel(s);
|
||
detail = `connected via ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : "");
|
||
} else if (s.phase === "failed") {
|
||
dot = "off"; word = "Offline";
|
||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||
via = left != null ? `retrying in ${left} s` : "";
|
||
detail = s.error ? s.error.message : "not connected";
|
||
} else {
|
||
word = "Connecting…";
|
||
const st = activeStage(s);
|
||
via = st ? st.label.toLowerCase() : "";
|
||
detail = via || "connecting";
|
||
}
|
||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(word)}` +
|
||
`${via ? `<span class="via"> · ${esc(via)}</span>` : ""}</span></span>`;
|
||
$("conn").title = `${dev}: ${detail.replace(/[.\s]+$/, "")}. Click to see or change its addresses.`;
|
||
return true;
|
||
}
|
||
window.connPill = renderPill;
|
||
|
||
// The banner (when the headset can't be reached) repeats the reason and the countdown.
|
||
function renderBanner() {
|
||
const s = link.s;
|
||
if (!link.live || !s) return;
|
||
$("offDetails").hidden = false;
|
||
// A changed host key: never accepted by itself. The person checks the new key and decides.
|
||
const hk = s.phase === "failed" && s.error && s.error.hostkey && s.device ? s.device.id : null;
|
||
$("offTrust").hidden = !hk;
|
||
if (hk) $("offTrust").onclick = () => openTrust(hk);
|
||
if (s.phase === "failed" && s.error) {
|
||
$("offline").hidden = false;
|
||
$("offMsg").textContent = s.error.message;
|
||
const tried = (s.probes || []).map(p => `${p.host}: ${asClause(p.detail)}`).join("; ");
|
||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
|
||
(left != null ? `Trying again in ${left} s.` : "");
|
||
} else if (s.phase === "connecting" && online === false) {
|
||
const st = activeStage(s);
|
||
$("offDetail").textContent = `Trying again: ${st ? asClause(st.label) + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
|
||
}
|
||
}
|
||
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() {
|
||
const s = link.s;
|
||
if (!s || !$("connDlg").open) return;
|
||
$("connTitle").textContent = s.device ? s.device.name : "Connection";
|
||
const connected = s.phase === "connected", failed = s.phase === "failed";
|
||
$("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"}…`;
|
||
// 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 facts = [["Network", n.ssid || n.name || "—"],
|
||
["This computer", n.local_ip || "—"], ["Router", n.gateway || "No default route"],
|
||
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
|
||
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
|
||
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
|
||
<span class="lb">${esc(st.id === "login" && s.device && s.device.user ? "Logging in as " + s.device.user : st.label)}</span>
|
||
<span class="tm">${esc(st.state === "pending" ? "" : secs(st.started, st.ended))}</span>
|
||
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
|
||
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
|
||
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
|
||
renderConnAddrs();
|
||
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)}` : "";
|
||
}
|
||
|
||
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) {
|
||
const prev = link.s;
|
||
link.s = s; link.live = !s.local; link.lastData = Date.now();
|
||
link.offset = Date.now() / 1000 - s.now;
|
||
if (!link.live) return;
|
||
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;
|
||
stateReceivedAt = null;
|
||
stateRead = {valid: false, error: null};
|
||
online = null;
|
||
$("desktopBatteryAlert").checked = false;
|
||
desktopBattery.invalidate();
|
||
// Keep the cooldown across switches, but a new headset can start its own charge episode.
|
||
desktopBattery.latched = false;
|
||
$("desktopBatteryStatus").textContent = "Off. Headset changed; enable for this headset if wanted.";
|
||
devGen++; // answers still on their way are about the other headset
|
||
padReset(); ctrlReset();
|
||
sourceState.jobs.clear(); sourceState.detail = null; sourceState.detailRequest++;
|
||
$('appDetail').close();
|
||
window.frameControlCenter?.reset();
|
||
refreshing = null; // and the next status check must be a new one
|
||
if (live) toggleLive(false); // the other headset's video or captures
|
||
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
|
||
viewGen++; // a capture still on its way is the other headset's too
|
||
lastImg = null; lastShot = null;
|
||
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
||
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
||
$("saveBtn").disabled = $("copyBtn").disabled = true;
|
||
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
||
// before anyone clicks one, until the new headset's arrive.
|
||
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
||
if ($(id)) $(id).innerHTML = `<div class="wait">Waiting for ${esc(s.device.name)}</div>`;
|
||
});
|
||
disp.list = []; disp.port = null;
|
||
// The lists behind those panels too, so filters can't bring the old ones back.
|
||
gm.storeDraft = ""; gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.shownBySource = {owned:0, store:0}; gm.seq++;
|
||
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
|
||
renderHome();
|
||
titlesSeq++; disp.seq++; runningSeq++; // answers to loads already on their way are ignored
|
||
$('storageResult').textContent = 'Headset changed. Open Game storage or Refresh to read this headset.';
|
||
runningGames = []; $("nowRunning").hidden = true; $("runningList").innerHTML = "";
|
||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||
// Confirmations still open were checked against the other headset.
|
||
["titleDlg", "apkAltDlg", "pwDlg", "repDlg"].forEach(id => { if ($(id) && $(id).open) $(id).close(); });
|
||
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
|
||
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
|
||
link.reload = true; // everything on the page was the other headset's
|
||
$("battChip").hidden = true; 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 === "connected") {
|
||
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
|
||
$("offline").hidden = true;
|
||
const reload = link.reload;
|
||
link.reload = false;
|
||
refresh().then(() => { if (reload) reloadAll(); checkContactPrompt(); });
|
||
} else if (s.phase === "failed" && s.error) {
|
||
setOnline(false, s.error.message);
|
||
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
|
||
log(`${s.reason || "Reconnecting"}; reconnecting…`);
|
||
}
|
||
}
|
||
renderBanner();
|
||
const netId = s.network ? s.network.id || s.network.gateway || "" : null;
|
||
if (s.devices_rev !== link.rev || devId !== link.device || (netId !== null && netId !== link.net)) {
|
||
link.rev = s.devices_rev; link.net = netId; // also when only the network changed: the Devices tab shows it
|
||
loadDevices();
|
||
}
|
||
link.phase = s.phase; link.device = devId;
|
||
// What the Devices tab offers for the failure (check a new key, pair again) follows it.
|
||
const problem = s.phase === "failed" && s.error ? (s.error.hostkey ? "hostkey" : s.error.auth || "") : "";
|
||
if (problem !== link.problem) { link.problem = problem; renderDevices(); }
|
||
if (page === "devices") renderTests();
|
||
}
|
||
|
||
// Read the event stream; fall back to polling if it can't be streamed.
|
||
async function watchConnection() {
|
||
for (;;) {
|
||
const ctl = new AbortController();
|
||
const watchdog = setInterval(() => { if (Date.now() - link.lastData > 40000) ctl.abort(); }, 5000);
|
||
try {
|
||
link.lastData = Date.now();
|
||
const r = await fetch("/api/connection/events", { headers: { "X-Frame-UI": UI_KEY }, signal: ctl.signal });
|
||
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
|
||
const reader = r.body.getReader(), dec = new TextDecoder();
|
||
let buf = "";
|
||
for (;;) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
link.lastData = Date.now();
|
||
buf += dec.decode(value, { stream: true });
|
||
let i;
|
||
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||
const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
|
||
if (chunk.startsWith("data: ")) onConnection(JSON.parse(chunk.slice(6)));
|
||
}
|
||
}
|
||
} catch {
|
||
try { onConnection(await api("/api/connection")); } catch {}
|
||
} finally { clearInterval(watchdog); }
|
||
if (link.s && link.s.local) return;
|
||
await sleep(2000);
|
||
}
|
||
}
|
||
setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { renderPill(); renderBanner(); renderConnDlg(); } }, 1000);
|
||
|
||
$("conn").onclick = () => {
|
||
if (!link.live) { if (online === false) refresh(); return; }
|
||
cd.editing = null; cd.sig = ""; cd.phase = null;
|
||
$("connDlg").showModal(); renderConnDlg();
|
||
if (!dv.data) loadDevices();
|
||
};
|
||
$("offDetails").onclick = () => $("conn").click();
|
||
$("connClose").onclick = () => $("connDlg").close();
|
||
$("connDevices").onclick = () => { $("connDlg").close(); location.hash = "devices"; };
|
||
async function retryNow() {
|
||
try { await api("/api/devices", { action: "retry" }); } catch (e) { toast(e.message, true); }
|
||
}
|
||
$("connRetryBtn").onclick = retryNow;
|
||
$("offRetry").onclick = () => { if (link.live) retryNow(); else refresh(); };
|
||
async function setUpHeadset(alias, host) {
|
||
return act(`Set Up Connection for ${alias}`, () => api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) }));
|
||
}
|
||
function setUpActive() {
|
||
const s = link.s;
|
||
if (HOST.mobile && window.frameApp && window.frameApp.setUpConnection) return window.frameApp.setUpConnection();
|
||
setUpHeadset(s && s.device ? s.device.alias : "frame");
|
||
}
|
||
$("connSetup").onclick = setUpActive;
|
||
|
||
// ---- Devices tab ----
|
||
// Secondary pages remain outside the four primary keyboard destinations.
|
||
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
|
||
async function loadDevices() {
|
||
if (!link.live) return;
|
||
const seq = ++devicesSeq;
|
||
let data;
|
||
try { data = await api("/api/devices"); } catch (e) { if (seq === devicesSeq) failed($("devList"), e); return; }
|
||
if (seq !== devicesSeq) return;
|
||
dv.data = data;
|
||
dv.list = dv.data.devices;
|
||
if (!dv.list.some(d => d.id === dv.sel)) dv.sel = dv.data.active;
|
||
const sel = $("devSel");
|
||
sel.hidden = dv.list.length < 2;
|
||
sel.innerHTML = dv.list.map(d => `<option value="${esc(d.id)}"${d.active ? " selected" : ""}>${esc(d.name)}</option>`).join("");
|
||
if (window.frameApp && window.frameApp.devicesChanged) {
|
||
window.frameApp.devicesChanged(dv.list.map(d => ({ id: d.id, name: d.name, alias: d.alias, active: !!d.active })));
|
||
}
|
||
renderDevices();
|
||
}
|
||
$("devSel").onchange = e => useDevice(e.target.value);
|
||
async function useDevice(id) {
|
||
const d = dv.list.find(x => x.id === id);
|
||
if (!d || d.active) return;
|
||
const res = await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id }));
|
||
if (!res) { // refused (an install is running): show the headset still in use
|
||
const current = dv.list.find(x => x.active);
|
||
if (current) $("devSel").value = current.id;
|
||
return;
|
||
}
|
||
// The Devices tab shows the headset now in use, not the one it was showing before.
|
||
if (dv.sel !== id) { dv.sel = id; dv.found = null; dv.editing = null; renderDevices(); }
|
||
}
|
||
async function devAction(label, body, btn) {
|
||
const res = await act(label, () => api("/api/devices", body), btn);
|
||
if (res) loadDevices(); // in order with every other load, so a late answer can't undo a newer one
|
||
return res;
|
||
}
|
||
|
||
function deviceStatus(d) {
|
||
const s = link.s;
|
||
if (d.active && s) {
|
||
return s.phase === "connected" ? ["on", `Connected via ${routeLabel(s)}`]
|
||
: s.phase === "failed" ? ["off", s.error ? s.error.short || s.error.message : "Offline", s.error && s.error.message] : ["", "Connecting…"];
|
||
}
|
||
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
|
||
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
|
||
}
|
||
|
||
function renderDevices() {
|
||
if (!dv.data) return;
|
||
const focus = document.activeElement?.closest?.('[data-dev-details], [data-use]');
|
||
const focusedId = focus?.dataset.devDetails || focus?.dataset.use;
|
||
const focusedAction = focus?.dataset.devDetails ? 'data-dev-details' : 'data-use';
|
||
$('devCount').textContent = dv.list.length;
|
||
$("devList").innerHTML = dv.list.map(d => {
|
||
const [dot, text, full] = deviceStatus(d);
|
||
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
||
<button class="dev-details grow" data-dev-details="${esc(d.id)}" aria-pressed="${d.id === dv.sel}" aria-label="Details for ${esc(d.name)}"><span class="dot ${dot}"></span><span><span class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ''}</span>
|
||
<span class="s"${full ? ` title="${esc(full)}"` : ''}>${esc(text)}</span></span></button>
|
||
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
||
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
||
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
||
if (e.target.closest("[data-use]")) return useDevice(e.target.closest("[data-use]").dataset.use);
|
||
if (e.target.closest('[data-dev-details]')) {
|
||
dv.sel = el.dataset.dev; dv.found = null; dv.editing = null; renderDevices();
|
||
}
|
||
});
|
||
if (focusedId) [...$('devList').querySelectorAll(`[${focusedAction}]`)].find(b => (b.dataset.devDetails || b.dataset.use) === focusedId)?.focus();
|
||
const n = dv.data.network;
|
||
$("netNow").textContent = n ? `${n.name}${n.gateway && !n.name.includes(n.gateway) ? ` (router ${n.gateway})` : ""}${n.tailscale && n.tailscale.up ? " · Tailscale on" : ""}` : "Checking…";
|
||
if (document.activeElement !== $("netName")) {
|
||
const known = n && dv.data.networks.find(x => x.id === n.id);
|
||
$("netName").value = known ? known.name || "" : "";
|
||
$("netName").disabled = $("netSave").disabled = !(n && n.id);
|
||
}
|
||
renderDetail();
|
||
renderConnDlg(); // the pill's dialog lists the same addresses
|
||
}
|
||
$("netSave").onclick = e => {
|
||
const n = dv.data && dv.data.network;
|
||
if (n && n.id) devAction("Name this network", { action: "name-network", network: n.id, name: $("netName").value }, e.currentTarget);
|
||
};
|
||
|
||
function renderDetail() {
|
||
const d = dv.list.find(x => x.id === dv.sel), el = $("devDetail");
|
||
if (!d) { el.innerHTML = `<div class="sub">Pick a headset.</div>`; return; }
|
||
if (el.contains(document.activeElement) && document.activeElement.matches("input, select") && el.dataset.dev === d.id) {
|
||
renderTests(); return; // don't rebuild the form under someone typing
|
||
}
|
||
el.dataset.dev = d.id;
|
||
if (d.transient) {
|
||
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2></div>
|
||
<div class="sub">This is the <code>${esc(d.alias)}</code> SSH alias, which Set Up Connection hasn't configured, so
|
||
ssh's own settings decide where it goes. Run Set Up Connection to manage its addresses here.</div>
|
||
<div class="row" style="margin-top:14px"><button class="action small" id="dvSetup">Set Up Connection…</button></div>`;
|
||
$("dvSetup").onclick = () => setUpHeadset(d.alias);
|
||
return;
|
||
}
|
||
const kinds = Object.entries(dv.data.kinds);
|
||
const kindSel = (id, v) => `<select id="${id}">${kinds.map(([k, label]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(label)}</option>`).join("")}</select>`;
|
||
const rows = d.addresses.map((a, i) => dv.editing === a.host ? `<div class="item addr"><div class="addr-edit">
|
||
<input type="text" id="edHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">${kindSel("edKind", a.kind)}
|
||
<input type="text" id="edLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
|
||
<button class="small action" id="edSave">Save</button><button class="small" id="edCancel">Cancel</button></div></div>`
|
||
: `<div class="item addr" data-host="${esc(a.host)}">
|
||
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
|
||
<div class="s">${a.network_names.length ? "Worked on " + esc(a.network_names.join(", ")) : "Hasn't worked on any network yet"}
|
||
· last OK ${esc(ago(a.last_ok))}${a.last_rtt_ms != null ? ` (${esc(a.last_rtt_ms)} ms)` : ""}</div>
|
||
<div class="s test" data-test="${esc(a.host)}"></div></div>
|
||
<button class="small" data-mv="-1" title="Try earlier"${i ? "" : " disabled"}>↑</button>
|
||
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
|
||
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
||
const err = d.active && link.s && link.s.phase === "failed" ? link.s.error : null;
|
||
const problem = err && err.hostkey ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
|
||
? "Its saved SSH identity is missing" : "Its SSH identity has changed"}</div>
|
||
<div class="s">${esc(err.message)}</div></div><button class="small action" id="dvTrustNow">Check the new key…</button></div>`
|
||
: err && err.auth === "denied" ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
|
||
<div class="s">Set Up Connection pairs this computer with it again. On the headset, Developer Mode must be on.</div></div>
|
||
<button class="small action" id="dvPair">Set Up Connection…</button></div>` : "";
|
||
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>${problem}
|
||
<form class="dev-name" id="dvNameForm">
|
||
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
|
||
<button class="small" type="submit">Rename</button></form>
|
||
<h3>Addresses <span class="h3-note">tried all at once; the first in the list wins a tie</span></h3>
|
||
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
|
||
<form class="addr-add" id="dvAdd">
|
||
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
|
||
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
|
||
<button class="small" type="submit">Add</button></form>
|
||
<div class="row" style="margin-top:14px">
|
||
<button class="small action" id="dvTest"${d.addresses.length ? "" : " disabled"}>Test now</button>
|
||
<button class="small" id="dvTs">Find on Tailscale</button>
|
||
<button class="small" id="dvMdns">Find on this network</button>
|
||
<span class="spacer"></span>
|
||
<button class="small danger" id="dvRemove">Remove headset…</button></div>
|
||
<div class="found" id="dvFound"></div>
|
||
<details class="adv"${dv.adv ? " open" : ""}><summary>Advanced</summary>
|
||
<form class="dev-form" id="dvForm">
|
||
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
||
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
||
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
||
</form>
|
||
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
||
<span class="sub">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="dvTrust" title="After reinstalling SteamOS the headset shows a new identity">Check its key…</button>
|
||
<span class="sub">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
||
: d.identity_lost ? "the saved key is missing or can't be read, so nothing is trusted until you check its key."
|
||
: "not saved yet; the next connection saves it."}</span></div>
|
||
<div class="hint">SteamOS 0.5.6 stopped publishing mDNS names. If a <code>.local</code> name stops working, Frame Control tries its last known IP on the same network. Find on this network checks nearby IPs; you can also enter the headset's IP above or use its SSH alias. A found SSH port is checked against the headset's saved identity before use.</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);
|
||
const saveDev = btn => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
|
||
user: $("dvUser").value, port: $("dvPort").value }, btn);
|
||
$("dvSave").onclick = e => saveDev(e.currentTarget);
|
||
$("dvNameForm").onsubmit = e => { e.preventDefault(); saveDev(e.submitter); };
|
||
$("dvAdd").onsubmit = e => {
|
||
e.preventDefault();
|
||
const host = $("adHost").value.trim();
|
||
if (!host) return $("adHost").focus();
|
||
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: "", label: $("adLabel").value });
|
||
};
|
||
el.querySelectorAll("[data-host]").forEach(row => {
|
||
const host = row.dataset.host;
|
||
row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () =>
|
||
devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b));
|
||
row.querySelector("[data-ed]").onclick = () => { dv.editing = host; el.dataset.dev = ""; renderDetail(); $("edHost").focus(); };
|
||
row.querySelector("[data-rm]").onclick = e => devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
|
||
});
|
||
if ($("edSave")) {
|
||
const host = dv.editing;
|
||
$("edSave").onclick = async e => {
|
||
dv.editing = null;
|
||
await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost: $("edHost").value.trim(),
|
||
kind: $("edKind").value, label: $("edLabel").value }, e.currentTarget);
|
||
};
|
||
$("edCancel").onclick = () => { dv.editing = null; el.dataset.dev = ""; renderDetail(); };
|
||
}
|
||
$("dvTest").onclick = e => devAction(`Test ${d.name}'s addresses`, { action: "test", id: d.id }, e.currentTarget);
|
||
$("dvTs").onclick = e => findOn("tailscale", d, e.currentTarget);
|
||
$("dvMdns").onclick = e => findOn("mdns", d, e.currentTarget);
|
||
$("dvTrust").onclick = () => openTrust(d.id);
|
||
if ($("dvTrustNow")) $("dvTrustNow").onclick = () => openTrust(d.id);
|
||
if ($("dvPair")) $("dvPair").onclick = () => setUpHeadset(d.alias);
|
||
$("dvRemove").onclick = () => askRemove(d);
|
||
renderFound(d);
|
||
renderTests();
|
||
}
|
||
|
||
function renderTests() {
|
||
const d = dv.list.find(x => x.id === dv.sel), t = d && link.s && (link.s.tests || {})[d.id];
|
||
document.querySelectorAll("#dvAddrs [data-test]").forEach(el => {
|
||
const row = t && t.rows.find(r => r.host === el.dataset.test);
|
||
el.className = "s test" + (row ? ` res-${row.ssh || row.state}` : "");
|
||
el.textContent = row ? `Test: ${row.detail}${row.ssh === "checking" ? " · checking SSH…" : ""}` : "";
|
||
});
|
||
}
|
||
|
||
async function findOn(where, d, btn) {
|
||
btn.disabled = true;
|
||
$("dvFound").innerHTML = `<div class="wait">${where === "tailscale" ? "Asking Tailscale for its devices…" : "Looking for headsets on this network…"}</div>`;
|
||
try {
|
||
dv.found = { where, dev: d.id, data: await api(`/api/devices/${where}?id=${encodeURIComponent(d.id)}`) };
|
||
} catch (e) { dv.found = { where, dev: d.id, error: e.message }; }
|
||
finally { btn.disabled = false; }
|
||
if (dv.sel === d.id) renderFound(d); // another headset may be showing by now
|
||
}
|
||
function renderFound(d) {
|
||
const f = dv.found, el = $("dvFound");
|
||
if (!f || f.dev !== d.id) { el.innerHTML = ""; return; }
|
||
if (f.error) { el.innerHTML = `<div class="sub">${esc(f.error)}</div>`; return; }
|
||
const add = (host, kind, label, done) => done ? `<span class="sub">Added</span>`
|
||
: `<button class="small" data-add="${esc(host)}" data-kind="${kind}" data-label="${esc(label)}">Add ${esc(host)}</button>`;
|
||
let rows;
|
||
if (f.where === "tailscale") {
|
||
if (!f.data.up) { el.innerHTML = `<div class="sub">${esc(f.data.message)}</div>`; return; }
|
||
rows = f.data.peers.slice(0, 20).map(p => `<div class="item"><span class="dot ${p.online ? "on" : ""}"></span>
|
||
<div class="grow"><div class="t">${esc(p.name)} ${p.likely ? '<span class="tag">Likely</span>' : ""}</div>
|
||
<div class="s">${esc(p.dns)} · ${esc(p.ip || "")} · ${esc(p.os || "?")} · ${p.online ? "online" : "offline"}</div></div>
|
||
${p.added ? '<span class="sub">Added</span>' : add(p.dns || p.ip, "tailscale", "Tailscale", false) + (p.ip && p.dns ? add(p.ip, "tailscale", "Tailscale IP", false) : "")}</div>`);
|
||
} else {
|
||
rows = f.data.hosts.map(h => `<div class="item"><span class="dot ${h.state === "answered" ? "on" : ""}"></span>
|
||
<div class="grow"><div class="t">${esc(h.host)} ${h.advertised ? '<span class="tag">SteamOS devkit</span>' : ""}</div>
|
||
<div class="s res-${esc(h.state)}">${esc(h.detail)}${h.ip ? " · " + esc(h.ip) : ""}</div></div>
|
||
${add(h.host, h.host.endsWith('.local') ? "mdns" : "lan", h.host.endsWith('.local') ? "mDNS" : "Local network", h.added)}${h.ip && h.ip !== h.host && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
|
||
if (!f.data.tool) rows.push(`<div class="sub">Install Bonjour (Windows) or avahi (Linux) to search for SteamOS devkit services.</div>`);
|
||
rows.push(`<div class="sub">${f.data.scan_requires_pin ? "Enter this headset's IP above to pair it first. The subnet search starts after its SSH identity is saved." : `Checked ${Number(f.data.scanned || 0)} nearby IPs against this headset's saved SSH identity.`} A device may be absent if SSH is off or this computer is on another subnet.</div>`);
|
||
}
|
||
el.innerHTML = `<h3>${f.where === "tailscale" ? "On your tailnet" : "On this network"}</h3>` +
|
||
(rows.length ? `<div class="list">${rows.join("")}</div>` : `<div class="sub">Nothing found.</div>`);
|
||
el.querySelectorAll("[data-add]").forEach(b => b.onclick = async () => {
|
||
const res = await devAction(`Add ${b.dataset.add}`, { action: "address-add", id: d.id, host: b.dataset.add,
|
||
kind: b.dataset.kind, label: b.dataset.label }, b);
|
||
if (res) { b.replaceWith(Object.assign(document.createElement("span"), { className: "sub", textContent: "Added" })); }
|
||
});
|
||
}
|
||
|
||
// Trust a new key: read the key the headset shows now (nothing is saved), show it next to the
|
||
// saved one, and save it only when the person says it's their headset. The server pins exactly
|
||
// the key it showed here (frame_link.Link.trust_identity), never whatever answers next.
|
||
const HOST_KEY_FILE = { "ssh-ed25519": "ed25519", "ssh-rsa": "rsa", "ssh-dss": "dsa" };
|
||
async function openTrust(id) {
|
||
const d = dv.list.find(x => x.id === id);
|
||
const name = d ? d.name : "the headset";
|
||
$("trustTitle").textContent = `Check ${name}'s key`;
|
||
$("trustText").textContent = "Asking the headset for its key…";
|
||
$("trustKeys").innerHTML = ""; $("trustMsg").textContent = "";
|
||
$("trustHow").hidden = true; $("trustGo").disabled = true;
|
||
$("trustCancel").onclick = () => $("trustDlg").close();
|
||
$("trustForm").onsubmit = e => e.preventDefault();
|
||
if (!$("trustDlg").open) $("trustDlg").showModal();
|
||
let res;
|
||
try { res = await api("/api/devices", { action: "identity-scan", id }); }
|
||
catch (e) { $("trustText").textContent = e.message; return; }
|
||
const scan = res.scan;
|
||
const saved = (scan.saved || []).map(k => `${k.type} ${k.fingerprint}`).join("\n")
|
||
|| ((scan.recorded || []).length ? `${scan.recorded.join("\n")}\n(recorded; the saved file is missing or unreadable)` : "None");
|
||
$("trustKeys").innerHTML = [["Saved key", saved], ["It shows now", `${scan.type} ${scan.fingerprint}`], ["At", scan.host]]
|
||
.map(([k, v]) => `<dt>${esc(k)}</dt><dd style="white-space:pre-wrap; word-break:break-all"><code>${esc(v)}</code></dd>`).join("");
|
||
if (scan.matches) {
|
||
$("trustText").textContent = `${name} shows the key already saved for it, so there's nothing to change. If it still won't connect, try Test now.`;
|
||
return;
|
||
}
|
||
$("trustText").textContent = scan.recorded_match
|
||
? `${name} shows the key recorded for it before, but the saved copy is missing or can't be read. Trust it to save it again.`
|
||
: (scan.saved || []).length || (scan.recorded || []).length
|
||
? `${name} now shows a different SSH key from the one saved. That's expected after SteamOS was reinstalled or reset, or if this is a new headset at the same address. If none of that happened, don't trust it: another device may be answering in its place.`
|
||
: `No key is saved for ${name} yet. Trust this one if it's your headset.`;
|
||
$("trustCmd").textContent = `ssh-keygen -lf /etc/ssh/ssh_host_${HOST_KEY_FILE[scan.type] || "ecdsa"}_key.pub`;
|
||
$("trustHow").hidden = false;
|
||
$("trustGo").disabled = false;
|
||
$("trustForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const ok = await devAction(`Trust ${name}'s new key`, { action: "identity-trust", id, fingerprint: scan.fingerprint }, $("trustGo"));
|
||
if (ok) $("trustDlg").close();
|
||
else $("trustMsg").textContent = "Not saved. Close this and check the key again.";
|
||
};
|
||
}
|
||
|
||
function askRemove(d) {
|
||
$("rmDevTitle").textContent = `Remove ${d.name}?`;
|
||
$("rmDevText").textContent = `Frame Control forgets this headset, its ${d.addresses.length} address${d.addresses.length === 1 ? "" : "es"} and its saved identity. Its SSH keys stay.`;
|
||
$("rmDevConfigText").textContent = `Also remove the '${d.alias}' entry from ~/.ssh/config (Terminal's ssh ${d.alias} stops working)`;
|
||
// The only headset: its entry has to go too, or the app would go on using that alias.
|
||
$("rmDevConfig").checked = dv.list.filter(x => !x.transient).length === 1; $("rmMsg").textContent = "";
|
||
$("rmDevConfig").parentElement.hidden = !d.managed;
|
||
$("rmCancel").onclick = () => $("rmDevDlg").close();
|
||
$("rmDevForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const res = await devAction(`Remove ${d.name}`, { action: "remove", id: d.id, config: $("rmDevConfig").checked }, $("rmGo"));
|
||
if (res) $("rmDevDlg").close();
|
||
};
|
||
$("rmDevDlg").showModal();
|
||
}
|
||
$("devAdd").onclick = () => {
|
||
$("addAlias").value = (dv.data && dv.data.nextAlias) || "frame-2";
|
||
$("addHost").value = ""; $("addMsg").textContent = "";
|
||
$("addCancel").onclick = () => $("addDevDlg").close();
|
||
$("addDevDlg").showModal();
|
||
$("addAlias").select();
|
||
};
|
||
$("addDevForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const alias = $("addAlias").value.trim(), host = $("addHost").value.trim();
|
||
$("addGo").disabled = true;
|
||
try {
|
||
const res = await api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) });
|
||
log(res.message, "ok"); toast(res.message);
|
||
$("addDevDlg").close();
|
||
} catch (err) { $("addMsg").textContent = err.message; }
|
||
finally { $("addGo").disabled = false; }
|
||
};
|
||
// Electron's Frame menu can switch headsets and open this tab.
|
||
if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(id => useDevice(id));
|
||
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
|
||
showPage(); // #devices is a page now
|
||
api("/api/host").then(h => {
|
||
Object.assign(HOST, h);
|
||
window.frameControlCenter?.host(h);
|
||
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
|
||
$("pcToolLink").hidden = h.mobile || h.os !== "Windows";
|
||
$("steamPipewireLink").hidden = h.mobile || h.os !== "Linux";
|
||
if (!h.mobile && h.os === "Windows") TOOL_TITLES.macview = "PC in the headset";
|
||
$("backupToolLink").hidden = !!h.mobile; // backups are folders on a computer
|
||
if (h.mobile) return;
|
||
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
|
||
watchConnection();
|
||
}).catch(() => watchConnection());
|
||
</script>
|
||
<script src="/artwork-settings.js"></script>
|
||
<script src="/control-center.js"></script>
|
||
<script>
|
||
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
|
||
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
|
||
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || "", confirm: text => confirm(text)});
|
||
window.frameControlCenter.host(HOST);
|
||
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
|
||
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
|
||
if (online === false) window.frameControlCenter.connection(false);
|
||
</script>
|
||
</body>
|
||
</html>
|