mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +02:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3643 lines
209 KiB
HTML
3643 lines
209 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 */
|
||
html { scroll-behavior: smooth; scroll-padding-top: calc(76px + env(safe-area-inset-top)); }
|
||
body { margin: 0; color: var(--text); min-height: 100vh; padding-bottom: calc(46px + env(safe-area-inset-bottom));
|
||
font: 14px/1.45 "Motiva Sans", -apple-system, "Segoe UI", Arial, sans-serif;
|
||
background: radial-gradient(1400px 700px at 15% -15%, #2a475e 0%, transparent 60%),
|
||
linear-gradient(180deg, var(--bg) 0%, #16202d 40%, var(--bg-deep) 100%) fixed; }
|
||
::selection { background: var(--blue); color: #fff; }
|
||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||
::-webkit-scrollbar-thumb { background: #3d4450; border-radius: 5px; border: 2px solid transparent; background-clip: padding-box; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
|
||
/* ---- top bar (Steam's STORE / LIBRARY / COMMUNITY strip) ---- */
|
||
header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 30px;
|
||
height: calc(64px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(28px + env(safe-area-inset-right)) 0 calc(28px + env(safe-area-inset-left)); background: rgba(23, 29, 37, .94); backdrop-filter: blur(12px);
|
||
box-shadow: 0 1px 0 rgba(255,255,255,.04), 0 6px 18px rgba(0,0,0,.35); }
|
||
.brand { display: flex; align-items: center; gap: 11px; color: var(--bright); text-decoration: none; }
|
||
.brand svg { width: 30px; height: 30px; }
|
||
.brand b { font-weight: 700; font-size: 15px; letter-spacing: 2.5px; }
|
||
nav { display: flex; gap: 4px; height: 100%; }
|
||
nav a { display: flex; align-items: center; padding: 0 13px; height: 100%; color: var(--text); text-decoration: none;
|
||
font-weight: 500; font-size: 15px; letter-spacing: 1.2px; text-transform: uppercase;
|
||
border-bottom: 3px solid transparent; transition: color .15s; }
|
||
nav a:hover { color: var(--bright); }
|
||
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
|
||
.spacer { flex: 1; }
|
||
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
|
||
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
|
||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
|
||
.dot.on { background: var(--green); box-shadow: 0 0 6px rgba(89,191,64,.8); }
|
||
.dot.off { background: var(--bad); }
|
||
|
||
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
||
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
|
||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||
@media (max-width: 820px) { .brand b { display: none; } }
|
||
nav svg { display: none; }
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
body { -webkit-tap-highlight-color: transparent; }
|
||
.mobile-only { display: none; }
|
||
body.mobile .mobile-only { display: revert; }
|
||
body.mobile .desk-only { display: none; }
|
||
|
||
|
||
/* ---- pages: one per tab; the header nav switches between them ---- */
|
||
.page { display: flex; flex-direction: column; gap: 26px; }
|
||
.page:not(.on) { display: none; }
|
||
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
|
||
@media (max-width: 1150px) { nav kbd { display: none; } }
|
||
|
||
/* ---- offline banner ---- */
|
||
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
|
||
background: rgba(217,65,38,.12); box-shadow: inset 3px 0 0 var(--bad); }
|
||
.banner svg { width: 22px; height: 22px; flex: none; color: #ff8a73; }
|
||
.banner .grow { flex: 1; min-width: 0; }
|
||
.banner .t { color: var(--bright); font-weight: 500; }
|
||
.banner .s { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||
|
||
/* ---- drop anywhere ---- */
|
||
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
||
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
||
background: rgba(26,159,255,.08); color: var(--text); max-width: 520px; }
|
||
.dropzone b { display: block; color: var(--bright); font-size: 20px; font-weight: 500; margin-bottom: 6px; }
|
||
|
||
/* ---- panels and section headers ---- */
|
||
.panel { background: linear-gradient(180deg, rgba(42,47,56,.72) 0%, rgba(30,35,41,.9) 100%);
|
||
border-radius: 4px; padding: 18px; box-shadow: 0 8px 24px rgba(0,0,0,.25); min-width: 0; }
|
||
.shelf-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 14px; }
|
||
.shelf-head h2 { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: var(--text); }
|
||
.shelf-head .count { color: var(--muted); font-size: 13px; }
|
||
.shelf-head .spacer { flex: 1; }
|
||
.sub { color: var(--muted); font-size: 12.5px; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||
.hint { color: var(--muted); font-size: 12.5px; margin-top: 11px; line-height: 1.5; }
|
||
|
||
/* ---- buttons ---- */
|
||
button { appearance: none; border: 0; border-radius: 3px; padding: 0 14px; height: 34px; font: inherit; font-size: 14px;
|
||
color: var(--text); background: var(--btn); cursor: pointer; white-space: nowrap;
|
||
transition: background .12s, color .12s, filter .12s; display: inline-flex; align-items: center; gap: 8px; }
|
||
button:hover { background: var(--btn-hi); color: var(--bright); }
|
||
button:active { filter: brightness(.9); }
|
||
button:disabled { opacity: .45; cursor: default; }
|
||
button:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
||
button.action { background: var(--action); color: #fff; font-weight: 500; }
|
||
button.action:hover { background: var(--action-hi); }
|
||
button.play { background: var(--play); color: #fff; font-weight: 700; letter-spacing: .5px; }
|
||
button.play:hover { background: var(--play-hi); }
|
||
button.danger:hover { background: rgba(217,65,38,.35); color: #fff; }
|
||
button.small { height: 28px; padding: 0 11px; font-size: 13px; }
|
||
button.on { background: rgba(26,159,255,.22); color: var(--link); }
|
||
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
|
||
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
|
||
.seg button.on { background: var(--btn-hi); color: var(--bright); }
|
||
input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||
textarea { resize: vertical; min-height: 76px; }
|
||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||
|
||
/* ---- headset view ---- */
|
||
.viewer { position: relative; aspect-ratio: 16 / 9; max-height: calc(100vh - 190px); margin: 0 auto; background: #000; border-radius: 3px; overflow: hidden;
|
||
display: grid; place-items: center; box-shadow: inset 0 0 0 1px rgba(255,255,255,.05); }
|
||
/* Fit the whole frame; zoom and pan are a transform on top of that. */
|
||
.viewer canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block;
|
||
transform-origin: 0 0; cursor: zoom-in; }
|
||
.viewer.zoomed canvas { cursor: grab; }
|
||
.viewer.panning canvas { cursor: grabbing; }
|
||
.viewer:fullscreen { aspect-ratio: auto; border-radius: 0; }
|
||
.zoombar { position: absolute; left: 12px; bottom: 12px; display: flex; gap: 2px; padding: 2px; border-radius: 3px;
|
||
background: rgba(0,0,0,.6); }
|
||
.zoombar button { height: 26px; min-width: 28px; padding: 0 8px; font-size: 13px; background: transparent; justify-content: center; }
|
||
.zoombar button:hover { background: rgba(255,255,255,.12); }
|
||
.zoombar .lvl { min-width: 46px; font-variant-numeric: tabular-nums; color: var(--muted); font-size: 12px; }
|
||
.viewer .empty { text-align: center; color: var(--muted); padding: 24px; max-width: 460px; }
|
||
.viewer .empty svg { width: 64px; height: 64px; opacity: .35; margin-bottom: 10px; }
|
||
.viewer .badge { position: absolute; left: 12px; top: 12px; font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase;
|
||
background: rgba(0,0,0,.6); color: var(--text); padding: 4px 9px; border-radius: 2px; }
|
||
.viewer .badge.live { left: auto; right: 12px; background: var(--bad); color: #fff; font-weight: 700; }
|
||
.view-actions { display: flex; gap: 10px; }
|
||
.ctrl-status { margin-top: 10px; font-size: 13px; color: var(--blue); }
|
||
.ctrl-status::before { content: "● "; }
|
||
.ctrl-status.warn { color: var(--warn); }
|
||
/* Control: the view takes taps, clicks, drags and keys, and none of them zoom or pan it. */
|
||
.viewer.control { outline: 1px solid var(--blue); }
|
||
.viewer.control canvas { cursor: crosshair; touch-action: none; }
|
||
.viewer.control.rel canvas { cursor: move; }
|
||
.viewer.immersive { position: fixed; z-index: 40; inset: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left);
|
||
max-height: none; aspect-ratio: auto; border-radius: 0; }
|
||
.ctrl-keys { margin-top: 10px; }
|
||
#panelPick { max-width: 240px; height: 30px; }
|
||
.viewer .stamp { position: absolute; right: 12px; bottom: 12px; font-size: 11px; color: #c7d5e0;
|
||
background: rgba(0,0,0,.6); padding: 3px 8px; border-radius: 2px; }
|
||
.viewer .asleep { position: absolute; inset: auto 0 0 0; padding: 14px 18px; text-align: center; color: var(--text);
|
||
background: linear-gradient(to top, rgba(0,0,0,.85), transparent); font-size: 13px; }
|
||
.viewer .loading { position: absolute; inset: 0; display: none; place-items: center; background: rgba(0,0,0,.35); }
|
||
.viewer.busy .loading { display: grid; }
|
||
.spinner { width: 34px; height: 34px; border: 3px solid rgba(255,255,255,.15); border-top-color: var(--blue);
|
||
border-radius: 50%; animation: spin .8s linear infinite; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
||
|
||
/* ---- device ---- */
|
||
.battery { display: flex; gap: 16px; align-items: center; padding: 16px; border-radius: 3px; background: rgba(0,0,0,.22); }
|
||
.battery svg { width: 74px; height: 38px; flex: none; }
|
||
.battery .pct { font-size: 34px; font-weight: 300; color: var(--bright); line-height: 1; font-variant-numeric: tabular-nums; }
|
||
.battery .state { font-weight: 700; font-size: 13px; letter-spacing: 1.2px; text-transform: uppercase; margin-top: 6px; }
|
||
.battery .state.charging { color: var(--green-hi); } .battery .state.full { color: var(--green); }
|
||
.battery .state.low { color: var(--bad); } .battery .state.draining { color: var(--muted); }
|
||
.battery .detail { color: var(--muted); font-size: 12.5px; margin-top: 2px; }
|
||
.stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; margin-top: 14px; border-radius: 3px; overflow: hidden; }
|
||
.stat { background: rgba(0,0,0,.18); padding: 10px 12px; min-width: 0; }
|
||
.stat .k { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; }
|
||
.stat .v { color: var(--bright); font-size: 17px; margin-top: 1px; font-variant-numeric: tabular-nums;
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.stat .d { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.meter { height: 4px; background: rgba(255,255,255,.08); border-radius: 2px; margin-top: 7px; overflow: hidden; }
|
||
.meter > i { display: block; width: 0; height: 100%; background: var(--link); transition: width .4s; }
|
||
.meter > i.warn { background: var(--warn); } .meter > i.bad { background: var(--bad); }
|
||
.chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 14px; }
|
||
.chips .chip { height: 26px; font-size: 12px; background: rgba(0,0,0,.22); }
|
||
.vol { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||
.vol input[type=range] { flex: 1; -webkit-appearance: none; appearance: none; height: 4px; border-radius: 2px;
|
||
background: linear-gradient(to right, var(--blue) var(--fill, 0%), rgba(255,255,255,.12) var(--fill, 0%)); }
|
||
.vol input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
|
||
background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.5); cursor: pointer; }
|
||
.vol .num { width: 40px; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||
|
||
/* ---- Steam screenshots from the headset ---- */
|
||
.shot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
|
||
.shot-card { display: flex; flex-direction: column; gap: 6px; }
|
||
.shot-card .thumb { width: 100%; aspect-ratio: 16 / 9; border-radius: 3px; object-fit: cover; background: rgba(0,0,0,.3);
|
||
display: block; cursor: zoom-in; box-shadow: 0 6px 16px rgba(0,0,0,.45); }
|
||
.shot-card .thumb:hover { box-shadow: 0 6px 16px rgba(0,0,0,.45), 0 0 0 1px rgba(255,255,255,.25); }
|
||
.shot-card .row { flex-wrap: nowrap; }
|
||
.shot-card .grow { flex: 1; min-width: 0; }
|
||
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.shot-card .s { color: var(--muted); font-size: 12px; }
|
||
|
||
/* ---- 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; }
|
||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||
.popt b { font-weight: 600; color: var(--text); }
|
||
.popt .sub { grid-column: 2; line-height: 1.45; }
|
||
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
|
||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
|
||
details summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 12px; }
|
||
.links { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 13px; color: var(--muted); }
|
||
.links a { color: var(--link); text-decoration: none; } .links a:hover { color: #fff; }
|
||
.links div { margin: 5px 0; }
|
||
|
||
/* ---- bottom bar (Steam's DOWNLOADS strip) ---- */
|
||
.bottombar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; height: calc(42px + env(safe-area-inset-bottom)); display: flex; align-items: center; gap: 14px;
|
||
padding: 0 calc(28px + env(safe-area-inset-right)) env(safe-area-inset-bottom) calc(28px + env(safe-area-inset-left)); background: rgba(23,29,37,.96); box-shadow: 0 -1px 0 rgba(255,255,255,.04); cursor: pointer; }
|
||
.bottombar .label { font-size: 12px; font-weight: 700; letter-spacing: 1.6px; color: var(--muted); }
|
||
.bottombar .jobs { display: inline-flex; align-items: center; gap: 8px; color: var(--link); font-size: 12.5px; }
|
||
.bottombar .jobs .spinner { width: 13px; height: 13px; border-width: 2px; }
|
||
.bottombar .last { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); font-size: 13px; }
|
||
.bottombar .last.e { color: #ff8a73; }
|
||
.drawer { position: fixed; left: 0; right: 0; bottom: calc(42px + env(safe-area-inset-bottom)); z-index: 19; max-height: 0; overflow: hidden;
|
||
background: rgba(14,20,27,.98); transition: max-height .25s ease; box-shadow: 0 -10px 30px rgba(0,0,0,.5); }
|
||
.drawer.open { max-height: 300px; }
|
||
.log { font: 12px/1.55 ui-monospace, "SF Mono", Menlo, monospace; color: #b8c4d2; max-height: 300px; overflow: auto;
|
||
white-space: pre-wrap; padding: 12px calc(28px + env(safe-area-inset-right)) 12px calc(28px + env(safe-area-inset-left)); }
|
||
.log:empty::before { content: "Actions you take here are logged with their output."; color: var(--dim); }
|
||
.log .e { color: #ff8a73; } .log .ok { color: #a4d007; } .log time { color: var(--dim); }
|
||
.toast { position: fixed; bottom: calc(58px + env(safe-area-inset-bottom)); right: calc(28px + env(safe-area-inset-right)); z-index: 30; max-width: 460px; padding: 12px 16px; border-radius: 3px;
|
||
background: #3d4450; color: #fff; box-shadow: 0 10px 30px rgba(0,0,0,.5); border-left: 3px solid var(--blue);
|
||
opacity: 0; transform: translateY(10px); transition: opacity .2s, transform .2s; pointer-events: none; }
|
||
.toast.show { opacity: 1; transform: none; }
|
||
.toast.err { border-left-color: var(--bad); }
|
||
/* ---- Android ---- */
|
||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||
.rep-item .s { white-space: normal; }
|
||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||
#bugForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||
#bugForm .popt { margin: 14px 0 0; }
|
||
#bugForm .sentlog { max-height: 200px; }
|
||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||
.about-text { max-height: 55vh; overflow: auto; }
|
||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||
#titleForm select:focus { outline: none; border-color: var(--blue); }
|
||
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||
#titleForm .note.warn { color: #d9a23a; }
|
||
#titleList { margin-top: 8px; }
|
||
#wiFacts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 13.5px; }
|
||
#wiFacts dt { color: var(--muted); }
|
||
#wiFacts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
|
||
#wiWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 14px 0 0; }
|
||
#wiProg:not([hidden]) { display: block; }
|
||
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
|
||
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
|
||
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
|
||
#repForm input:invalid:not(:placeholder-shown) { border-color: var(--bad); }
|
||
.rep-row { display: grid; grid-template-columns: 1fr 1.4fr; gap: 10px; }
|
||
.rep-file { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||
.rep-actions { margin-top: 16px; }
|
||
.and-item { flex-wrap: wrap; }
|
||
.and-btns { flex-basis: 100%; padding-left: 46px; }
|
||
.and-icon { width: 36px; height: 36px; border-radius: 8px; flex: none; background: rgba(0,0,0,.25); object-fit: cover; }
|
||
.cat-tools { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||
.cat-tools input[type=text] { flex: 1 1 240px; width: auto; }
|
||
.cat-tools select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
|
||
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
|
||
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
|
||
.vchip[aria-pressed=true] { opacity: 1; box-shadow: inset 0 0 0 1px currentColor; }
|
||
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||
.cat-card { background: rgba(0,0,0,.2); border-radius: 3px; padding: 10px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||
.cat-card .top { display: flex; gap: 10px; align-items: flex-start; }
|
||
.cat-card .t { color: var(--bright); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||
.cat-card .s { color: var(--muted); font-size: 12px; }
|
||
.cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; }
|
||
.cat-card .row { margin-top: auto; }
|
||
.v-works, .v-likely { color: var(--green-hi); } .v-maybe { color: var(--warn); }
|
||
.v-unlikely, .v-no { color: var(--bad); }
|
||
.vbadge { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; margin-left: auto; }
|
||
@media (max-width: 1100px) { .and-grid { grid-template-columns: 1fr; } }
|
||
/* ---- Steam games (owned + store) ---- */
|
||
.game-card .art { width: 100%; aspect-ratio: 231 / 87; border-radius: 2px; object-fit: cover; background: rgba(0,0,0,.25); display: block; }
|
||
.game-card .price { color: var(--bright); font-size: 13px; }
|
||
.game-card .price s { color: var(--dim); margin-right: 6px; }
|
||
.cat-card .row a { color: var(--link); text-decoration: none; font-size: 13px; padding: 0 4px; }
|
||
.cat-card .row a:hover { color: #fff; }
|
||
.game-card .bar { height: 3px; background: rgba(255,255,255,.08); border-radius: 2px; overflow: hidden; }
|
||
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
|
||
.f-0 { color: var(--muted); }
|
||
/* ---- Android display ---- */
|
||
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
|
||
.mv-windows { max-height: 420px; overflow-y: auto; }
|
||
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
|
||
.mv-perm .row { margin-top: 8px; }
|
||
.mv-live { color: var(--green, #7fd67f); font-size: 12px; }
|
||
.mv-opt { display: block; font-size: 13px; margin: 0 0 12px; }
|
||
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
|
||
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||
padding: 8px 10px; font: inherit; font-size: 13px; }
|
||
.disp select:focus { outline: none; border-color: var(--blue); }
|
||
.disp .stats { grid-template-columns: repeat(3, 1fr); margin: 12px 0 4px; }
|
||
.disp select:disabled { opacity: .6; }
|
||
.disp .stat .v { font-size: 15px; }
|
||
.disp .k2 { color: var(--muted); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; margin: 12px 0 5px; }
|
||
.disp .seg { flex-wrap: wrap; }
|
||
.disp .seg button { padding: 0 10px; }
|
||
.disp input[type=number], #comfort input[type=number] { width: 72px; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||
/* ---- keyboard and trackpad ---- */
|
||
.pad-area { position: relative; height: 190px; margin: 12px 0; border-radius: 4px; background: rgba(0,0,0,.28);
|
||
border: 1px dashed rgba(255,255,255,.14); display: grid; place-items: center; cursor: pointer;
|
||
touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none; }
|
||
.pad-area:focus-visible, .pad-area.captured { border: 1px solid var(--blue); box-shadow: 0 0 0 1px var(--blue) inset; }
|
||
.pad-area.off { cursor: default; opacity: .55; }
|
||
.pad-hint { max-width: 420px; padding: 0 16px; text-align: center; color: var(--muted); font-size: 13px; pointer-events: none; }
|
||
.pad-keys { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||
.pad-keys input { flex: 1 1 220px; min-width: 0; height: 32px; padding: 0 10px; border-radius: 3px; border: 1px solid transparent;
|
||
background: rgba(0,0,0,.28); color: var(--text); font: inherit; }
|
||
.pad-keys input:focus { outline: none; border-color: var(--blue); }
|
||
#pad .hint { margin-top: 10px; }
|
||
|
||
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||
html { scroll-padding-top: calc(64px + env(safe-area-inset-top)); }
|
||
/* No blur here: it would make the header the containing block of the fixed tab bar. */
|
||
header { height: calc(52px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) calc(14px + env(safe-area-inset-right)) 0 calc(14px + env(safe-area-inset-left)); gap: 10px;
|
||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||
.brand svg { width: 26px; height: 26px; }
|
||
header .chip { height: 30px; padding: 0 10px; }
|
||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||
nav a { flex: 1; flex-direction: column; justify-content: center; gap: 3px; padding: 0; font-size: 10.5px;
|
||
letter-spacing: .5px; border-bottom: 0; color: var(--muted); }
|
||
nav a.on { border-bottom: 0; }
|
||
nav svg { display: block; width: 23px; height: 23px; }
|
||
.bottombar, .drawer { display: none; }
|
||
body { padding-bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||
.toast { left: calc(14px + env(safe-area-inset-left)); right: calc(14px + env(safe-area-inset-right)); max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||
main, .page { gap: 16px; }
|
||
main { padding: 12px calc(12px + env(safe-area-inset-right)) 20px calc(12px + env(safe-area-inset-left)); }
|
||
.panel { padding: 14px; }
|
||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||
.toolbar { gap: 8px; }
|
||
.toolbar .spacer { display: none; }
|
||
.view-actions { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
|
||
.view-actions button { justify-content: center; padding: 0 4px; }
|
||
#panelPick { flex: 1 1 100%; max-width: none; }
|
||
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
|
||
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
|
||
.cat-grid { grid-template-columns: 1fr; }
|
||
.shot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||
.and-btns { padding-left: 0; }
|
||
.banner { flex-wrap: wrap; }
|
||
.banner .s { white-space: normal; }
|
||
button { height: 38px; }
|
||
button.small { height: 32px; }
|
||
.actions button { height: 46px; }
|
||
.cat-tools select { 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; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<a class="brand" href="#home" aria-label="Frame Control">
|
||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1a9fff"/><stop offset="1" stop-color="#1a44c2"/></linearGradient></defs>
|
||
<circle cx="16" cy="16" r="15" fill="url(#lg)"/>
|
||
<rect x="6.5" y="11" width="19" height="10" rx="4" fill="none" stroke="#fff" stroke-width="2"/>
|
||
<circle cx="12" cy="16" r="1.8" fill="#fff"/><circle cx="20" cy="16" r="1.8" fill="#fff"/>
|
||
</svg>
|
||
<b>FRAME CONTROL</b>
|
||
</a>
|
||
<nav id="nav">
|
||
<a href="#home" class="on" title="Headset view, status and screenshots (1)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="4"/><circle cx="8.5" cy="12" r="1.6"/><circle cx="15.5" cy="12" r="1.6"/></svg>Home<kbd>1</kbd></a>
|
||
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
|
||
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
|
||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
|
||
</nav>
|
||
<div class="spacer"></div>
|
||
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
||
<span class="chip" id="battChip" title="Battery">—</span>
|
||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
||
</button>
|
||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||
</button>
|
||
</header>
|
||
|
||
<main>
|
||
<div class="notice" id="updateBar" hidden>
|
||
<div class="grow" id="updateText"></div>
|
||
<div class="progress" id="updateProg" hidden><i></i></div>
|
||
<button class="small" id="updateNotes">What's new</button>
|
||
<button class="action small" id="updateGo">Update and restart</button>
|
||
<button class="small" id="updateLater">Later</button>
|
||
</div>
|
||
<div class="notice" id="privacyNotice" hidden>
|
||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
|
||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||
worked and the details of errors, which helps fix problems faster.</div>
|
||
<button class="small" id="noticeSettings">Privacy settings</button>
|
||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||
<button class="action small" id="noticeOk">OK</button>
|
||
</div>
|
||
<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" id="offSetup" hidden>Set Up Connection…</button>
|
||
<button class="action small" id="offRetry">Retry now</button>
|
||
</div>
|
||
|
||
<div class="page on" data-page="home">
|
||
<div class="grid-top" id="view">
|
||
<section class="panel">
|
||
<div class="toolbar">
|
||
<div class="seg" aria-label="What to capture">
|
||
<button data-view="headset" class="on" title="What the headset shows, via the SteamVR compositor">Headset view</button>
|
||
<button data-view="flat" title="The app panel in use, flat: it stays still however the wearer looks around">Desktop</button>
|
||
</div>
|
||
<select id="panelPick" class="small" hidden aria-label="Which panel to show"></select>
|
||
<div class="seg" id="eyeSeg" aria-label="Crop">
|
||
<button data-eye="left" class="on" title="One eye, like pointing a camera into one lens">Left eye</button>
|
||
<button data-eye="both" title="Both eyes side by side">Both eyes</button>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<span class="view-actions">
|
||
<button class="action" id="shotBtn">Capture</button>
|
||
<button id="liveBtn" title="Keep updating, as video">Live</button>
|
||
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
|
||
<button id="saveBtn" disabled>Save</button>
|
||
</span>
|
||
</div>
|
||
<div class="viewer" id="viewer">
|
||
<div class="empty" id="viewerEmpty">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="2" y="7" width="20" height="10" rx="4"/><circle cx="8" cy="12" r="2"/><circle cx="16" cy="12" r="2"/></svg>
|
||
<div>Press <b>Capture</b> to grab what the Frame is showing, or <b>Live</b> to keep it updating.</div>
|
||
</div>
|
||
<canvas id="canvas" hidden></canvas>
|
||
<span class="badge" id="srcBadge" hidden></span>
|
||
<span class="badge live" id="liveBadge" hidden>Live</span>
|
||
<span class="stamp" id="stamp" hidden></span>
|
||
<div class="zoombar" id="zoombar" hidden>
|
||
<button id="zoomOut" title="Zoom out (−)" aria-label="Zoom out">−</button>
|
||
<button id="zoomFit" class="lvl" title="Fit the whole frame (0)">Fit</button>
|
||
<button id="zoomIn" title="Zoom in (+)" aria-label="Zoom in">+</button>
|
||
<button id="fullBtn" title="Full screen (F)" aria-label="Full screen">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="asleep" id="asleep" hidden>The headset is showing a blank frame. It's probably asleep or not being worn. Put it on and capture again.</div>
|
||
<div class="loading"><div class="spinner"></div></div>
|
||
</div>
|
||
<div class="ctrl-status" id="ctrlBadge" role="status" hidden></div>
|
||
<div class="pad-keys ctrl-keys" id="ctrlKeys" hidden aria-label="Keys and clicks for the Frame">
|
||
<input id="ctrlType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||
<span class="seg pad-clicks">
|
||
<button data-ctrl-click="left" title="Left click where the pointer is">Click</button>
|
||
<button data-ctrl-click="right" title="Right click where the pointer is">Right-click</button>
|
||
</span>
|
||
<span class="seg pad-special">
|
||
<button data-ctrl-key="1" title="Escape">Esc</button>
|
||
<button data-ctrl-key="15" title="Tab">Tab</button>
|
||
<button data-ctrl-key="105" title="Left arrow" aria-label="Left arrow">←</button>
|
||
<button data-ctrl-key="103" title="Up arrow" aria-label="Up arrow">↑</button>
|
||
<button data-ctrl-key="108" title="Down arrow" aria-label="Down arrow">↓</button>
|
||
<button data-ctrl-key="106" title="Right arrow" aria-label="Right arrow">→</button>
|
||
<button data-ctrl-key="14" title="Backspace" aria-label="Backspace">⌫</button>
|
||
<button data-ctrl-key="28" title="Enter">Enter</button>
|
||
</span>
|
||
</div>
|
||
<div class="hint" id="viewHint"></div>
|
||
</section>
|
||
|
||
<section class="panel" id="device">
|
||
<div class="shelf-head"><h2>Device</h2><span class="count" id="devName"></span><span class="spacer"></span><span class="sub" id="updated"></span></div>
|
||
<div class="battery">
|
||
<svg viewBox="0 0 74 38" aria-hidden="true">
|
||
<rect x="1.5" y="1.5" width="64" height="35" rx="5" fill="none" stroke="#8f98a0" stroke-width="3"/>
|
||
<rect x="67" y="12" width="5.5" height="14" rx="2" fill="#8f98a0"/>
|
||
<rect id="battFill" x="6" y="6" width="0" height="26" rx="2.5" fill="#59bf40"/>
|
||
<path id="battBolt" d="M37 5 L27 21 H34 L31 33 L42 16 H35 Z" fill="#fff" stroke="#1e2329" stroke-width="1.6" stroke-linejoin="round" visibility="hidden"/>
|
||
</svg>
|
||
<div style="min-width:0">
|
||
<div class="pct" id="battPct">—</div>
|
||
<div class="state draining" id="battState"> </div>
|
||
<div class="detail" id="battDetail"> </div>
|
||
</div>
|
||
</div>
|
||
<div class="stats">
|
||
<div class="stat"><div class="k">Storage free</div><div class="v" id="sDisk">—</div><div class="meter"><i id="mDisk"></i></div></div>
|
||
<div class="stat"><div class="k">Memory used</div><div class="v" id="sMem">—</div><div class="meter"><i id="mMem"></i></div></div>
|
||
<div class="stat"><div class="k">Temperature</div><div class="v" id="sTemp">—</div><div class="d" id="dTemp"> </div></div>
|
||
<div class="stat"><div class="k">Wi-Fi</div><div class="v" id="sWifi">—</div><div class="d" id="dWifi"> </div></div>
|
||
<div class="stat"><div class="k">Uptime</div><div class="v" id="sUp">—</div><div class="d" id="dUp"> </div></div>
|
||
<div class="stat"><div class="k">SteamOS</div><div class="v" id="sOs">—</div><div class="d" id="dOs"> </div></div>
|
||
</div>
|
||
<div class="chips" id="services"></div>
|
||
<div class="vol">
|
||
<button class="small" id="muteBtn" aria-label="Mute">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9h4l5-4v14l-5-4H4z"/><path id="volWaves" d="M16 8.5a5 5 0 0 1 0 7M18.5 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2"/></svg>
|
||
</button>
|
||
<input type="range" id="vol" min="0" max="100" step="1" value="0" aria-label="Volume">
|
||
<span class="num" id="volNum">—</span>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<section class="panel" id="comfort" style="margin:20px 0;padding:16px">
|
||
<div class="shelf-head"><h2>Family and comfort</h2><span class="spacer"></span>
|
||
<button class="action small" id="castBtn">Cast headset view</button>
|
||
</div>
|
||
<p class="hint">Share this screen with people in the room. Casting shows everything the wearer sees, including private content.</p>
|
||
<form id="comfortForm">
|
||
<div class="row" style="flex-wrap:wrap;gap:12px">
|
||
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
|
||
<label>Break every <input id="breakMinutes" type="number" min="0" max="120" value="20" required style="width:75px"> minutes</label>
|
||
<label>Check in after <input id="stillMinutes" type="number" min="0" max="240" value="30" required style="width:75px"> active minutes</label>
|
||
</div>
|
||
<div class="row" style="flex-wrap:wrap;margin:14px 0;gap:14px">
|
||
<label><input id="batteryAlert" type="checkbox" checked> Alert on low battery</label>
|
||
<label><input id="heatAlert" type="checkbox" checked> Alert on overheating</label>
|
||
<button class="action small" id="sessionStart" type="submit">Start session</button>
|
||
<button class="small" id="sessionCancel" type="button" disabled>Cancel session</button>
|
||
<button class="small" id="testNotification" type="button">Enable / test notifications</button>
|
||
</div>
|
||
</form>
|
||
<p id="comfortStatus" role="status">Checking session…</p>
|
||
<p id="comfortNotificationStatus" class="hint" role="status" hidden></p>
|
||
<p class="hint">A one-minute warning, then Steam Home. Games stay running: save and pause first. Keep this app open and connected for notifications.</p>
|
||
<details class="hint"><summary>How sessions and alerts work</summary>
|
||
<p>This is a reminder, not a parental lock. The timer continues on the Frame if you disconnect; a headset restart cancels it. Cancel before changing settings. Set break/check-in to 0 to turn them off.</p>
|
||
<p>Battery, heat and check-in alerts appear on connected companions during a session. Headset warnings and break reminders continue without a companion. iOS may suspend phone notifications in the background. Breaks and check-ins count SteamVR activity, not confirmed wear time.</p>
|
||
</details>
|
||
<div id="comfortEvents" class="hint"></div>
|
||
<span id="comfortAnnouncement" class="sr-only" aria-live="polite"></span>
|
||
</section>
|
||
|
||
<section class="panel" id="pad">
|
||
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
|
||
<button class="small" id="padOn">Turn on</button>
|
||
</div>
|
||
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
|
||
<div class="pad-hint" id="padHint"></div>
|
||
</div>
|
||
<div class="pad-keys" aria-label="Keys and clicks">
|
||
<input id="padType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||
<span class="seg pad-clicks">
|
||
<button data-pad-click="singleclick" title="Left click">Click</button>
|
||
<button data-pad-click="rightclick" title="Right click">Right-click</button>
|
||
</span>
|
||
<span class="seg pad-special">
|
||
<button data-pad-key="14" title="Escape">Esc</button>
|
||
<button data-pad-key="2" title="Tab">Tab</button>
|
||
<button data-pad-key="4" title="Left arrow" aria-label="Left arrow">←</button>
|
||
<button data-pad-key="5" title="Up arrow" aria-label="Up arrow">↑</button>
|
||
<button data-pad-key="7" title="Down arrow" aria-label="Down arrow">↓</button>
|
||
<button data-pad-key="6" title="Right arrow" aria-label="Right arrow">→</button>
|
||
<button data-pad-key="1" title="Backspace" aria-label="Backspace">⌫</button>
|
||
<button data-pad-key="12" title="Enter">Enter</button>
|
||
</span>
|
||
</div>
|
||
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
|
||
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
|
||
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
|
||
else to install. <a href="#" data-about>Licences</a></div>
|
||
</section>
|
||
|
||
<section id="shots">
|
||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||
<button class="small" id="shotsRefresh">Refresh</button>
|
||
<button class="small" id="shotsFolder" title="Open the SteamFrame folder in your Pictures">Show folder</button>
|
||
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
|
||
</div>
|
||
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
|
||
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="page" data-page="games">
|
||
<section id="library">
|
||
<div class="shelf-head"><h2>Installed</h2><span class="count" id="gameCount"></span></div>
|
||
<div class="shelf" id="games"><div class="sub">Loading…</div></div>
|
||
</section>
|
||
|
||
<section class="panel" id="sideloaded">
|
||
<div class="shelf-head"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span><span class="spacer"></span>
|
||
<button class="small" id="titleAdd" title="Pick a game's .zip or .exe to add to the Steam library">+ Add a game…</button></div>
|
||
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
|
||
<div class="hint">Your own Linux or Windows builds, added to the Steam library as Devkit Games. Drop a game's
|
||
<code>.zip</code>, folder or <code>.exe</code> anywhere in this window.</div>
|
||
</section>
|
||
|
||
<section id="getgames">
|
||
<div class="shelf-head"><h2>Get games</h2><span class="count" id="gmCount"></span><span class="spacer"></span>
|
||
<span class="sub" id="gmDl"></span></div>
|
||
<div class="panel">
|
||
<div class="cat-tools">
|
||
<div class="seg" id="gmSrc" aria-label="Where to look">
|
||
<button data-src="owned" class="on">Your games</button>
|
||
<button data-src="store">Steam store</button>
|
||
</div>
|
||
<input type="text" id="gmQ" placeholder="Filter your games…">
|
||
<button class="small" id="gmReload" title="Read the library again, e.g. after buying something">Refresh</button>
|
||
</div>
|
||
<div class="vchips" id="gmChips"></div>
|
||
<div class="cat-grid" id="gmGrid"><div class="sub">Loading your library…</div></div>
|
||
<div class="row" style="margin-top:12px"><button class="small" id="gmMore" hidden>Show more</button></div>
|
||
<div class="hint">Install downloads a game you own straight to the Frame and accepts Steam's default options;
|
||
EULAs and license prompts still need answering in the headset. To buy, open the store page in your browser
|
||
(signed in to Steam) or in the headset, then Refresh and install. Ratings are Valve's Steam Frame
|
||
compatibility categories.</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="page" data-page="android">
|
||
<section id="android">
|
||
<div class="shelf-head"><h2>Android apps</h2><span class="count" id="andCount"></span><span class="spacer"></span>
|
||
<button class="action small" id="repNew" title="Report how any APK worked on the Frame">+ Report an APK</button></div>
|
||
<div class="and-grid">
|
||
<div class="and-col">
|
||
<div class="panel">
|
||
<div class="shelf-head"><h2>On the Frame</h2></div>
|
||
<div class="list" id="andApps"><div class="sub">Loading…</div></div>
|
||
<div class="hint">Each app runs in its own Lepton instance and keeps its data. It's in the Steam
|
||
library under its own name and opens in its own panel. Test launches it and records
|
||
whether it stays up; Report records how it worked for you.</div>
|
||
</div>
|
||
<section class="panel disp" id="display">
|
||
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
|
||
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
|
||
<select id="dispSel" aria-label="Lepton instance"><option>Loading…</option></select>
|
||
<div class="hint" id="dispEmpty" hidden>Open an Android app in the headset, then Refresh, to change its
|
||
resolution, UI scale and text size here.</div>
|
||
<div id="dispCtl">
|
||
<div class="stats">
|
||
<div class="stat"><div class="k">Resolution</div><div class="v" id="dispSize">—</div><div class="d" id="dispSizeD"> </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>
|
||
<div class="panel">
|
||
<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>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="page" data-page="tools">
|
||
<section class="panel"><h2>Assistant and AI agents</h2><p>Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.</p><a href="/assistant">Open assistant</a></section>
|
||
<div class="grid-3">
|
||
<section class="panel" id="transfer">
|
||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||
<b><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></b>
|
||
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
|
||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||
<input type="file" id="fileInput" multiple hidden>
|
||
</div>
|
||
<label class="sub" for="apkDisplay">Android display</label>
|
||
<select id="apkDisplay" aria-label="Android app display mode">
|
||
<option value="auto">Automatic (detect VR)</option>
|
||
<option value="flat">Flat screen</option>
|
||
<option value="vr">VR app</option>
|
||
</select>
|
||
<div class="progress" id="prog"><i></i></div>
|
||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||
<div class="row" style="margin-top:8px">
|
||
<button class="action small" id="clipSend">Send text</button>
|
||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||
</div>
|
||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="media">
|
||
<div class="shelf-head"><h2>Media in the headset</h2><span class="count">Preview</span></div>
|
||
<p class="sub">Play your movies and stereo photos with Frame Control's own player.</p>
|
||
<div class="row"><button class="small" id="mediaChoose">Send media…</button>
|
||
<button class="small" id="mediaRefresh">Refresh</button></div>
|
||
<input type="file" id="mediaInput" accept=".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden>
|
||
<div class="row" style="margin-top:12px"><label for="mediaFile">On the Frame</label>
|
||
<select id="mediaFile"><option value="">Refresh to load your media</option></select></div>
|
||
<div class="row" style="margin-top:8px"><label for="mediaLayout">Layout</label>
|
||
<select id="mediaLayout">
|
||
<option value="auto">Detect from name or metadata</option><option value="mono">2D</option>
|
||
<option value="sbs">Half SBS · left / right</option><option value="ou">Half OU · top / bottom</option>
|
||
<option value="full-sbs">Full SBS · left / right</option><option value="full-ou">Full OU · top / bottom</option>
|
||
</select></div>
|
||
<div class="row" style="margin-top:8px"><label><input type="checkbox" id="mediaTheatre" checked>
|
||
Theatre · bigger screen, dark surround</label></div>
|
||
<div class="row" style="margin-top:12px"><button class="action small" id="mediaPlay">Play</button>
|
||
<button class="small" id="mediaStop">Stop</button></div>
|
||
<p class="sub" id="mediaState" role="status" aria-live="polite">Choose a file to begin.</p>
|
||
<div class="hint">H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
|
||
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
|
||
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
|
||
Stop works from here; playback also ends after four hours.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="apps">
|
||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||
<div class="row" style="margin-top:14px; flex-wrap:nowrap">
|
||
<input type="text" id="fpId" placeholder="Flathub ID, e.g. org.mozilla.firefox">
|
||
<button class="action" id="fpInstall">Install</button>
|
||
</div>
|
||
<div class="row" style="margin-top:8px" id="fpQuick"></div>
|
||
</section>
|
||
|
||
|
||
<section class="panel" id="power">
|
||
<div class="shelf-head"><h2>Remote & power</h2></div>
|
||
<div class="actions">
|
||
<button data-open="terminal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-5-6-5M12 19h8"/></svg>SSH</button>
|
||
<button data-open="sftp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v16M3 8l4-4 4 4M17 20V4M13 16l4 4 4-4"/></svg>SFTP</button>
|
||
<button data-open="steamlink"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5l12 7-12 7z"/></svg>Steam Link</button>
|
||
<button data-open="rdp"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg>Remote desktop</button>
|
||
<button data-open="suspend" data-confirm="Put the Frame to sleep?"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5z"/></svg>Sleep</button>
|
||
<button data-open="reboot" data-confirm="Restart the Frame?"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>Restart</button>
|
||
<button data-open="poweroff" data-confirm="Shut the Frame down?" class="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M12 3v9"/><path d="M6.3 7.3a8 8 0 1 0 11.4 0"/></svg>Shut down</button>
|
||
</div>
|
||
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
|
||
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
|
||
<div class="row" style="margin-top:14px"><button class="small" data-about>About and licences</button></div>
|
||
<div class="links desk-only">
|
||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel" id="panels">
|
||
<div class="shelf-head"><h2>Panel switcher</h2><span class="spacer"></span>
|
||
<button class="small" id="panelsRefresh">Refresh</button>
|
||
<button class="small action" id="panelsOpen">Open in headset</button></div>
|
||
<div class="list" id="panelList"><div class="sub">Refresh to see open panels.</div></div>
|
||
<div class="hint">Choose a panel to show it in SteamVR. The headset switcher stays available from Steam's dashboard.
|
||
Saved spatial layouts are not available yet.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="macview" hidden>
|
||
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||
<select id="mvQuality" title="Picture quality and bandwidth">
|
||
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
|
||
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
|
||
</select>
|
||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
|
||
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
|
||
<div class="mv-perm" id="mvPerm" hidden></div>
|
||
<div class="list" id="mvDisplays"></div>
|
||
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
|
||
<div class="list mv-windows" id="mvWindows"><div class="sub">Loading…</div></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="small" data-mv="show" data-src="test" data-title="Test pattern">Test pattern</button>
|
||
<button class="small danger" data-mv="stopall">Stop all</button>
|
||
</div>
|
||
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
|
||
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="privacy">
|
||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||
worked (with an error category, never the message). F-Droid package names only.</span></label>
|
||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
|
||
compatibility database, so the verdicts get better for everyone.</span></label>
|
||
<label class="popt"><input type="checkbox" id="tDiag"><b>Send error details</b>
|
||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||
folder, user name, addresses and keys removed.</span></label>
|
||
<div class="hint" id="tStatus"></div>
|
||
<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>
|
||
</main>
|
||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||
|
||
<div class="drawer" id="drawer"><div class="log" id="log"></div></div>
|
||
<div class="bottombar" id="bottombar" role="button" aria-label="Show activity">
|
||
<span class="label">ACTIVITY</span>
|
||
<span class="jobs" id="jobs" hidden><span class="spinner"></span><span id="jobsText"></span></span>
|
||
<span class="last" id="lastLog">Ready</span>
|
||
<span class="sub" id="drawerHint">Show ▴</span>
|
||
</div>
|
||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||
<p id="apkAltReason"></p>
|
||
<div class="list" id="apkAltVersions"></div>
|
||
<p class="sub" id="apkAltNote"></p>
|
||
<div id="apkAltLinks"></div>
|
||
<p class="sub" id="apkAltErrors"></p>
|
||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||
</dialog>
|
||
|
||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||
<h2 id="aboutTitle">About and licences</h2>
|
||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||
licence texts follow, with where to get the source.</p>
|
||
<div id="aboutText" class="about-text">Loading…</div>
|
||
<div class="actions"><button id="aboutClose">Close</button></div>
|
||
</dialog>
|
||
|
||
<dialog id="repDlg" aria-labelledby="repTitle">
|
||
<form method="dialog" id="repForm">
|
||
<h2 id="repTitle">Report an APK</h2>
|
||
<div class="rep-file"><button type="button" class="small" id="repPick">Read details from an .apk file…</button>
|
||
<input type="file" id="repFile" accept=".apk" hidden><span class="sub" id="repFileNote"></span></div>
|
||
<label>App name<input type="text" id="repLabel" placeholder="e.g. AntennaPod" maxlength="120"></label>
|
||
<label>Package<input type="text" id="repPkg" placeholder="e.g. de.danoeh.antennapod" required
|
||
pattern="[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+" maxlength="200" list="repPkgs"></label>
|
||
<datalist id="repPkgs"></datalist>
|
||
<div class="rep-row">
|
||
<label>Version<input type="text" id="repVer" placeholder="e.g. 3.12.2" maxlength="80"></label>
|
||
<label>Where it came from<input type="text" id="repSrc" placeholder="F-Droid, APKMirror, GitHub, own build…" maxlength="300"></label>
|
||
</div>
|
||
<fieldset><legend>Did it work?</legend>
|
||
<label class="opt"><input type="radio" name="repRating" value="works" required> <span class="v-works">Works</span></label>
|
||
<label class="opt"><input type="radio" name="repRating" value="issues"> <span class="v-maybe">Works, with problems</span></label>
|
||
<label class="opt"><input type="radio" name="repRating" value="broken"> <span class="v-no">Doesn't work</span></label>
|
||
</fieldset>
|
||
<fieldset><legend>How did you run it?</legend>
|
||
<label class="opt"><input type="radio" name="repRuntime" value="instance" checked> Own instance (Frame Control)</label>
|
||
<label class="opt"><input type="radio" name="repRuntime" value="lepton-dev"> Lepton Development</label>
|
||
<label class="opt"><input type="radio" name="repRuntime" value="other"> Other</label>
|
||
</fieldset>
|
||
<label>Notes<textarea id="repNotes" maxlength="1000" placeholder="What works, what doesn't, crash messages, settings you changed…"></textarea></label>
|
||
<div class="row rep-actions"><span class="sub" id="repMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="repCancel">Cancel</button>
|
||
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="bugDlg" aria-labelledby="bugTitle">
|
||
<form method="dialog" id="bugForm">
|
||
<h2 id="bugTitle">Report a problem</h2>
|
||
<label class="field">What kind of report?<select id="bugKind">
|
||
<option value="bug">Something's broken</option><option value="idea">An idea</option>
|
||
<option value="question">A question</option><option value="other">Something else</option></select></label>
|
||
<label class="field">Title<input type="text" id="bugTitleIn" maxlength="120" required minlength="5"
|
||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||
<label class="field">How can we reach you? (optional, for a reply)<input type="text" id="bugContact" maxlength="120" placeholder="Email, GitHub or Discord name"></label>
|
||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
|
||
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
|
||
<p id="bugWarn">Sent privately to the Frame Control developer. Nothing is published.</p>
|
||
<div class="row rep-actions"><span class="sub" id="bugMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="bugCancel">Cancel</button>
|
||
<button type="button" class="small" id="bugCopy">Copy report</button>
|
||
<button type="submit" class="action small" id="bugSend">Send report</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="titleDlg" aria-labelledby="titleTitle">
|
||
<form method="dialog" id="titleForm">
|
||
<h2 id="titleTitle">Add to the Steam library</h2>
|
||
<div class="sub" id="titleSrc"></div>
|
||
<label>Name<input type="text" id="titleName" maxlength="120" required></label>
|
||
<div class="note" id="titleIdNote"></div>
|
||
<label>Launches<select id="titleExe"></select></label>
|
||
<label>Runtime<select id="titleRt"></select></label>
|
||
<div class="note" id="titleNote"></div>
|
||
<div class="note warn" id="titleWarn"></div>
|
||
<div class="row rep-actions"><span class="sub" id="titleMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="titleCancel">Cancel</button>
|
||
<button type="button" class="small" id="titlePush">Copy to ~/Downloads instead</button>
|
||
<button type="submit" class="action small" id="titleInstall">Install</button></div>
|
||
</form>
|
||
</dialog>
|
||
<dialog id="wiDlg" aria-labelledby="wiTitle">
|
||
<h2 id="wiTitle">Install from a website</h2>
|
||
<dl id="wiFacts"></dl>
|
||
<p id="wiWarn">A website asked Frame Control to install this. Nothing is downloaded until you click Install.
|
||
Only install software from sites you trust.</p>
|
||
<div class="progress" id="wiProg" hidden><i></i></div>
|
||
<div class="row rep-actions"><span class="sub" id="wiMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="wiCancel">Cancel</button>
|
||
<button type="button" class="action small" id="wiGo" disabled>Install</button></div>
|
||
</dialog>
|
||
<dialog id="pwDlg" aria-labelledby="pwTitle">
|
||
<form method="dialog" id="pwForm">
|
||
<h2 id="pwTitle">Restart the Frame?</h2>
|
||
<div class="sub">SteamOS asks for the Developer Mode password for this. It's used once and not saved.</div>
|
||
<input type="password" id="pwInput" autocomplete="current-password" placeholder="Developer Mode password" style="margin-top:12px">
|
||
<div class="row rep-actions"><span class="sub" id="pwMsg"></span><span class="spacer"></span>
|
||
<button type="button" class="small" id="pwCancel">Cancel</button>
|
||
<button type="submit" class="action small" id="pwGo">Restart</button></div>
|
||
</form>
|
||
</dialog>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
// Every /api/ request carries this header. The desktop server takes "1"; the
|
||
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
|
||
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
|
||
const QUICK = [["Remmina", "org.remmina.Remmina"], ["Moonlight", "com.moonlight_stream.Moonlight"],
|
||
["Firefox", "org.mozilla.firefox"], ["VLC", "org.videolan.VLC"]];
|
||
const CDN = "https://cdn.cloudflare.steamstatic.com/steam/apps";
|
||
const HINTS = {
|
||
headset: "What the lenses show, composited by SteamVR: the room, floating panels, dashboard and controllers. Live streams it as video (one eye, about 30 fps); Capture takes a still of both eyes. Captures show everything on screen, including anything private.",
|
||
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
|
||
};
|
||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
|
||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||
|
||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||
function gb(n) { return n >= 1e12 ? (n/1e12).toFixed(2) + " TB" : n >= 1e9 ? (n/1e9).toFixed(1) + " GB" : (n/1e6).toFixed(0) + " MB"; }
|
||
function dur(s) { const d = Math.floor(s/86400), h = Math.floor(s%86400/3600), m = Math.floor(s%3600/60);
|
||
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`; }
|
||
function meter(el, pct, warnAt, badAt) {
|
||
el.style.width = Math.max(0, Math.min(100, pct)) + "%";
|
||
el.className = pct >= badAt ? "bad" : pct >= warnAt ? "warn" : "";
|
||
}
|
||
|
||
function log(msg, kind = "") {
|
||
const line = document.createElement("div");
|
||
line.className = kind;
|
||
line.innerHTML = `<time>${new Date().toLocaleTimeString()}</time> ${esc(msg)}`;
|
||
$("log").prepend(line);
|
||
while ($("log").childElementCount > 200) $("log").lastElementChild.remove();
|
||
$("lastLog").textContent = msg.split("\n")[0];
|
||
$("lastLog").className = "last " + kind;
|
||
}
|
||
let toastTimer;
|
||
function toast(msg, err = false) {
|
||
const el = $("toast");
|
||
el.textContent = msg.split("\n").slice(-1)[0] || msg;
|
||
el.className = "toast show" + (err ? " err" : "");
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el.className = "toast" + (err ? " err" : ""), 3500);
|
||
}
|
||
$("bottombar").onclick = () => {
|
||
const open = $("drawer").classList.toggle("open");
|
||
$("drawerHint").textContent = open ? "Hide ▾" : "Show ▴";
|
||
};
|
||
|
||
// Wording for the computer the app runs on (Mac or PC, Finder or File Explorer).
|
||
const HOST = { computer: "computer" };
|
||
function applyHostWording(h) {
|
||
Object.assign(HOST, h);
|
||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||
document.body.classList.toggle("mobile", !!h.mobile);
|
||
$("shotsFolder").hidden = !!h.mobile;
|
||
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
|
||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||
if (h.mobile) $("offSetup").textContent = "Change headset…";
|
||
}
|
||
// Images to the phone (the app offers Save Image to Photos, Files and so on).
|
||
function blobBase64(blob) {
|
||
return new Promise((ok, bad) => {
|
||
const r = new FileReader();
|
||
r.onload = () => ok(String(r.result).split(",")[1]);
|
||
r.onerror = () => bad(new Error("couldn't read the image"));
|
||
r.readAsDataURL(blob);
|
||
});
|
||
}
|
||
async function saveToDevice(items) {
|
||
const images = await Promise.all(items.map(async i => ({ name: i.name, data: await blobBase64(i.blob) })));
|
||
return window.frameApp.saveImages(images);
|
||
}
|
||
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
||
|
||
async function api(path, body) {
|
||
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
|
||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY}, body: JSON.stringify(body) };
|
||
let r;
|
||
try { r = await fetch(path, opts); }
|
||
catch {
|
||
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
|
||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||
}
|
||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||
if (!r.ok) {
|
||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||
err.offline = !!data.offline;
|
||
if (err.offline) setOnline(false, err.message);
|
||
throw err;
|
||
}
|
||
return data;
|
||
}
|
||
// What a panel shows when its data couldn't be read: a quiet placeholder while
|
||
// the Frame is unreachable (the banner explains), the real error otherwise.
|
||
function failed(el, e) {
|
||
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) {
|
||
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
|
||
}
|
||
|
||
// Whether the Frame answers. Any request that couldn't reach it flips this off;
|
||
// the next successful status check flips it back and reloads what failed.
|
||
let online = null;
|
||
function setOnline(ok, why) {
|
||
const was = online;
|
||
online = ok;
|
||
$("offline").hidden = ok;
|
||
if (!ok) {
|
||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||
setConn(false, "Offline");
|
||
$("battChip").hidden = true;
|
||
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
||
} else if (was === false) {
|
||
log("Connected to the Frame again", "ok");
|
||
reloadAll();
|
||
}
|
||
}
|
||
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); }
|
||
$("offRetry").onclick = () => refresh();
|
||
if (window.frameApp && window.frameApp.setUpConnection) {
|
||
$("offSetup").hidden = false;
|
||
$("offSetup").onclick = () => window.frameApp.setUpConnection();
|
||
}
|
||
$("conn").onclick = () => { if (online === false) refresh(); };
|
||
|
||
let refreshing = null, pollTimer = null;
|
||
function schedule() { // every 30 s while connected, every 8 s while not
|
||
clearTimeout(pollTimer);
|
||
pollTimer = setTimeout(() => { if (document.hidden) schedule(); else refresh(); }, online === false ? 8000 : 30000);
|
||
}
|
||
function refresh() {
|
||
if (refreshing) return refreshing;
|
||
$("refreshAll").disabled = true; $("offRetry").disabled = true;
|
||
refreshing = (async () => {
|
||
try {
|
||
state = await api("/api/status");
|
||
render(state);
|
||
setConn(true, state.hostname);
|
||
setOnline(true);
|
||
} catch (e) {
|
||
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;
|
||
}
|
||
|
||
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||
async function localNotification(message, request = false) {
|
||
try {
|
||
if (window.frameApp?.notify) await window.frameApp.notify(message, request);
|
||
else {
|
||
if (!("Notification" in window)) throw new Error("This browser has no notifications; use the Frame Control app.");
|
||
const permission = request ? await Notification.requestPermission() : Notification.permission;
|
||
if (permission !== "granted") throw new Error("Notifications are off. Enable them in system settings.");
|
||
new Notification("Frame Control", {body: message});
|
||
}
|
||
$("comfortNotificationStatus").hidden = true;
|
||
} catch (e) {
|
||
$("comfortNotificationStatus").hidden = false;
|
||
$("comfortNotificationStatus").textContent = "Notifications aren't available on this device. Check system notification settings, then use Enable / test notifications. Headset reminders continue during an active session.";
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
function renderComfort(s) {
|
||
const firstSnapshot = !comfortHydrated;
|
||
comfortHydrated = true;
|
||
$("sessionStart").disabled = !!s.active;
|
||
for (const id of ["sessionMinutes", "breakMinutes", "stillMinutes", "batteryAlert", "heatAlert"])
|
||
$(id).disabled = !!s.active;
|
||
$("sessionCancel").disabled = !s.active;
|
||
if (s.id !== comfortSession) {
|
||
comfortSession = s.id;
|
||
comfortSeen.clear();
|
||
if (s.options) for (const [key, value] of Object.entries(s.options)) {
|
||
const el = $(key === "minutes" ? "sessionMinutes" : key);
|
||
if (!el) continue;
|
||
if (el.type === "checkbox") el.checked = value; else el.value = value;
|
||
}
|
||
}
|
||
let statusText = s.error || (s.active
|
||
? `${Math.ceil(s.remaining / 60)} min until Steam Home · ${s.activity == null ? "activity unknown" : s.activity === 3 ? "headset in standby" : "monitoring"}`
|
||
: "No session running.");
|
||
if (s.active && s.unavailable?.length)
|
||
statusText += " · No readings: " + s.unavailable.join(", ");
|
||
if ($("comfortStatus").textContent !== statusText) $("comfortStatus").textContent = statusText;
|
||
let latest = null;
|
||
for (const e of s.events || []) {
|
||
if (comfortSeen.has(e.id)) continue;
|
||
comfortSeen.add(e.id);
|
||
// Historical events remain visible but never produce a burst on reconnect.
|
||
if (!firstSnapshot && s.time - e.time >= 0 && s.time - e.time < 30 && !["started", "cancelled"].includes(e.kind)) {
|
||
latest = e.message;
|
||
log(e.message); toast(e.message, e.kind === "error");
|
||
localNotification(e.message).catch(() => {}); // the notification status keeps the failure visible
|
||
}
|
||
}
|
||
// Keep only the server's bounded history; a new page seeds it without replay.
|
||
comfortSeen = new Set((s.events || []).map(e => e.id));
|
||
if (latest !== null) $("comfortAnnouncement").textContent = latest;
|
||
const history = (s.events || []).slice(-3).map(e => e.message).join(" · ");
|
||
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
|
||
}
|
||
async function pollComfort() {
|
||
if (!comfortBusy) {
|
||
comfortBusy = true;
|
||
try { renderComfort(await api("/api/comfort", {action: "status"})); }
|
||
catch (e) {
|
||
const message = "Session status unavailable: " + e.message;
|
||
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
|
||
}
|
||
finally { comfortBusy = false; }
|
||
}
|
||
setTimeout(pollComfort, 5000);
|
||
}
|
||
$("comfortForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const result = await act("Start session", () => api("/api/comfort", {
|
||
action: "start", minutes: Number($("sessionMinutes").value), breakMinutes: Number($("breakMinutes").value),
|
||
stillMinutes: Number($("stillMinutes").value), batteryAlert: $("batteryAlert").checked, heatAlert: $("heatAlert").checked,
|
||
}), $("sessionStart"));
|
||
if (result) renderComfort(result);
|
||
};
|
||
$("sessionCancel").onclick = async () => {
|
||
const result = await act("Cancel session", () => api("/api/comfort", {action: "cancel"}), $("sessionCancel"));
|
||
if (result) renderComfort(result);
|
||
};
|
||
$("testNotification").onclick = () => act("Test notification", async () => {
|
||
await localNotification("Comfort notifications are enabled on this device.", true);
|
||
return {message: "Test notification sent. Check your device's notification settings if it didn't appear."};
|
||
});
|
||
$("castBtn").onclick = () => {
|
||
setView("headset");
|
||
if (!live) toggleLive(true);
|
||
$("viewer").scrollIntoView({behavior: "smooth", block: "center"});
|
||
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
|
||
$("viewer").requestFullscreen?.().catch(() => {});
|
||
};
|
||
pollComfort();
|
||
|
||
// ---- battery ----
|
||
function battery(b, power) {
|
||
const pct = b?.percent;
|
||
const full = b?.status === "Full" || (!!power && pct >= 100);
|
||
const charging = !full && b?.status === "Charging";
|
||
// A weak charger can lose to the load: plugged in but still draining.
|
||
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
|
||
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
|
||
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
|
||
const low = !charging && !full && pct != null && pct <= 15;
|
||
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
|
||
$("battFill").setAttribute("width", pct == null ? 0 : (55 * Math.min(100, Math.max(0, pct)) / 100).toFixed(1));
|
||
$("battFill").setAttribute("fill", color);
|
||
$("battBolt").setAttribute("visibility", charging ? "visible" : "hidden");
|
||
$("battPct").textContent = pct == null ? "—" : `${pct}%`;
|
||
|
||
let label, cls, detail = [];
|
||
if (!b) { label = "Battery unknown"; cls = "draining"; }
|
||
else if (full) { label = "Fully charged"; cls = "full"; }
|
||
else if (charging) {
|
||
label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging";
|
||
if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`);
|
||
} else if (!draining) { label = "Plugged in, not charging"; cls = "draining"; }
|
||
else {
|
||
label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery";
|
||
cls = low ? "low" : "draining";
|
||
if (b?.watts < 0) detail.push(`Using ${Math.abs(b.watts).toFixed(1)} W`);
|
||
if (b?.timeToEmpty > 0 && b.timeToEmpty < 86400) detail.push(`About ${dur(b.timeToEmpty)} left`);
|
||
}
|
||
if (power) detail.push(`${/PD/.test(power.type || "") ? "USB-C PD" : power.type || "Charger"}${power.watts ? ` ${Math.round(power.watts)} W` : ""}`);
|
||
if (b?.tempC != null) detail.push(`${b.tempC.toFixed(0)}°C`);
|
||
$("battState").textContent = label;
|
||
$("battState").className = "state " + cls;
|
||
$("battDetail").textContent = detail.join(" · ") || " ";
|
||
|
||
const bolt = charging ? `<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden="true"><path d="M6 0L0 8h4l-1 6 7-8H6z" fill="#a4d007"/></svg>` : "";
|
||
const chipPct = pct == null ? 0 : Math.min(100, Math.max(0, pct));
|
||
$("battChip").innerHTML = pct == null ? "—" :
|
||
`<svg width="22" height="12" viewBox="0 0 22 12" aria-hidden="true"><rect x=".75" y=".75" width="18" height="10.5" rx="2" fill="none" stroke="#8f98a0" stroke-width="1.5"/><rect x="19.8" y="3.5" width="1.8" height="5" rx=".6" fill="#8f98a0"/><rect x="2.5" y="2.5" width="${(14.5 * chipPct / 100).toFixed(1)}" height="7" rx="1" fill="${color}"/></svg>${bolt}<span>${pct}%</span>`;
|
||
$("battChip").title = label;
|
||
$("battChip").hidden = pct == null;
|
||
}
|
||
|
||
function render(s) {
|
||
battery(s.battery, s.power);
|
||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||
const h = s.disk.home;
|
||
if (h) { $("sDisk").textContent = gb(h.free); meter($("mDisk"), 100 * (1 - h.free / h.total), 80, 92); }
|
||
const m = s.memory;
|
||
if (m) { const used = m.total - m.available; $("sMem").textContent = `${gb(used)} / ${gb(m.total)}`; meter($("mMem"), 100 * used / m.total, 75, 90); }
|
||
$("sTemp").textContent = s.temp == null ? "—" : `${s.temp.toFixed(0)}°C`;
|
||
$("dTemp").textContent = s.temp == null ? "" : s.temp > 75 ? "Running hot" : s.temp > 60 ? "Warm" : "Normal";
|
||
$("sWifi").textContent = s.wifi ? `${s.wifi.signal ?? "?"}%` : "—";
|
||
$("dWifi").textContent = s.wifi?.ssid ?? "Not on Wi-Fi";
|
||
$("sUp").textContent = s.uptime ? dur(s.uptime) : "—";
|
||
$("dUp").textContent = "since boot";
|
||
$("sOs").textContent = s.os.version ?? "—";
|
||
$("dOs").textContent = s.os.build ? `build ${s.os.build}` : "";
|
||
|
||
const svc = [["SteamVR", s.services.steamvr], ["Desktop", s.services.desktop],
|
||
["Lepton", s.services.lepton], ["RDP", s.services.rdp]];
|
||
$("services").innerHTML = svc.map(([n, on]) => `<span class="chip"><span class="dot ${on ? "on" : ""}"></span>${esc(n)}</span>`).join("");
|
||
|
||
if (s.volume && !volTimer) setVolume(Math.round(s.volume.level * 100), s.volume.muted);
|
||
|
||
const games = s.games.filter(g => /^\d+$/.test(g.appid)); // appid goes into image URLs
|
||
$("gameCount").textContent = `(${games.length})`;
|
||
$("games").innerHTML = games.length ? games.map(g => `
|
||
<div class="capsule" data-appid="${g.appid}">
|
||
<div class="fallback">${esc(g.name)}</div>
|
||
<div class="over">
|
||
<div class="name">${esc(g.name)}</div>
|
||
<div class="size">${gb(g.size)}</div>
|
||
<button class="play small" data-launch="${g.appid}" data-name="${esc(g.name)}">▶ PLAY</button>
|
||
</div>
|
||
</div>`).join("") : `<div class="sub">No games installed.</div>`;
|
||
// Tall library art, falling back to the wide header, then to the name card.
|
||
$("games").querySelectorAll(".capsule").forEach(c => {
|
||
const id = c.dataset.appid;
|
||
const tryArt = (urls) => {
|
||
if (!urls.length) return;
|
||
const img = new Image();
|
||
img.onload = () => { c.style.backgroundImage = `url("${urls[0]}")`; c.querySelector(".fallback").hidden = true; };
|
||
img.onerror = () => tryArt(urls.slice(1));
|
||
img.src = urls[0];
|
||
};
|
||
tryArt([`${CDN}/${id}/library_600x900.jpg`, `${CDN}/${id}/header.jpg`]);
|
||
});
|
||
|
||
$("flatpaks").innerHTML = s.flatpaks.length ? s.flatpaks.map(f => `
|
||
<div class="item">
|
||
<div class="grow"><div class="t">${esc(f.name)}</div><div class="s">${esc(f.id)} · ${esc(f.version || "")}</div></div>
|
||
<span class="tag">${esc(f.installation)}</span>
|
||
${f.installation === "user" ? `<button class="small danger" data-uninstall="${esc(f.id)}" data-name="${esc(f.name)}">Remove</button>` : ""}
|
||
</div>`).join("") : `<div class="sub">No Flatpak apps.</div>`;
|
||
const have = new Set(s.flatpaks.map(f => f.id));
|
||
$("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id))
|
||
.map(([n, id]) => installing.has(id) ? `<button class="small" disabled>Installing ${esc(n)}…</button>`
|
||
: `<button class="small" data-quick="${esc(id)}">+ ${esc(n)}</button>`).join("");
|
||
}
|
||
|
||
// ---- view ----
|
||
function setView(v) {
|
||
const changed = v !== view;
|
||
view = v;
|
||
document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("on", b.dataset.view === v));
|
||
$("eyeSeg").style.visibility = v === "headset" && !video.ctl ? "visible" : "hidden";
|
||
$("eyeSeg").style.display = v === "flat" ? "none" : ""; // the Desktop view has no eyes to pick
|
||
$("viewHint").textContent = HINTS[v];
|
||
$("panelPick").hidden = v !== "flat";
|
||
if (v === "flat") watchPanels(); else clearTimeout(desk.timer);
|
||
if (live && changed) { toggleLive(false); toggleLive(true); }
|
||
ctrlShow();
|
||
}
|
||
function setEye(e) {
|
||
eye = e;
|
||
document.querySelectorAll("[data-eye]").forEach(b => b.classList.toggle("on", b.dataset.eye === e));
|
||
if (lastImg) draw();
|
||
}
|
||
// ---- zoom and pan (1 = fit the whole frame in the viewer) ----
|
||
let zoom = 1, panX = 0, panY = 0;
|
||
function setZoom(z, cx, cy) {
|
||
const v = $("viewer").getBoundingClientRect();
|
||
z = Math.min(8, Math.max(1, z));
|
||
// Keep the point under the cursor (or the centre) fixed while zooming.
|
||
cx ??= v.width / 2; cy ??= v.height / 2;
|
||
panX = cx - (cx - panX) * z / zoom; panY = cy - (cy - panY) * z / zoom;
|
||
zoom = z;
|
||
if (zoom === 1) panX = panY = 0;
|
||
// Don't let the image slide out of view.
|
||
panX = Math.min(0, Math.max(v.width - v.width * zoom, panX));
|
||
panY = Math.min(0, Math.max(v.height - v.height * zoom, panY));
|
||
$("canvas").style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
|
||
$("zoomFit").textContent = zoom === 1 ? "Fit" : `${Math.round(zoom * 100)}%`;
|
||
$("viewer").classList.toggle("zoomed", zoom > 1);
|
||
}
|
||
$("zoomIn").onclick = () => setZoom(zoom * 1.5);
|
||
$("zoomOut").onclick = () => setZoom(zoom / 1.5);
|
||
$("zoomFit").onclick = () => setZoom(1);
|
||
// Full screen, or on phones (no element full screen there) the viewer filling the window.
|
||
$("fullBtn").onclick = () => {
|
||
if (document.fullscreenElement) return document.exitFullscreen();
|
||
if ($("viewer").requestFullscreen && !TOUCH) return $("viewer").requestFullscreen().catch(() => $("viewer").classList.toggle("immersive"));
|
||
$("viewer").classList.toggle("immersive");
|
||
setZoom(1);
|
||
};
|
||
document.addEventListener("fullscreenchange", () => setZoom(1));
|
||
$("viewer").addEventListener("wheel", e => {
|
||
if ($("canvas").hidden || ctrl.on) return;
|
||
e.preventDefault();
|
||
const v = $("viewer").getBoundingClientRect();
|
||
setZoom(zoom * Math.exp(-e.deltaY * 0.002), e.clientX - v.left, e.clientY - v.top);
|
||
}, { passive: false });
|
||
$("canvas").addEventListener("dblclick", e => {
|
||
if (ctrl.on) return;
|
||
const v = $("viewer").getBoundingClientRect();
|
||
setZoom(zoom > 1 ? 1 : 2.5, e.clientX - v.left, e.clientY - v.top);
|
||
});
|
||
$("canvas").addEventListener("pointerdown", e => {
|
||
if (zoom === 1 || ctrl.on) return;
|
||
const start = { x: e.clientX - panX, y: e.clientY - panY };
|
||
$("viewer").classList.add("panning");
|
||
$("canvas").setPointerCapture(e.pointerId);
|
||
const move = ev => { panX = ev.clientX - start.x; panY = ev.clientY - start.y; setZoom(zoom); };
|
||
const up = () => { $("viewer").classList.remove("panning"); $("canvas").removeEventListener("pointermove", move); };
|
||
$("canvas").addEventListener("pointermove", move);
|
||
$("canvas").addEventListener("pointerup", up, { once: true });
|
||
});
|
||
document.addEventListener("keydown", e => {
|
||
if (page !== "home" || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
|
||
if (ctrl.on && document.activeElement === $("viewer")) return; // keys are going to the Frame
|
||
if (e.key === "c") return $("ctrlBtn").click();
|
||
if ($("canvas").hidden) return;
|
||
if (e.key === "+" || e.key === "=") setZoom(zoom * 1.5);
|
||
else if (e.key === "-") setZoom(zoom / 1.5);
|
||
else if (e.key === "0") setZoom(1);
|
||
else if (e.key === "f") $("fullBtn").click();
|
||
});
|
||
|
||
function draw() {
|
||
const img = lastImg, c = $("canvas");
|
||
// Side-by-side stereo captures: crop to the left half for "one eye".
|
||
const sbs = lastSource === "steamvr" && img.naturalWidth >= img.naturalHeight * 1.5;
|
||
const crop = sbs && eye === "left";
|
||
const sw = crop ? img.naturalWidth / 2 : img.naturalWidth, sh = img.naturalHeight;
|
||
if (c.width !== sw || c.height !== sh) {
|
||
c.width = sw; c.height = sh;
|
||
$("viewer").style.aspectRatio = `${sw} / ${sh}`; // one eye is taller than wide
|
||
setZoom(1);
|
||
}
|
||
const ctx = c.getContext("2d");
|
||
ctx.drawImage(img, 0, 0, sw, sh, 0, 0, sw, sh);
|
||
$("asleep").hidden = lastSource !== "steamvr" || !isBlank(ctx, sw, sh);
|
||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false;
|
||
$("srcBadge").hidden = false;
|
||
$("srcBadge").textContent = SOURCE_LABEL[lastSource] + (crop ? " · left eye" : "");
|
||
}
|
||
// A sleeping headset renders a flat near-black frame; sample a grid to spot it.
|
||
function isBlank(ctx, w, h) {
|
||
const px = ctx.getImageData(0, 0, w, h).data;
|
||
let min = 255, max = 0;
|
||
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
|
||
const i = (Math.floor((y + .5) * h / 16) * w + Math.floor((x + .5) * w / 16)) * 4;
|
||
const l = (px[i] + px[i + 1] + px[i + 2]) / 3;
|
||
min = Math.min(min, l); max = Math.max(max, l);
|
||
}
|
||
return max - min < 6;
|
||
}
|
||
async function capture() {
|
||
if (video.ctl) toggleLive(false); // Capture during live video takes a still of both eyes instead
|
||
const gen = viewGen; // a screenshot opened meanwhile wins over this capture
|
||
if (!live) $("viewer").classList.add("busy"); // no spinner flashing over a live stream
|
||
let url = null;
|
||
try {
|
||
let url_ = "/api/screenshot?view=headset";
|
||
var shotPanel = null;
|
||
if (view === "flat") {
|
||
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
|
||
if (!shotPanel) throw new Error("no app panel is open in the headset");
|
||
url_ = `/api/screenshot?view=panel&window=${shotPanel.window}&display=${encodeURIComponent(shotPanel.display)}`;
|
||
}
|
||
const r = await fetch(url_, { headers: {"X-Frame-UI": UI_KEY} });
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||
const source = r.headers.get("X-Capture-Source") || "gamescope";
|
||
url = URL.createObjectURL(await r.blob());
|
||
const img = new Image();
|
||
await new Promise((ok, bad) => { img.onload = ok; img.onerror = () => bad(new Error("not an image")); img.src = url; });
|
||
if (gen !== viewGen) return true;
|
||
lastImg = img; lastSource = source; lastShot = null;
|
||
draw();
|
||
desk.shown = source === "panel" ? panelKey(shotPanel) : null;
|
||
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
|
||
ctrlShow();
|
||
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
|
||
$("saveBtn").disabled = 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 = 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");
|
||
function download(blob, name) {
|
||
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(b.dataset.view));
|
||
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
|
||
|
||
// ---- Desktop view: the app panel in use in the headset (or one picked to watch) ----
|
||
// gamescope says which panel has focus: the one the wearer last used. That's the one
|
||
// Control reaches, so the view follows it unless you pick another to watch.
|
||
// Panels are "display/window": window ids are per X display, and the Frame has two.
|
||
// desk.shown is the panel whose picture is on screen now; Control only acts on that one.
|
||
const desk = { panels: [], focus: null, pick: "", timer: null, loaded: false, shown: null };
|
||
const panelKey = p => p ? `${p.display}/${p.window}` : null;
|
||
function deskPanel() {
|
||
const want = desk.pick || desk.focus;
|
||
// Nothing in use and nothing picked: show the first panel rather than nothing.
|
||
return desk.panels.find(p => panelKey(p) === want) || (!desk.pick && !desk.focus && desk.panels[0]) || null;
|
||
}
|
||
async function loadPanels() {
|
||
try {
|
||
const before = panelKey(deskPanel());
|
||
const r = await api("/api/panels");
|
||
desk.panels = r.panels || []; desk.loaded = true;
|
||
desk.focus = r.focus ? `${r.focus_display}/${r.focus}` : null;
|
||
if (desk.pick && !desk.panels.some(p => panelKey(p) === desk.pick)) desk.pick = "";
|
||
renderPanelPick();
|
||
const now = panelKey(deskPanel());
|
||
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
|
||
ctrlShow();
|
||
} catch (e) {
|
||
log("Couldn't list the headset's panels: " + e.message, "e");
|
||
}
|
||
}
|
||
function watchPanels() {
|
||
clearTimeout(desk.timer);
|
||
if (view !== "flat") return;
|
||
loadPanels().finally(() => { if (view === "flat") desk.timer = setTimeout(watchPanels, document.hidden ? 10000 : 3000); });
|
||
}
|
||
function renderPanelPick() {
|
||
const sel = $("panelPick");
|
||
sel.innerHTML = `<option value="">${desk.focus ? "Panel in use" : "Panel in use (none yet)"}</option>` +
|
||
desk.panels.map(p => `<option value="${esc(panelKey(p))}">${esc(p.name || "Untitled")}${p.focused ? " (in use)" : ""}</option>`).join("");
|
||
sel.value = desk.pick;
|
||
}
|
||
$("panelPick").onchange = () => {
|
||
desk.pick = $("panelPick").value;
|
||
ctrlShow();
|
||
if (live) { toggleLive(false); toggleLive(true); } else if (lastImg || lastSource) capture();
|
||
};
|
||
|
||
// ---- Control: tap or click on the view to use the Frame ----
|
||
// Through gamescope's own input (frame_touch.py). On the Desktop view a tap lands
|
||
// exactly where you tapped; on the headset view, which moves with the wearer's head,
|
||
// the view is a trackpad for the pointer instead.
|
||
const ctrl = { on: false, queue: [], sending: false, state: "off", message: "", move: null, rel: [0, 0], raf: 0,
|
||
held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
|
||
const EVDEV = { Escape: 1, Minus: 12, Equal: 13, Backspace: 14, Tab: 15, BracketLeft: 26, BracketRight: 27, Enter: 28,
|
||
ControlLeft: 29, Semicolon: 39, Quote: 40, Backquote: 41, ShiftLeft: 42, Backslash: 43, Comma: 51, Period: 52,
|
||
Slash: 53, ShiftRight: 54, NumpadMultiply: 55, AltLeft: 56, Space: 57, CapsLock: 58, F11: 87, F12: 88,
|
||
IntlBackslash: 86, NumpadEnter: 96, ControlRight: 97, NumpadDivide: 98, AltRight: 100, Home: 102, ArrowUp: 103,
|
||
PageUp: 104, ArrowLeft: 105, ArrowRight: 106, End: 107, ArrowDown: 108, PageDown: 109, Insert: 110, Delete: 111,
|
||
MetaLeft: 125, MetaRight: 126, ContextMenu: 127, NumpadSubtract: 74, NumpadAdd: 78, NumpadDecimal: 83 };
|
||
[..."qwertyuiop"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 16 + i);
|
||
[..."asdfghjkl"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 30 + i);
|
||
[..."zxcvbnm"].forEach((c, i) => EVDEV["Key" + c.toUpperCase()] = 44 + i);
|
||
[..."1234567890"].forEach((c, i) => EVDEV["Digit" + c] = 2 + i);
|
||
for (let i = 1; i <= 10; i++) EVDEV["F" + i] = 58 + i;
|
||
[[7, 71], [8, 72], [9, 73], [4, 75], [5, 76], [6, 77], [1, 79], [2, 80], [3, 81], [0, 82]].forEach(([n, k]) => EVDEV["Numpad" + n] = k);
|
||
const CTRL_BUTTONS = ["left", "middle", "right"]; // MouseEvent.button 0, 1, 2
|
||
|
||
function ctrlMode() { return view === "flat" ? "abs" : "rel"; }
|
||
// Where taps go on the Desktop view, or why they can't.
|
||
function ctrlTarget() {
|
||
if (ctrlMode() === "rel") return { ok: true };
|
||
const p = deskPanel();
|
||
if (!desk.loaded) return { why: "Finding the headset's panels…" };
|
||
if (!p) return { why: "No app panel is open in the headset." };
|
||
if (!desk.focus) return { why: "Nobody's using a panel in the headset yet. Taps work once one is in use." };
|
||
if (panelKey(p) !== desk.focus) return { why: "Only the panel in use takes taps. Pick “Panel in use” to control it." };
|
||
if (desk.shown !== desk.focus) return { why: live ? "Waiting for the picture of the panel in use…"
|
||
: "The panel in use has changed. Press Capture or Live to see it first." };
|
||
return { ok: true, panel: p };
|
||
}
|
||
function ctrlShow() {
|
||
const v = $("viewer"), b = $("ctrlBadge");
|
||
$("ctrlBtn").classList.toggle("on", ctrl.on);
|
||
v.classList.toggle("control", ctrl.on);
|
||
v.classList.toggle("rel", ctrl.on && ctrlMode() === "rel");
|
||
$("ctrlKeys").hidden = !ctrl.on;
|
||
b.hidden = !ctrl.on;
|
||
if (!ctrl.on) return;
|
||
const t = ctrlTarget();
|
||
let text = ctrlMode() === "abs" ? `Tap or click to use ${t.panel ? t.panel.name || "the panel" : "the panel"}`
|
||
: "Drag to move the pointer, tap to click, two fingers to scroll";
|
||
let warn = false;
|
||
if (!t.ok) { text = t.why; warn = true; }
|
||
else if (ctrl.state === "error") { text = ctrl.message || "Control isn't working"; warn = true; }
|
||
else if (ctrl.state === "starting") text = "Connecting…";
|
||
b.textContent = text;
|
||
b.classList.toggle("warn", warn);
|
||
}
|
||
function ctrlToggle(on) {
|
||
if (!on) ctrlRelease();
|
||
ctrl.on = on;
|
||
if (on) {
|
||
setZoom(1);
|
||
ctrlPoll(0);
|
||
if (view === "flat" && !desk.loaded) watchPanels();
|
||
if (!TOUCH) $("viewer").focus({ preventScroll: true });
|
||
}
|
||
if (window.frameApp && window.frameApp.captureKeys) window.frameApp.captureKeys(on && document.activeElement === $("viewer"));
|
||
ctrlShow();
|
||
}
|
||
$("ctrlBtn").onclick = () => ctrlToggle(!ctrl.on);
|
||
// Until it's ready, check on the Frame side every half second (for up to 15 s).
|
||
async function ctrlPoll(n) {
|
||
if (!ctrl.on) return;
|
||
try {
|
||
const r = await api(n ? "/api/touch" : "/api/touch?start=1");
|
||
ctrl.state = r.state; ctrl.message = r.message || "";
|
||
} catch (e) { ctrl.state = "error"; ctrl.message = e.message; }
|
||
if (!ctrl.on) return; // turned off while connecting
|
||
ctrlShow();
|
||
if (ctrl.state !== "ready" && ctrl.state !== "error" && n < 30) return setTimeout(() => ctrlPoll(n + 1), 500);
|
||
if (ctrl.state === "ready") return ctrlFlush();
|
||
if (ctrl.state !== "error") {
|
||
ctrl.state = "error"; ctrl.message = "The Frame didn't answer. Turn Control off and on to try again.";
|
||
ctrlShow();
|
||
}
|
||
}
|
||
$("viewer").tabIndex = 0;
|
||
$("viewer").addEventListener("focus", () => { if (ctrl.on && window.frameApp?.captureKeys) window.frameApp.captureKeys(true); });
|
||
$("viewer").addEventListener("blur", () => { ctrlRelease(); if (window.frameApp?.captureKeys) window.frameApp.captureKeys(false); });
|
||
document.addEventListener("visibilitychange", () => { if (document.hidden) ctrlRelease(); });
|
||
window.addEventListener("blur", () => ctrlRelease()); // another app took the keyboard
|
||
|
||
const isMoveEvent = e => ("fx" in e || "dx" in e) && !("button" in e) && !("key" in e);
|
||
const isRelease = e => ("button" in e || "key" in e) && e.down === false;
|
||
// While it connects, keep clicks and keys, each with the last position before it, so a
|
||
// tap still lands where it was made; lone moves are stale by then.
|
||
function ctrlKeepable(events) {
|
||
const out = [];
|
||
let move = null;
|
||
for (const e of events) {
|
||
if (isMoveEvent(e)) { if ("fx" in e) move = e; continue; }
|
||
if (move && "button" in e && e.down !== false) out.push(move);
|
||
move = null;
|
||
out.push(e);
|
||
}
|
||
if (move) out.push(move); // the last position: a click queued after it needs it
|
||
return out;
|
||
}
|
||
function ctrlSend(events) {
|
||
ctrl.queue.push(...events.map(e => isMoveEvent(e) ? e : ctrlAim(e)).filter(Boolean));
|
||
// Keep order; if the Frame is slow to answer, drop pointer moves first, then the oldest
|
||
// presses and scrolls, never a release.
|
||
if (ctrl.queue.length > 300) {
|
||
const q = ctrlKeepable(ctrl.queue), keep = new Set();
|
||
q.forEach((e, i) => { if (isRelease(e) || i >= q.length - 200) keep.add(i); });
|
||
// A kept press keeps the position before it, so it lands where it was made.
|
||
for (const i of [...keep]) if (i > 0 && "button" in q[i] && q[i].down !== false && "fx" in q[i - 1]) keep.add(i - 1);
|
||
ctrl.queue = q.filter((e, i) => keep.has(i));
|
||
}
|
||
ctrlFlush();
|
||
}
|
||
async function ctrlFlush() {
|
||
if (ctrl.sending || !ctrl.queue.length) return;
|
||
// Backing off after a failure: new input waits for the same retry.
|
||
const wait = (ctrl.retryAt || 0) - Date.now();
|
||
if (wait > 0) {
|
||
clearTimeout(ctrl.retry);
|
||
ctrl.retry = setTimeout(ctrlFlush, wait);
|
||
return;
|
||
}
|
||
ctrl.sending = true;
|
||
// Up to 200 events, and at most about 100 characters of text: the Frame types a batch
|
||
// key by key before reading the next, and a release mustn't wait behind a long paste.
|
||
let n = 0, chars = 0;
|
||
while (n < ctrl.queue.length && n < 200 && (n === 0 || chars + (ctrl.queue[n].text?.length || 0) <= 100)) {
|
||
chars += ctrl.queue[n].text?.length || 0;
|
||
n++;
|
||
}
|
||
const batch = ctrl.queue.splice(0, n);
|
||
let again = 0;
|
||
try {
|
||
const r = await api("/api/touch", { events: batch });
|
||
ctrl.state = r.state; ctrl.message = r.message || "";
|
||
if (r.stale && typeof loadPanels === "function") loadPanels(); // focus moved on: catch up now
|
||
if (!r.sent && r.state === "error") {
|
||
// Broken: presses would be stale by the time it's back; releases still matter.
|
||
ctrl.queue.unshift(...batch.filter(isRelease));
|
||
again = 2000;
|
||
} else if (!r.sent) {
|
||
ctrl.queue.unshift(...ctrlKeepable(batch)); // connecting: send them once it's ready
|
||
again = 300;
|
||
}
|
||
} catch (e) {
|
||
ctrl.state = "error"; ctrl.message = e.message;
|
||
ctrl.queue.unshift(...batch.filter(isRelease));
|
||
again = 1000; // try the releases again in a moment, not in a tight loop
|
||
} finally {
|
||
ctrl.sending = false;
|
||
ctrlShow();
|
||
clearTimeout(ctrl.retry);
|
||
ctrl.retryAt = again ? Date.now() + again : 0;
|
||
if (again) { if (ctrl.queue.length) ctrl.retry = setTimeout(ctrlFlush, again); }
|
||
else if (ctrl.queue.length) ctrlFlush();
|
||
}
|
||
}
|
||
// Pointer moves: at most one per animation frame.
|
||
function ctrlMoveTo(fx, fy) {
|
||
const p = ctrlTarget().panel;
|
||
if (!p) return;
|
||
ctrl.move = { fx, fy, window: p.window, display: p.display };
|
||
ctrlSchedule();
|
||
}
|
||
function ctrlMoveBy(dx, dy) { ctrl.rel[0] += dx; ctrl.rel[1] += dy; ctrlSchedule(); }
|
||
function ctrlSchedule() { if (!ctrl.raf) ctrl.raf = requestAnimationFrame(ctrlFlushMoves); }
|
||
function ctrlFlushMoves() {
|
||
ctrl.raf = 0;
|
||
const out = [];
|
||
if (ctrl.move) { out.push(ctrl.move); ctrl.move = null; }
|
||
if (ctrl.rel[0] || ctrl.rel[1]) { out.push({ dx: Math.round(ctrl.rel[0]), dy: Math.round(ctrl.rel[1]) }); ctrl.rel = [0, 0]; }
|
||
if (out.length) ctrlSend(out);
|
||
}
|
||
// On the Desktop view, presses name the panel they're for, so the Frame drops them if
|
||
// focus has moved on (releases always go).
|
||
// A press with no panel to aim at (the Desktop view lost its target) isn't sent at all.
|
||
function ctrlAim(e) {
|
||
if (ctrlMode() !== "abs" || e.down === false) return e;
|
||
const p = ctrlTarget().panel;
|
||
return p ? { ...e, window: p.window, display: p.display } : null;
|
||
}
|
||
function ctrlButton(name, down) {
|
||
ctrlFlushMoves(); // the click lands where the pointer was sent
|
||
if (down) ctrl.held.add(name); else ctrl.held.delete(name);
|
||
ctrlSend([ctrlAim({ button: name, down })]);
|
||
}
|
||
function ctrlKeyEvent(code, down) {
|
||
ctrlFlushMoves();
|
||
if (down) ctrl.keys.add(code); else ctrl.keys.delete(code);
|
||
ctrlSend([ctrlAim({ key: code, down })]);
|
||
}
|
||
function ctrlClick(name = "left") { ctrlButton(name, true); ctrlButton(name, false); }
|
||
function ctrlRelease() {
|
||
for (const b of [...ctrl.held]) ctrlButton(b, false);
|
||
for (const k of [...ctrl.keys]) ctrlKeyEvent(k, false);
|
||
if (ctrl.g && ctrl.g.timer) clearTimeout(ctrl.g.timer);
|
||
ctrl.pointers.clear(); ctrl.g = null;
|
||
}
|
||
// Where a pointer event falls on the panel, as fractions (the canvas box includes any letterboxing).
|
||
function ctrlFraction(e) {
|
||
const c = $("canvas"), r = c.getBoundingClientRect();
|
||
const scale = Math.min(r.width / c.width, r.height / c.height);
|
||
const w = c.width * scale, h = c.height * scale;
|
||
const x = (e.clientX - r.left - (r.width - w) / 2) / w, y = (e.clientY - r.top - (r.height - h) / 2) / h;
|
||
return x >= 0 && x <= 1 && y >= 0 && y <= 1 ? [x, y] : null;
|
||
}
|
||
|
||
// Mouse (and pen) on a computer: the view is the Frame's screen.
|
||
function ctrlMouse(e) {
|
||
if (ctrlMode() === "abs") {
|
||
const f = ctrlFraction(e);
|
||
if (f) ctrlMoveTo(...f);
|
||
} else if (e.type === "pointermove" && (e.movementX || e.movementY)) {
|
||
ctrlMoveBy(e.movementX * 1.5, e.movementY * 1.5);
|
||
}
|
||
}
|
||
// Touch: tap to click, press and hold to right-click, two fingers to scroll. Dragging is a
|
||
// mouse drag on the Desktop view (sliders, selecting text) and moves the pointer on the headset view.
|
||
const TAP_MOVE = 8, HOLD_MS = 550, TOUCH_SPEED = 1.6, SCROLL_SPEED = 2;
|
||
function ctrlTouchDown(e) {
|
||
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (ctrl.pointers.size === 1) {
|
||
const g = ctrl.g = { kind: "maybe", x: e.clientX, y: e.clientY, e, target: ctrlAimedAt() };
|
||
g.timer = setTimeout(() => {
|
||
if (ctrl.g !== g || g.kind !== "maybe" || !ctrlSameTarget(g)) return;
|
||
g.kind = "held";
|
||
if (ctrlMode() === "abs") {
|
||
const f = ctrlFraction(g.e);
|
||
if (!f) return; // on the bars around the picture: nothing to press
|
||
ctrlMoveTo(...f);
|
||
}
|
||
ctrlClick("right");
|
||
navigator.vibrate?.(10);
|
||
}, HOLD_MS);
|
||
} else if (ctrl.pointers.size === 2) {
|
||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||
if (ctrl.held.has("left")) ctrlButton("left", false);
|
||
ctrl.g = { kind: "scroll", ...centroid() };
|
||
}
|
||
}
|
||
// The panel a gesture started on; if that stops being the one in use, the gesture
|
||
// does nothing more (except let go of what it holds).
|
||
function ctrlAimedAt() { return ctrlMode() === "abs" ? panelKey(ctrlTarget().panel) : "headset"; }
|
||
function ctrlSameTarget(g) { return g.target && g.target === ctrlAimedAt(); }
|
||
function centroid() {
|
||
const ps = [...ctrl.pointers.values()];
|
||
return { x: ps.reduce((a, p) => a + p.x, 0) / ps.length, y: ps.reduce((a, p) => a + p.y, 0) / ps.length };
|
||
}
|
||
function ctrlTouchMove(e) {
|
||
const prev = ctrl.pointers.get(e.pointerId);
|
||
if (!prev || !ctrl.g) return;
|
||
const g = ctrl.g, dx = e.clientX - prev.x, dy = e.clientY - prev.y;
|
||
ctrl.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (g.kind === "scroll") {
|
||
const c = centroid();
|
||
// Content follows the fingers, as on the phone itself.
|
||
ctrlSend([{ scroll: [-(c.x - g.x) * SCROLL_SPEED, -(c.y - g.y) * SCROLL_SPEED] }]);
|
||
g.x = c.x; g.y = c.y;
|
||
return;
|
||
}
|
||
if (g.kind === "maybe" && Math.hypot(e.clientX - g.x, e.clientY - g.y) > TAP_MOVE) {
|
||
clearTimeout(g.timer);
|
||
if (!ctrlSameTarget(g)) { g.kind = "done"; return; }
|
||
if (ctrlMode() === "abs") {
|
||
const f = ctrlFraction(g.e);
|
||
if (f) { ctrlMoveTo(...f); ctrlButton("left", true); }
|
||
g.kind = "drag";
|
||
} else g.kind = "move";
|
||
}
|
||
if (g.kind === "drag") { const f = ctrlFraction(e); if (f) ctrlMoveTo(...f); }
|
||
else if (g.kind === "move") ctrlMoveBy(dx * TOUCH_SPEED, dy * TOUCH_SPEED);
|
||
}
|
||
function ctrlTouchUp(e) {
|
||
const g = ctrl.g;
|
||
ctrl.pointers.delete(e.pointerId);
|
||
if (!g) return;
|
||
if (g.kind === "scroll") g.kind = "done"; // the finger left behind doesn't jump the scroll or drag
|
||
if (g.kind === "maybe" && !ctrlSameTarget(g)) g.kind = "done";
|
||
if (g.kind === "maybe" && ctrl.pointers.size === 0) {
|
||
clearTimeout(g.timer);
|
||
if (ctrlMode() === "abs") { const f = ctrlFraction(g.e); if (!f) { ctrl.g = null; return; } ctrlMoveTo(...f); }
|
||
ctrlClick("left");
|
||
} else if (g.kind === "drag") ctrlButton("left", false);
|
||
if (ctrl.pointers.size === 0) ctrl.g = null;
|
||
}
|
||
// The system took the touch (a swipe home, a call): no tap, and let go of anything held.
|
||
function ctrlTouchCancel(e) {
|
||
ctrl.pointers.delete(e.pointerId);
|
||
if (ctrl.g?.timer) clearTimeout(ctrl.g.timer);
|
||
if (ctrl.held.has("left")) ctrlButton("left", false);
|
||
if (ctrl.g) ctrl.g.kind = "done";
|
||
if (ctrl.pointers.size === 0) ctrl.g = null;
|
||
}
|
||
const canvasEl = $("canvas");
|
||
canvasEl.addEventListener("pointerdown", e => {
|
||
if (!ctrl.on) return;
|
||
e.preventDefault();
|
||
$("viewer").focus({ preventScroll: true });
|
||
try { canvasEl.setPointerCapture(e.pointerId); } catch { /* a pointer that's already gone */ }
|
||
if (!ctrlTarget().ok) return ctrlShow();
|
||
if (e.pointerType === "touch") return ctrlTouchDown(e);
|
||
if (ctrlMode() === "abs" && !ctrlFraction(e)) return; // on the bars around the picture
|
||
ctrlMouse(e);
|
||
ctrlButton(CTRL_BUTTONS[e.button] || "left", true);
|
||
});
|
||
canvasEl.addEventListener("pointermove", e => {
|
||
if (!ctrl.on || !ctrlTarget().ok) return;
|
||
if (e.pointerType === "touch") return ctrlTouchMove(e);
|
||
ctrlMouse(e);
|
||
});
|
||
for (const type of ["pointerup", "pointercancel"]) canvasEl.addEventListener(type, e => {
|
||
if (!ctrl.on) return;
|
||
if (e.pointerType === "touch") return type === "pointercancel" ? ctrlTouchCancel(e) : ctrlTouchUp(e);
|
||
if (type === "pointercancel") return [...ctrl.held].forEach(b => ctrlButton(b, false)); // no button is named
|
||
const name = CTRL_BUTTONS[e.button] || "left";
|
||
if (ctrl.held.has(name)) ctrlButton(name, false);
|
||
});
|
||
canvasEl.addEventListener("contextmenu", e => { if (ctrl.on) e.preventDefault(); });
|
||
$("viewer").addEventListener("wheel", e => {
|
||
if (!ctrl.on || !ctrlTarget().ok) return;
|
||
e.preventDefault();
|
||
const unit = e.deltaMode === 1 ? 40 : e.deltaMode === 2 ? 800 : 1;
|
||
ctrlSend([{ scroll: [e.deltaX * unit, e.deltaY * unit] }]);
|
||
}, { passive: false });
|
||
// Keys while the view has focus go to the Frame (⌘ as Ctrl on a Mac); click elsewhere to stop.
|
||
function ctrlKey(e, down) {
|
||
if (!ctrl.on || document.activeElement !== $("viewer") || (down && !ctrlTarget().ok)) return;
|
||
let code = EVDEV[e.code];
|
||
if (IS_MAC && (e.code === "MetaLeft" || e.code === "MetaRight")) code = 29;
|
||
if (!code) return;
|
||
e.preventDefault();
|
||
e.stopPropagation(); // it's for the Frame, not Frame Control's own shortcuts
|
||
if (down && e.repeat && [29, 42, 54, 56, 97, 100, 125, 126].includes(code)) return;
|
||
if (!down && !ctrl.keys.has(code)) return;
|
||
ctrlKeyEvent(code, down);
|
||
}
|
||
$("viewer").addEventListener("keydown", e => ctrlKey(e, true));
|
||
$("viewer").addEventListener("keyup", e => ctrlKey(e, false));
|
||
|
||
// Phone keyboard: the field types plain text; the buttons send single keys and clicks.
|
||
function ctrlTap(code) { ctrlKeyEvent(code, true); ctrlKeyEvent(code, false); }
|
||
let ctrlWarned = false;
|
||
function ctrlText(text) {
|
||
const plain = text.replace(/[^\x20-\x7e\n\t]/g, "");
|
||
if (plain !== text && !ctrlWarned) {
|
||
ctrlWarned = true;
|
||
toast("Control types plain letters, numbers and symbols. For accents and emoji, use Keyboard and trackpad below.");
|
||
}
|
||
// Small pieces: the Frame types them key by key, and a release queued behind mustn't wait long.
|
||
for (const part of plain.match(/[\s\S]{1,100}/g) || []) ctrlSend([{ text: part }]);
|
||
}
|
||
$("ctrlType").addEventListener("beforeinput", e => {
|
||
if (e.isComposing) return;
|
||
e.preventDefault();
|
||
if (!ctrlTarget().ok) return ctrlShow();
|
||
if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") return ctrlTap(28);
|
||
if (e.inputType === "deleteContentBackward") return ctrlTap(14);
|
||
if (e.inputType === "deleteContentForward") return ctrlTap(111);
|
||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||
if (text) ctrlText(text);
|
||
});
|
||
$("ctrlType").addEventListener("compositionend", e => { if (e.data) ctrlText(e.data); $("ctrlType").value = ""; });
|
||
$("ctrlKeys").addEventListener("click", e => {
|
||
const k = e.target.closest("[data-ctrl-key]"), c = e.target.closest("[data-ctrl-click]");
|
||
if (!ctrlTarget().ok) return ctrlShow();
|
||
if (k) ctrlTap(+k.dataset.ctrlKey);
|
||
if (c) ctrlClick(c.dataset.ctrlClick);
|
||
});
|
||
|
||
// ---- controls ----
|
||
$("refreshAll").onclick = () => { refresh(); loadTitles(); }; // titles too: the Frame may have been asleep at start
|
||
document.addEventListener("keydown", e => {
|
||
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
|
||
});
|
||
document.body.addEventListener("click", async (e) => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
if (b.dataset.launch) act(`Launch ${b.dataset.name}`, () => api("/api/launch", { appid: b.dataset.launch }), b);
|
||
else if (b.dataset.uninstall) {
|
||
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
|
||
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
|
||
} else if (b.dataset.quick) installFlatpak(b.dataset.quick);
|
||
else if (b.dataset.open) {
|
||
const what = b.dataset.open, label = b.textContent.trim();
|
||
// On the phone, power needs the password here, and the rest opens other apps.
|
||
if (HOST.mobile && POWER[what]) return askPower(what);
|
||
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
|
||
if (HOST.mobile && window.frameApp?.open) return act(label, () => window.frameApp.open(what), b);
|
||
act(label, () => api("/api/open", { what }), b);
|
||
}
|
||
});
|
||
// Installs run as server jobs: start one, then poll until it's done. The
|
||
// bottom bar counts what's running; `installing` keeps re-rendered lists from
|
||
// offering Install again for something already on its way.
|
||
const installing = new Set();
|
||
let jobsRunning = 0;
|
||
function showJobs() {
|
||
$("jobs").hidden = !jobsRunning;
|
||
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
|
||
}
|
||
async function runJob(label, key, start) {
|
||
installing.add(key); jobsRunning++; showJobs();
|
||
log(label + "…");
|
||
try {
|
||
const { job } = await start();
|
||
for (let misses = 0; ;) {
|
||
await new Promise(ok => setTimeout(ok, 1500));
|
||
let j;
|
||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||
if (!j.done) continue;
|
||
if (j.error) throw new Error(j.error);
|
||
log(j.message, "ok"); toast(j.message);
|
||
return j.result || {};
|
||
}
|
||
} catch (e) {
|
||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||
} finally { installing.delete(key); jobsRunning--; showJobs(); }
|
||
}
|
||
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
|
||
poweroff: ["Shut the Frame down?", "Shut down"] };
|
||
function askPower(what) {
|
||
const [title, verb] = POWER[what], dlg = $("pwDlg");
|
||
$("pwTitle").textContent = title; $("pwGo").textContent = verb;
|
||
$("pwInput").value = ""; $("pwMsg").textContent = "";
|
||
$("pwCancel").onclick = () => dlg.close();
|
||
$("pwForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const password = $("pwInput").value;
|
||
if (!password) return ($("pwMsg").textContent = "Enter the password");
|
||
$("pwGo").disabled = true; $("pwMsg").textContent = "";
|
||
try {
|
||
const res = await api("/api/open", { what, password });
|
||
dlg.close(); log(res.message, "ok"); toast(res.message);
|
||
} catch (err) { $("pwMsg").textContent = err.message; }
|
||
finally { $("pwGo").disabled = false; $("pwInput").value = ""; }
|
||
};
|
||
dlg.showModal();
|
||
$("pwInput").focus();
|
||
}
|
||
$("changeHeadset").onclick = () => window.frameApp?.setUpConnection && window.frameApp.setUpConnection();
|
||
|
||
function installFlatpak(id) {
|
||
if (installing.has(id)) return;
|
||
toast(`Installing ${id}. This can take a few minutes…`);
|
||
const done = runJob(`Install ${id}`, id, () => api("/api/flatpak", { action: "install", id })).then(refresh);
|
||
if (state) render(state); // show it as installing
|
||
return done;
|
||
}
|
||
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) { installFlatpak(id); $("fpId").value = ""; } };
|
||
$("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); });
|
||
|
||
function setVolume(pct, muted) {
|
||
$("vol").value = pct;
|
||
$("vol").style.setProperty("--fill", pct + "%");
|
||
$("volNum").textContent = pct + "%";
|
||
if (muted !== undefined) $("volWaves").style.display = muted ? "none" : "";
|
||
}
|
||
$("vol").oninput = () => {
|
||
setVolume(+$("vol").value);
|
||
clearTimeout(volTimer);
|
||
volTimer = setTimeout(async () => {
|
||
try { await api("/api/volume", { level: $("vol").value / 100 }); } 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 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 });
|
||
}
|
||
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
|
||
|
||
// ---- file drop ----
|
||
const drop = $("drop");
|
||
drop.onclick = () => $("fileInput").click();
|
||
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
|
||
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
|
||
// Files can be dropped anywhere in the window; an overlay says what happens.
|
||
// Dialogs keep their own drops (none take files today), so the overlay stays out of them.
|
||
let dragDepth = 0;
|
||
const carriesFiles = e => [...(e.dataTransfer?.types || [])].includes("Files");
|
||
window.addEventListener("dragenter", e => {
|
||
if (!carriesFiles(e) || document.querySelector("dialog[open]")) return;
|
||
e.preventDefault();
|
||
if (dragDepth++ === 0) { $("dropzone").hidden = false; drop.classList.add("over"); }
|
||
});
|
||
window.addEventListener("dragover", e => { if (carriesFiles(e)) e.preventDefault(); });
|
||
window.addEventListener("dragleave", e => {
|
||
if (!carriesFiles(e) || dragDepth === 0) return;
|
||
if (--dragDepth === 0) { $("dropzone").hidden = true; drop.classList.remove("over"); }
|
||
});
|
||
window.addEventListener("drop", e => {
|
||
if (!carriesFiles(e)) return;
|
||
e.preventDefault();
|
||
dragDepth = 0; $("dropzone").hidden = true; drop.classList.remove("over");
|
||
if (document.querySelector("dialog[open]")) return;
|
||
// Folders only show up as directory entries, and only during the drop event itself.
|
||
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
|
||
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||
sendFiles([...e.dataTransfer.files], dirs);
|
||
});
|
||
function upload(file, mode) {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open("POST", "/api/upload");
|
||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||
xhr.setRequestHeader("X-Mode", mode);
|
||
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||
const bar = $("prog").firstElementChild;
|
||
$("prog").style.display = "block"; bar.style.width = "0";
|
||
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
||
xhr.onload = () => {
|
||
$("prog").style.display = "none";
|
||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
|
||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||
};
|
||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||
xhr.send(file);
|
||
});
|
||
}
|
||
async function refreshMedia(selectId) {
|
||
const data = await api("/api/media", { action: "list" });
|
||
const selected = selectId || $("mediaFile").value;
|
||
$("mediaFile").replaceChildren();
|
||
if (!data.files.length) $("mediaFile").add(new Option("Send media to begin", ""));
|
||
for (const file of data.files) $("mediaFile").add(new Option(file.name, file.id));
|
||
if (data.files.some(f => f.id === selected)) $("mediaFile").value = selected;
|
||
const p = data.player;
|
||
$("mediaState").textContent = p.error || `${p.state}${p.file ? ": " + p.file : ""}`;
|
||
return data;
|
||
}
|
||
$("mediaChoose").onclick = () => $("mediaInput").click();
|
||
$("mediaInput").onchange = () => act("Send media", async () => {
|
||
const file = $("mediaInput").files[0];
|
||
if (!file) return;
|
||
const result = await upload(file, "media");
|
||
await refreshMedia(result.id);
|
||
$("mediaInput").value = "";
|
||
return result;
|
||
}, $("mediaChoose"));
|
||
$("mediaRefresh").onclick = () => act("Refresh media", () => refreshMedia(), $("mediaRefresh"));
|
||
$("mediaPlay").onclick = () => act("Play media", async () => {
|
||
const id = $("mediaFile").value;
|
||
if (!id) throw new Error("Send or select a media file first");
|
||
const result = await api("/api/media", { action: "play", id,
|
||
layout: $("mediaLayout").value, theatre: $("mediaTheatre").checked });
|
||
$("mediaState").textContent = "Starting…";
|
||
setTimeout(() => refreshMedia().catch(e => { $("mediaState").textContent = e.message; }), 1500);
|
||
return result;
|
||
}, $("mediaPlay"));
|
||
$("mediaStop").onclick = () => act("Stop media", async () => {
|
||
const result = await api("/api/media", { action: "stop" });
|
||
await refreshMedia();
|
||
return result;
|
||
}, $("mediaStop"));
|
||
|
||
let apkLookup = 0;
|
||
async function checkApkAlternatives(apk) {
|
||
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);
|
||
} catch (e) {
|
||
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
||
}
|
||
}
|
||
function showApkAlternatives(reason, result) {
|
||
$("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…";
|
||
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
||
action: "install", package: result.package, url: v.url
|
||
}));
|
||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||
await loadAndroid();
|
||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
||
if (res && res.app) await offerTest(res.app);
|
||
};
|
||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||
}
|
||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
||
|
||
// ---- about and licences ----
|
||
document.addEventListener("click", async e => {
|
||
if (!e.target.closest("[data-about]")) return;
|
||
e.preventDefault();
|
||
$("aboutDlg").showModal();
|
||
try {
|
||
const { notices } = await api("/api/licenses");
|
||
$("aboutText").innerHTML = notices.map((n, i) =>
|
||
`<details${i ? "" : " open"}><summary>${esc(n.title)}</summary><pre>${esc(n.text)}</pre></details>`).join("");
|
||
} catch (err) {
|
||
$("aboutText").textContent = `Couldn't load the licences: ${err.message}. They're also in THIRD_PARTY_NOTICES.md and frame/kdeconnect in Frame Control's source.`;
|
||
}
|
||
});
|
||
$("aboutClose").onclick = () => $("aboutDlg").close();
|
||
|
||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||
async function sendFiles(files, dirs = new Set()) {
|
||
for (const [i, f] of files.entries()) {
|
||
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
|
||
if (dirs.has(i)) {
|
||
if (path) await sideload(f, path, true);
|
||
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
|
||
continue;
|
||
}
|
||
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||
if (apk && res && res.app) await offerTest(res.app);
|
||
}
|
||
$("fileInput").value = "";
|
||
refresh();
|
||
}
|
||
|
||
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
|
||
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"];
|
||
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
|
||
const trim = x => x.replace(/^_+|_+$/g, "");
|
||
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9]+/g, "_")).slice(0, 64));
|
||
if (!s) return s;
|
||
if (/^[0-9]/.test(s)) s = "_" + s.slice(0, 63);
|
||
if (s.length < 2 || RESERVED_IDS.includes(s.toLowerCase())) s += "_game";
|
||
return s;
|
||
}
|
||
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
|
||
function candLabel(c) {
|
||
const kind = c.format === "pe" ? `Windows ${ARCH[c.arch] || c.arch}` : c.format === "elf" ? `Linux ${ARCH[c.arch] || c.arch}` : "script";
|
||
return `${c.path} · ${kind} · ${gb(c.size)}${c.skip ? " · helper?" : ""}`;
|
||
}
|
||
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
|
||
// One at a time, so a second drop doesn't take over the dialog of the first.
|
||
let sideloadQueue = Promise.resolve();
|
||
function sideload(...args) {
|
||
const run = sideloadQueue.then(() => sideloadOne(...args));
|
||
sideloadQueue = run.catch(() => {});
|
||
return run;
|
||
}
|
||
async function sideloadOne(file, path, isDir = false) {
|
||
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"); }
|
||
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"));
|
||
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"));
|
||
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">▶</button>
|
||
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
|
||
</div>`).join("") : `<div class="sub">None yet.</div>`;
|
||
}
|
||
$("titleList").onclick = async e => {
|
||
const b = e.target.closest("[data-title]"); if (!b) return;
|
||
const id = b.dataset.id;
|
||
if (b.dataset.title === "remove") {
|
||
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
|
||
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
|
||
loadTitles();
|
||
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
|
||
};
|
||
loadTitles();
|
||
$("titleAdd").onclick = () => $("fileInput").click();
|
||
|
||
|
||
// ---- Steam games: install owned games, find and buy on the store ----
|
||
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
|
||
const FCLASS = { 3: "v-works", 2: "v-maybe", 1: "v-no", 0: "f-0" };
|
||
const gm = { src: "owned", owned: null, byId: new Map(), country: null, dl: null, q: "",
|
||
// Rating filters per view: most store results aren't rated yet, so show them all there.
|
||
filters: { owned: new Set(["3", "2"]), store: new Set(["3", "2", "0", "1"]) },
|
||
results: [], shown: 0, store: [], storeQ: null, poll: null, seq: 0 };
|
||
|
||
async function loadOwned() {
|
||
$("gmReload").disabled = true;
|
||
try {
|
||
const r = await api("/api/steam/owned");
|
||
gm.owned = r.games; gm.country = r.country || gm.country; gm.dl = r.download;
|
||
gm.byId = new Map(gm.owned.map(g => [g.id, g]));
|
||
} catch (e) {
|
||
if (!gm.owned) failed($("gmGrid"), e);
|
||
if (!e.offline) log("Steam library: " + e.message, "e");
|
||
// Keep trying while a download was in progress; one failed poll shouldn't freeze it.
|
||
clearTimeout(gm.poll);
|
||
if (gm.dl) gm.poll = setTimeout(loadOwned, 15000);
|
||
return;
|
||
} finally { $("gmReload").disabled = false; }
|
||
const d = gm.dl, name = d && gm.byId.get(d.appid)?.name;
|
||
$("gmDl").textContent = d ? `${d.paused ? "Paused" : "Downloading"} ${name || d.appid} · ${d.percent ?? 0}%` +
|
||
(d.eta > 0 && !d.paused ? ` · ${dur(d.eta)} left` : "") : "";
|
||
// Keep polling while something downloads so the progress and Installed tags update.
|
||
clearTimeout(gm.poll);
|
||
if (d) gm.poll = setTimeout(loadOwned, document.hidden ? 30000 : 5000);
|
||
renderChips(); filterGames(true);
|
||
}
|
||
|
||
function renderChips() {
|
||
const counts = {};
|
||
(gm.src === "owned" ? gm.owned || [] : gm.store).forEach(g => counts[g.frame] = (counts[g.frame] || 0) + 1);
|
||
$("gmChips").innerHTML = [3, 2, 0, 1].map(k => `<button class="chip vchip ${FCLASS[k]}" data-f="${k}"
|
||
aria-pressed="${gm.filters[gm.src].has(String(k))}">${FLABEL[k]} (${(counts[k] || 0).toLocaleString()})</button>`).join("");
|
||
}
|
||
|
||
function filterGames(keepScroll) {
|
||
const y = window.scrollY;
|
||
if (gm.src === "owned") {
|
||
if (!gm.owned) return;
|
||
const terms = gm.q.toLowerCase().split(/\s+/).filter(Boolean);
|
||
gm.results = gm.owned.filter(g => gm.filters.owned.has(String(g.frame)) && terms.every(t => g.name.toLowerCase().includes(t)));
|
||
// Installed and downloading first, then recently played, then by name.
|
||
gm.results.sort((a, b) => (b.installed - a.installed) || (b.lastPlayed - a.lastPlayed) || a.sort.localeCompare(b.sort));
|
||
$("gmCount").textContent = `${gm.results.length.toLocaleString()} of ${gm.owned.length.toLocaleString()} owned`;
|
||
} else {
|
||
gm.results = gm.store.filter(g => gm.filters.store.has(String(g.frame)));
|
||
$("gmCount").textContent = gm.storeQ ? `${gm.results.length} of ${gm.store.length} store results` : "";
|
||
}
|
||
const n = Math.max(48, keepScroll ? gm.shown : 0);
|
||
$("gmGrid").innerHTML = ""; gm.shown = 0; moreGames(n);
|
||
if (!gm.results.length) $("gmGrid").innerHTML = `<div class="sub">${gm.src === "store" && !gm.storeQ
|
||
? "Type a name and press Enter to search the Steam store." : "Nothing matches. Try turning on more ratings above."}</div>`;
|
||
if (keepScroll) window.scrollTo(0, y);
|
||
}
|
||
|
||
function price(p) {
|
||
if (!p) return "Free";
|
||
const f = n => { try { return new Intl.NumberFormat(undefined, { style: "currency", currency: p.currency }).format(n / 100); }
|
||
catch { return `${(n / 100).toFixed(2)} ${esc(p.currency || "")}`; } };
|
||
const fin = p.final ?? p.initial ?? 0;
|
||
return p.initial > fin ? `<s>${f(p.initial)}</s>${f(fin)}` : f(fin);
|
||
}
|
||
|
||
function gameButtons(id, name, free) {
|
||
const g = gm.byId.get(id), d = gm.dl;
|
||
const storeUrl = `https://store.steampowered.com/app/${id}/`;
|
||
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
|
||
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
|
||
if (g && g.installed) return `<span class="tag">Installed</span>` +
|
||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
|
||
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
|
||
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
|
||
}
|
||
|
||
function moreGames(count = 48) {
|
||
const next = gm.results.slice(gm.shown, gm.shown + count);
|
||
$("gmGrid").insertAdjacentHTML("beforeend", next.map(g => {
|
||
const own = gm.byId.get(g.id), d = gm.dl && gm.dl.appid === g.id ? gm.dl : null;
|
||
const sub = gm.src === "owned"
|
||
? [g.minutes ? `${(g.minutes / 60).toFixed(g.minutes < 600 ? 1 : 0)} h played` : "Never played", g.vrOnly ? "VR only" : g.vr ? "VR" : "",
|
||
g.installed && g.size ? gb(g.size) : ""].filter(Boolean).join(" · ")
|
||
: `<span class="price">${own ? "In your library" : price(g.price)}</span>${g.metascore ? ` · Metacritic ${esc(g.metascore)}` : ""}`;
|
||
return `<div class="cat-card game-card">
|
||
<img class="art" loading="lazy" src="${CDN}/${g.id}/capsule_231x87.jpg" alt="" onerror="this.style.visibility='hidden'">
|
||
<div class="top"><div style="min-width:0"><div class="t">${esc(g.name)}</div><div class="s">${sub}</div></div>
|
||
<span class="vbadge ${FCLASS[g.frame] ?? FCLASS[0]}">${FLABEL[g.frame] ?? FLABEL[0]}</span></div>
|
||
${d ? `<div class="bar"><i style="width:${d.percent ?? 0}%"></i></div>` : ""}
|
||
<div class="row">${gameButtons(g.id, g.name, gm.src === "store" && !g.price)}</div>
|
||
</div>`;
|
||
}).join(""));
|
||
gm.shown += next.length;
|
||
$("gmMore").hidden = gm.shown >= gm.results.length;
|
||
}
|
||
|
||
async function searchStore() {
|
||
const q = $("gmQ").value.trim();
|
||
if (!q) { gm.store = []; gm.storeQ = null; renderChips(); return filterGames(); }
|
||
if (!gm.country) await loadOwned(); // the store search needs the account's country
|
||
if (!gm.country) return toast("Couldn't read the Steam account's country from the Frame", true);
|
||
const seq = ++gm.seq;
|
||
$("gmGrid").innerHTML = `<div class="sub">Searching the Steam store…</div>`;
|
||
try {
|
||
const r = await api(`/api/steam/search?q=${encodeURIComponent(q)}&cc=${gm.country}`);
|
||
if (seq !== gm.seq) return;
|
||
gm.store = r.results; gm.storeQ = q;
|
||
} catch (e) { if (seq === gm.seq) $("gmGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||
renderChips(); filterGames();
|
||
}
|
||
|
||
function setGameSource(src) {
|
||
gm.src = src;
|
||
document.querySelectorAll("[data-src]").forEach(b => b.classList.toggle("on", b.dataset.src === src));
|
||
$("gmQ").placeholder = src === "owned" ? "Filter your games…" : "Search the Steam store, then press Enter…";
|
||
$("gmQ").value = src === "owned" ? gm.q : gm.storeQ || "";
|
||
renderChips(); filterGames();
|
||
}
|
||
|
||
$("gmSrc").onclick = e => { const b = e.target.closest("[data-src]"); if (b) setGameSource(b.dataset.src); };
|
||
let gmTimer;
|
||
$("gmQ").oninput = e => {
|
||
if (gm.src !== "owned") return;
|
||
clearTimeout(gmTimer); gmTimer = setTimeout(() => { gm.q = e.target.value; filterGames(); }, 150);
|
||
};
|
||
$("gmQ").addEventListener("keydown", e => { if (e.key === "Enter" && gm.src === "store") searchStore(); });
|
||
$("gmReload").onclick = loadOwned;
|
||
$("gmMore").onclick = () => moreGames();
|
||
$("gmChips").onclick = e => {
|
||
const b = e.target.closest("[data-f]"); if (!b) return;
|
||
const f = gm.filters[gm.src];
|
||
f.has(b.dataset.f) ? f.delete(b.dataset.f) : f.add(b.dataset.f);
|
||
b.setAttribute("aria-pressed", f.has(b.dataset.f)); filterGames();
|
||
};
|
||
document.body.addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-gm]"); if (!b) return;
|
||
const action = b.dataset.gm, appid = b.dataset.id, name = b.dataset.name;
|
||
const res = await act(action === "install" ? `Install ${name}` : `Open ${name} in the headset store`,
|
||
() => api("/api/steam", { action, appid }), b);
|
||
if (action === "install" && res) loadOwned();
|
||
});
|
||
loadOwned();
|
||
|
||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
|
||
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
|
||
let androidApps = [];
|
||
|
||
async function loadAndroid() {
|
||
try {
|
||
androidApps = (await api("/api/android")).apps;
|
||
} catch (e) { return failed($("andApps"), e); }
|
||
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
|
||
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
|
||
const c = cat.apps && cat.apps.find(x => x.p === a.package);
|
||
const icon = c && c.i ? `<img class="and-icon" src="${esc(c.i)}" alt="">` : `<div class="and-icon"></div>`;
|
||
return `<div class="item and-item">${icon}
|
||
<div class="grow"><div class="t">${esc(a.label)}</div>
|
||
<div class="s">${esc(a.package)} · v${esc(String(a.version || "").replace(/^v/i, ""))}</div></div>
|
||
${a.running ? `<span class="tag">Running</span>` : ""}
|
||
<div class="row and-btns">
|
||
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
|
||
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
|
||
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
|
||
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
|
||
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
|
||
</div></div>`;
|
||
}).join("") : `<div class="sub">No Android apps yet. Install one from the catalogue, or drop an .apk on Send to Frame.</div>`;
|
||
const installed = new Set(androidApps.map(a => a.package));
|
||
document.querySelectorAll('#catGrid [data-and="install"]').forEach(b => {
|
||
if (installed.has(b.dataset.pkg)) b.outerHTML = `<span class="tag">Installed</span>`;
|
||
});
|
||
}
|
||
|
||
async function loadCatalog() {
|
||
try { cat.apps = (await api("/api/android/catalog")).apps; }
|
||
catch (e) { $("catGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||
cat.apps.forEach(a => a.hay = [a.n, a.s, a.p, ...a.c].join(" ").toLowerCase());
|
||
const cats = [...new Set(cat.apps.flatMap(a => a.c))].sort();
|
||
$("catCat").innerHTML = `<option value="">All categories</option>` + cats.map(c => `<option>${esc(c)}</option>`).join("");
|
||
$("catCat").value = cat.cat;
|
||
const counts = {}; cat.apps.forEach(a => counts[a.r] = (counts[a.r] || 0) + 1);
|
||
$("catChips").innerHTML = Object.keys(VLABEL).map(k =>
|
||
`<button class="chip vchip v-${k}" data-v="${k}" aria-pressed="${cat.v.has(k)}">${VLABEL[k]} (${(counts[k] || 0).toLocaleString()})</button>`).join("");
|
||
filterCatalog();
|
||
loadAndroid(); // icons for installed apps come from the catalogue
|
||
}
|
||
|
||
function filterCatalog() {
|
||
const terms = cat.q.toLowerCase().split(/\s+/).filter(Boolean);
|
||
cat.results = cat.apps.filter(a => cat.v.has(a.r) && (!cat.cat || a.c.includes(cat.cat)) && terms.every(t => a.hay.includes(t)));
|
||
if (terms.length) {
|
||
const t = terms.join(" "), score = a => { const n = a.n.toLowerCase(); return n === t ? 0 : n.startsWith(t) ? 1 : n.includes(t) ? 2 : 3; };
|
||
const order = Object.keys(VLABEL);
|
||
cat.results.sort((x, y) => order.indexOf(x.r) - order.indexOf(y.r) || score(x) - score(y));
|
||
}
|
||
$("catCount").textContent = `${cat.results.length.toLocaleString()} of ${cat.apps.length.toLocaleString()}`;
|
||
$("catGrid").innerHTML = ""; cat.shown = 0; moreCatalog();
|
||
}
|
||
|
||
function moreCatalog() {
|
||
const installed = new Set(androidApps.map(a => a.package));
|
||
const next = cat.results.slice(cat.shown, cat.shown + 48);
|
||
$("catGrid").insertAdjacentHTML("beforeend", next.map(a => `
|
||
<div class="cat-card">
|
||
<div class="top">
|
||
${a.i ? `<img class="and-icon" loading="lazy" src="${esc(a.i)}" alt="">` : `<div class="and-icon"></div>`}
|
||
<div style="min-width:0"><div class="t">${esc(a.n)}</div><div class="s">v${esc(String(a.v || "").replace(/^v/i, ""))} · ${gb(a.z || 0)}</div></div>
|
||
<span class="vbadge v-${a.r}">${VLABEL[a.r]}</span>
|
||
</div>
|
||
<div class="s">${esc(a.s)}</div>
|
||
<div class="why">${esc(a.why[0] || "")}${a.why.length > 1 ? `<span title="${esc(a.why.slice(1).join("\n"))}"> · more</span>` : ""}</div>
|
||
<div class="row">
|
||
${installed.has(a.p) ? `<span class="tag">Installed</span>`
|
||
: installing.has(a.p) ? `<span class="tag">Installing…</span>`
|
||
: a.r === "no" ? "" : `<button class="action small" data-and="install" data-pkg="${esc(a.p)}" data-name="${esc(a.n)}">Install</button>`}
|
||
<button class="small" data-and="rate" data-pkg="${esc(a.p)}" data-ver="${esc(a.v)}" data-name="${esc(a.n)}" data-src="F-Droid" title="Report how it worked">Report</button>
|
||
<a class="small" href="https://f-droid.org/packages/${esc(a.p)}/" target="_blank" rel="noopener">F-Droid</a>
|
||
</div>
|
||
</div>`).join(""));
|
||
cat.shown += next.length;
|
||
$("catMore").hidden = cat.shown >= cat.results.length;
|
||
}
|
||
|
||
$("catMore").onclick = moreCatalog;
|
||
let catTimer;
|
||
$("catQ").oninput = e => { clearTimeout(catTimer); catTimer = setTimeout(() => { cat.q = e.target.value; filterCatalog(); }, 150); };
|
||
$("catCat").onchange = e => { cat.cat = e.target.value; filterCatalog(); };
|
||
$("catChips").onclick = e => {
|
||
const b = e.target.closest("[data-v]"); if (!b) return;
|
||
cat.v.has(b.dataset.v) ? cat.v.delete(b.dataset.v) : cat.v.add(b.dataset.v);
|
||
b.setAttribute("aria-pressed", cat.v.has(b.dataset.v)); filterCatalog();
|
||
};
|
||
document.body.addEventListener("click", async e => {
|
||
const b = e.target.closest("[data-and]"); if (!b) return;
|
||
const pkg = b.dataset.pkg, action = b.dataset.and;
|
||
if (action === "install") {
|
||
if (installing.has(pkg)) return;
|
||
toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
|
||
const done = runJob(`Install ${b.dataset.name}`, pkg, () => api("/api/android", { action, package: pkg }));
|
||
b.outerHTML = `<span class="tag">Installing…</span>`;
|
||
const res = await done;
|
||
if (res && res.app) await offerTest(res.app);
|
||
} else if (action === "remove") {
|
||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||
} else if (action === "rate") {
|
||
openReport({ package: pkg, version: b.dataset.ver, label: b.dataset.name, source: b.dataset.src });
|
||
return;
|
||
} else {
|
||
if (action === "probe") toast(`Testing ${pkg}: launching it and watching for 20 s…`);
|
||
await act(`${action[0].toUpperCase() + action.slice(1)} ${pkg}`, () => api("/api/android", { action, package: pkg }), b);
|
||
if (action === "probe") { loadCatalog(); loadReports(); }
|
||
}
|
||
loadAndroid();
|
||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
||
});
|
||
loadCatalog();
|
||
|
||
// ---- Android display (wm size / wm density / font_scale per Lepton instance) ----
|
||
const disp = { list: [], port: null, error: null, seq: 0 };
|
||
const DPI = [120, 640];
|
||
const dims = s => { const m = /^(\d+)x(\d+)$/.exec(s || ""); return m ? [+m[1], +m[2]] : null; };
|
||
const clampDpi = d => Math.max(DPI[0], Math.min(DPI[1], Math.round(d)));
|
||
function dispLabel(i) {
|
||
const apps = (i.packages || []).map(p => i.labels && i.labels[p] ? `${i.labels[p]} (${p})` : p);
|
||
const dev = i.container === "lepton-dev";
|
||
const what = apps.length ? apps.join(", ") : "no apps installed";
|
||
return `${dev ? "Lepton Development: " : ""}${what} · port ${i.port}`;
|
||
}
|
||
function dispCur() { return disp.list.find(i => i.port === disp.port); }
|
||
// Derived values: the density that looks "default" at the current resolution,
|
||
// and how far the user has moved from it.
|
||
function dispCalc(i) {
|
||
const phys = dims(i.physicalSize), size = dims(i.overrideSize) || phys;
|
||
const physD = i.physicalDensity, curD = i.overrideDensity || physD;
|
||
const factor = phys && size ? size[0] / phys[0] : 1;
|
||
const baseD = physD ? physD * factor : null;
|
||
return { phys, size, physD, curD, factor, baseD, userScale: baseD ? curD / baseD : 1 };
|
||
}
|
||
async function loadDisplays() {
|
||
$("dispReload").disabled = true;
|
||
const seq = ++disp.seq; // an older, slower request mustn't overwrite a newer answer
|
||
let list = [], error = null;
|
||
try {
|
||
list = (await api("/api/android/displays")).instances;
|
||
} catch (e) {
|
||
error = e;
|
||
if (!e.offline) log("Android display: " + e.message, "e");
|
||
} finally { $("dispReload").disabled = false; }
|
||
if (seq !== disp.seq) return;
|
||
disp.list = list; disp.error = error;
|
||
if (!disp.list.some(i => i.port === disp.port)) disp.port = disp.list.length ? disp.list[0].port : null;
|
||
const sel = $("dispSel");
|
||
sel.innerHTML = disp.list.length ? disp.list.map(i => `<option value="${i.port}">${esc(dispLabel(i))}</option>`).join("")
|
||
: `<option value="">${disp.error ? (disp.error.offline ? "Waiting for the Frame" : "Couldn't read Android's display")
|
||
: "No Android app is open"}</option>`;
|
||
sel.disabled = !disp.list.length;
|
||
// Open-an-app advice only fits a successful, empty answer; otherwise say what went wrong.
|
||
$("dispEmpty").textContent = !disp.error ? "Open an Android app in the headset, then Refresh, to change its resolution, UI scale and text size here."
|
||
: disp.error.offline ? "This fills in when the Frame answers." : disp.error.message;
|
||
$("dispEmpty").hidden = !!disp.list.length;
|
||
$("dispCtl").hidden = !disp.list.length;
|
||
if (disp.port) sel.value = String(disp.port);
|
||
renderDisplay();
|
||
}
|
||
function renderDisplay() {
|
||
const i = dispCur();
|
||
const btns = document.querySelectorAll("#display .seg button, #dispDpiSet, #dispReset, #dispDpi");
|
||
btns.forEach(b => b.disabled = !i || !!i.error);
|
||
document.querySelectorAll("#display .seg button").forEach(b => b.classList.remove("on"));
|
||
const set = (id, v, d) => { $(id).textContent = v; $(id + "D").textContent = d || " "; };
|
||
if (!i || i.error) {
|
||
set("dispSize", "—", i ? i.error : ""); set("dispDens", "—"); set("dispFont", "—");
|
||
return;
|
||
}
|
||
const c = dispCalc(i);
|
||
set("dispSize", c.size ? `${c.size[0]}×${c.size[1]}` : "—", i.overrideSize ? `panel is ${i.physicalSize}` : "native");
|
||
set("dispDens", `${c.curD ?? "—"} dpi`, i.overrideDensity ? `default ${i.physicalDensity}` : "default");
|
||
const fs = i.fontScale ?? 1;
|
||
set("dispFont", `${fs === 1 ? "1.0" : fs}×`, i.fontScale == null ? "default" : "custom");
|
||
$("dispDpi").value = c.curD ?? "";
|
||
const res = c.factor === 1 ? "native" : c.size && c.size[0] === 2560 && c.size[1] === 1440 ? "sharp" : "";
|
||
document.querySelectorAll("[data-res]").forEach(b => b.classList.toggle("on", b.dataset.res === res));
|
||
document.querySelectorAll("[data-scale]").forEach(b => b.classList.toggle("on", c.baseD && clampDpi(c.baseD * +b.dataset.scale) === c.curD));
|
||
document.querySelectorAll("[data-font]").forEach(b => b.classList.toggle("on", Math.abs(+b.dataset.font - fs) < 0.001));
|
||
}
|
||
async function applyDisplay(label, change, btn) {
|
||
const i = dispCur(); if (!i) return;
|
||
const res = await act(`${label} (port ${i.port})`, () => api("/api/android/display", { port: i.port, ...change }), btn);
|
||
if (res && res.display) { Object.assign(i, res.display); renderDisplay(); }
|
||
}
|
||
$("dispReload").onclick = loadDisplays;
|
||
$("dispSel").onchange = e => { disp.port = +e.target.value || null; renderDisplay(); };
|
||
$("dispRes").onclick = e => {
|
||
const b = e.target.closest("[data-res]"), i = dispCur(); if (!b || !i) return;
|
||
const c = dispCalc(i); if (!c.phys || !c.physD) return toast("Unknown panel size", true);
|
||
// Keep the user's relative UI scale while changing how many pixels there are.
|
||
if (b.dataset.res === "native") {
|
||
const d = clampDpi(c.physD * c.userScale);
|
||
applyDisplay("Native resolution", { size: "reset", density: d === c.physD ? "reset" : d }, b);
|
||
} else {
|
||
applyDisplay("Sharp resolution", { size: "2560x1440", density: clampDpi(c.physD * (2560 / c.phys[0]) * c.userScale) }, b);
|
||
}
|
||
};
|
||
$("dispScale").onclick = e => {
|
||
const b = e.target.closest("[data-scale]"), i = dispCur(); if (!b || !i) return;
|
||
const c = dispCalc(i); if (!c.baseD) return toast("Unknown default density", true);
|
||
const d = clampDpi(c.baseD * +b.dataset.scale);
|
||
applyDisplay(`UI scale ${b.textContent}`, { density: c.factor === 1 && d === c.physD ? "reset" : d }, b);
|
||
};
|
||
$("dispDpiSet").onclick = () => {
|
||
const d = Number($("dispDpi").value);
|
||
if (!Number.isInteger(d) || d < DPI[0] || d > DPI[1]) return toast(`Density must be a whole number ${DPI[0]}–${DPI[1]}`, true);
|
||
applyDisplay(`Density ${d} dpi`, { density: d }, $("dispDpiSet"));
|
||
};
|
||
$("dispDpi").addEventListener("keydown", e => { if (e.key === "Enter") $("dispDpiSet").click(); });
|
||
$("dispText").onclick = e => {
|
||
const b = e.target.closest("[data-font]"); if (!b) return;
|
||
const f = +b.dataset.font;
|
||
applyDisplay(`Text size ${f}×`, { fontScale: f === 1 ? "reset" : f }, b);
|
||
};
|
||
$("dispReset").onclick = () => applyDisplay("Reset Android display", { size: "reset", density: "reset", fontScale: "reset" }, $("dispReset"));
|
||
loadDisplays();
|
||
|
||
|
||
// ---- compatibility reports: any APK, did it work ----
|
||
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
|
||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||
let repShared = false;
|
||
function setRepHint() {
|
||
$("repHint").textContent = repShared
|
||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||
: telemetry.compat && !telemetry.blocked
|
||
? "Reports change the verdicts you see and are shared with Frame Control's compatibility database (Privacy settings). Any APK can be reported, including ones not on F-Droid."
|
||
: "Reports are saved on this computer and change the verdicts you see. Turn on Share compatibility results in Privacy settings to add them to the shared database. Any APK can be reported, including ones not on F-Droid.";
|
||
}
|
||
const REPORTS_SHOWN = 5;
|
||
let repsAll = false;
|
||
$("repMore").onclick = () => { repsAll = true; loadReports(); };
|
||
async function loadReports() {
|
||
let reps, shared;
|
||
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||
repShared = shared; setRepHint();
|
||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||
$("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
|
||
$("repMore").textContent = `Show all ${reps.length}`;
|
||
$("repList").innerHTML = reps.length ? (repsAll ? reps : reps.slice(0, REPORTS_SHOWN)).map(r => {
|
||
const k = r.rating || r.result;
|
||
return `<div class="item rep-item"><div class="grow">
|
||
<div class="t">${esc(r.name)} <span class="sub">v${esc(String(r.version || "?").replace(/^v/i, ""))}</span></div>
|
||
<div class="s">${esc(r.package)} · ${esc((r.date || "").slice(0, 10))} · ${esc(r.via === "user" ? "you" : r.via || "")}${r.runtime ? " · " + esc(r.runtime) : ""}${r.source ? " · " + esc(r.source) : ""}</div>
|
||
${r.notes ? `<div class="s">${esc(r.notes)}</div>` : ""}</div>
|
||
<span class="vbadge v-${RCLASS[k] || "no"}">${esc(RLABEL[k] || k || "")}</span></div>`;
|
||
}).join("") : `<div class="sub">No reports yet.</div>`;
|
||
}
|
||
function openReport(p = {}) {
|
||
const f = $("repForm");
|
||
f.reset();
|
||
$("repPkg").value = p.package || ""; $("repVer").value = p.version || "";
|
||
$("repLabel").value = p.label || ""; $("repSrc").value = p.source || "";
|
||
$("repMsg").textContent = ""; $("repFileNote").textContent = "";
|
||
const known = new Map();
|
||
androidApps.forEach(a => known.set(a.package, a.label));
|
||
(cat.apps || []).slice(0, 4000).forEach(a => known.has(a.p) || known.set(a.p, a.n));
|
||
$("repPkgs").innerHTML = [...known].map(([p, n]) => `<option value="${esc(p)}">${esc(n)}</option>`).join("");
|
||
$("repDlg").showModal();
|
||
(p.package ? document.querySelector('input[name="repRating"]') : $("repPkg")).focus();
|
||
}
|
||
$("repNew").onclick = () => openReport();
|
||
$("repCancel").onclick = () => $("repDlg").close();
|
||
$("repPkg").onchange = () => {
|
||
const pkg = $("repPkg").value.trim();
|
||
const a = androidApps.find(x => x.package === pkg), c = cat.apps && cat.apps.find(x => x.p === pkg);
|
||
if (!$("repLabel").value) $("repLabel").value = (a && a.label) || (c && c.n) || "";
|
||
if (!$("repVer").value) $("repVer").value = (a && a.version) || (c && c.v) || "";
|
||
};
|
||
$("repPick").onclick = () => $("repFile").click();
|
||
$("repFile").onchange = async () => {
|
||
const file = $("repFile").files[0]; if (!file) return;
|
||
$("repFileNote").textContent = "Reading…";
|
||
try {
|
||
const { apk } = await upload(file, "apkinfo");
|
||
$("repPkg").value = apk.package; $("repVer").value = apk.version; $("repLabel").value = apk.label;
|
||
if (!$("repSrc").value) $("repSrc").value = file.name;
|
||
if (apk.blocker) checkApkAlternatives(apk);
|
||
$("repFileNote").textContent = apk.blocker ? `Note: ${apk.blocker}` : `${apk.package} ${apk.version}`;
|
||
} catch (e) { $("repFileNote").textContent = e.message; }
|
||
$("repFile").value = "";
|
||
};
|
||
$("repForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const rating = document.querySelector('input[name="repRating"]:checked');
|
||
if (!$("repPkg").checkValidity()) return ($("repMsg").textContent = "Enter the package name, e.g. org.example.app");
|
||
if (!rating) return ($("repMsg").textContent = "Choose whether it worked");
|
||
const body = { action: "report", package: $("repPkg").value.trim(), version: $("repVer").value.trim(),
|
||
label: $("repLabel").value.trim(), source: $("repSrc").value.trim(), notes: $("repNotes").value.trim(),
|
||
rating: rating.value, runtime: document.querySelector('input[name="repRuntime"]:checked').value };
|
||
$("repSave").disabled = true;
|
||
try {
|
||
const res = await api("/api/android", body);
|
||
$("repDlg").close(); toast(res.message); log(res.message, "ok");
|
||
loadReports(); loadCatalog();
|
||
} catch (err) { $("repMsg").textContent = err.message; }
|
||
finally { $("repSave").disabled = false; }
|
||
};
|
||
loadReports();
|
||
api("/api/host").then(applyHostWording).catch(() => {});
|
||
|
||
// ---- Steam screenshots from the headset ----
|
||
const shots = { list: [], urls: [] };
|
||
const STEAMVR_APPID = "250820";
|
||
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();
|
||
}
|
||
async function loadShots() {
|
||
$("shotsRefresh").disabled = true;
|
||
try {
|
||
shots.list = (await api("/api/shots")).shots;
|
||
} catch (e) {
|
||
return failed($("shotGrid"), e);
|
||
} finally { $("shotsRefresh").disabled = false; }
|
||
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
|
||
const unsaved = shots.list.filter(s => !s.saved).length;
|
||
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
|
||
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
|
||
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
|
||
<div class="row"><div class="grow">
|
||
<div class="t">${esc(shotApp(s.appid))}</div>
|
||
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
|
||
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||
</div></div>`).join("")
|
||
: `<div class="sub">No screenshots on the Frame yet.</div>`;
|
||
// Thumbnails one at a time over the shared SSH connection.
|
||
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
|
||
const s = shots.list[+img.dataset.shot];
|
||
try {
|
||
const url = URL.createObjectURL(await shotBlob(s.id, true));
|
||
shots.urls.push(url);
|
||
img.src = url;
|
||
} catch (e) { img.alt = "Preview failed"; }
|
||
if (!img.isConnected) return; // the list was reloaded meanwhile
|
||
}
|
||
}
|
||
async function openShot(s) {
|
||
if (live) toggleLive(false);
|
||
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 = false;
|
||
$("view").scrollIntoView({ behavior: "smooth" });
|
||
} catch (e) {
|
||
toast("Couldn't open the screenshot: " + e.message, true);
|
||
} finally {
|
||
if (url) URL.revokeObjectURL(url);
|
||
$("viewer").classList.remove("busy");
|
||
}
|
||
}
|
||
async function saveShots(list, btn) {
|
||
if (!list.length) return;
|
||
if (HOST.mobile && savesToDevice()) {
|
||
return act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||
async () => saveToDevice(await Promise.all(list.map(async s => ({ blob: await shotBlob(s.id, false), name: s.file })))), btn);
|
||
}
|
||
const res = await act(`Save ${list.length} screenshot${list.length === 1 ? "" : "s"}`,
|
||
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
|
||
if (res) loadShots();
|
||
}
|
||
$("shotGrid").onclick = e => {
|
||
const img = e.target.closest("img[data-shot]");
|
||
if (img) return openShot(shots.list[+img.dataset.shot]);
|
||
const b = e.target.closest("[data-shot-save]");
|
||
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
|
||
};
|
||
$("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);
|
||
|
||
// ---- Panel switcher: our UI over SteamVR's panel API ----
|
||
let panelSeq = 0;
|
||
async function loadPanels() {
|
||
const seq = ++panelSeq;
|
||
try {
|
||
const s = await api("/api/panels", {action: "list"});
|
||
if (seq !== panelSeq) return;
|
||
$("panelList").innerHTML = s.panels.length ? s.panels.map(p =>
|
||
`<div class="item"><div class="grow"><div class="t">${esc(p.title)}</div></div>
|
||
<button class="small" data-panel="${esc(p.key)}">Show</button></div>`).join("")
|
||
: '<div class="sub">No open panels.</div>';
|
||
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
|
||
}
|
||
$("panelsRefresh").onclick = loadPanels;
|
||
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
|
||
() => api("/api/panels", {action: "open"}), e.currentTarget);
|
||
$("panelList").onclick = e => {
|
||
const b = e.target.closest("[data-panel]"); if (!b) return;
|
||
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
|
||
};
|
||
|
||
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
||
let mvSeq = 0, mvTimer = 0;
|
||
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
||
// How a live stream is doing: frames shown, Mac-to-headset delay, bitrate, and
|
||
// whether the network made it step down (fewer frames, then fewer pixels).
|
||
function mvHealth(st) {
|
||
if (!st) return "";
|
||
const x = st.stats || {}, c = st.controller || {}, bits = [];
|
||
if (x.fps) bits.push(`${Math.round(x.fps)} fps`);
|
||
if (x.total && x.total.p50 != null) bits.push(`${Math.round(x.total.p50)} ms`);
|
||
if (x.mbps) bits.push(`${x.mbps} Mbit/s`);
|
||
if (c.tier > 0) bits.push(`weak link: ${c.fps} fps${c.scale < 1 ? `, ${Math.round(c.scale * 100)}% size` : ""}`);
|
||
return bits.length ? " · " + esc(bits.join(" · ")) : "";
|
||
}
|
||
function mvRow(src, title, sub, w, h, streaming, st) {
|
||
return `<div class="item"><div class="grow"><div class="t">${esc(title)}</div>
|
||
<div class="s">${esc(sub)}${streaming ? ' · <span class="mv-live">in the headset</span>' + mvHealth(st) : ""}</div></div>
|
||
${streaming ? `<button class="small" data-mv="stop" data-src="${esc(streaming)}">Stop</button>`
|
||
: `<button class="small action" data-mv="show" data-src="${esc(src)}" data-title="${esc(title)}" data-w="${w}" data-h="${h}">Show</button>`}
|
||
</div>`;
|
||
}
|
||
async function loadMacView(restart) {
|
||
const seq = ++mvSeq;
|
||
let s, err;
|
||
try { s = await api("/api/macview" + (restart ? "?restart=1" : "")); } catch (e) { err = e; }
|
||
if (seq !== mvSeq) return;
|
||
const box = $("macview");
|
||
if (err) { box.hidden = false; return failed($("mvWindows"), err); }
|
||
if (!s.available) { box.hidden = true; return; }
|
||
box.hidden = false;
|
||
const live = new Set((s.streams || []).map(x => x.src));
|
||
const byStream = src => (s.streams || []).find(x => x.src === src);
|
||
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
|
||
const perm = [];
|
||
if (!s.screen) perm.push(`Frame Control needs <b>Screen Recording</b> permission to show your windows.`);
|
||
if (!s.accessibility) perm.push(`Allow <b>Accessibility</b> too, so clicks and keys from the headset reach the Mac.`);
|
||
$("mvPerm").hidden = !perm.length;
|
||
$("mvPerm").innerHTML = perm.join(" ") + `<div class="row"><button class="small action" data-mv="perm">Allow…</button>
|
||
<span class="sub">Then press Refresh. macOS may ask you to reopen Frame Control.</span></div>`;
|
||
const liveAs = src => live.has(src) ? src : src.startsWith("window:") && live.has("separate:" + src.slice(7)) ? "separate:" + src.slice(7) : "";
|
||
$("mvDisplays").innerHTML = (s.displays || []).map(d =>
|
||
mvRow(d.src, `Whole screen: ${d.name}`, `${d.w}×${d.h}${d.main ? " · main display" : ""}`, d.w, d.h, liveAs(d.src), byStream(liveAs(d.src)))).join("");
|
||
const wins = s.windows || [];
|
||
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
|
||
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
|
||
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
|
||
: `<div class="sub">No windows open on this Mac's current desktop.</div>`;
|
||
// Keep the numbers fresh while something is in the headset and the card is on screen.
|
||
clearTimeout(mvTimer);
|
||
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
|
||
if (live.size) mvTimer = setTimeout(tick, 3000);
|
||
}
|
||
$("mvRefresh").onclick = () => loadMacView(true);
|
||
$("mvSeparate").checked = localStorage.getItem("mvSeparate") !== "0";
|
||
$("mvSeparate").onchange = e => localStorage.setItem("mvSeparate", e.target.checked ? "1" : "0");
|
||
$("macview").onclick = async e => {
|
||
const b = e.target.closest("[data-mv]"); if (!b) return;
|
||
const src = b.dataset.src;
|
||
if (b.dataset.mv === "show") {
|
||
const as = $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
|
||
await act(`Show ${b.dataset.title} in the headset`, async () => {
|
||
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
|
||
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
|
||
return { message: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`, ...r };
|
||
}, b);
|
||
} else if (b.dataset.mv === "stop") {
|
||
await act("Stop showing it", () => api("/api/macview", { action: "stop", src }), b);
|
||
} else if (b.dataset.mv === "stopall") {
|
||
await act("Stop everything in the headset", () => api("/api/macview", { action: "stop" }), b);
|
||
} else if (b.dataset.mv === "perm") {
|
||
await act("Ask macOS for permission", () => api("/api/macview", { action: "permissions" }), b);
|
||
}
|
||
setTimeout(loadMacView, 800);
|
||
};
|
||
|
||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||
const PAGES = ["home", "games", "android", "tools"];
|
||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||
let page = "home";
|
||
function showPage() {
|
||
const id = location.hash.slice(1);
|
||
const next = PAGES.includes(id) ? id : SECTION_PAGE[id] || "home";
|
||
if (next !== page && page === "home" && live) toggleLive(false); // don't stream video nobody is watching
|
||
page = next;
|
||
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
|
||
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||
const section = !PAGES.includes(id) && id && $(id);
|
||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||
if (page === "tools") { loadMacView(); loadPanels(); }
|
||
}
|
||
window.addEventListener("hashchange", showPage);
|
||
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||
|
||
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
|
||
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
|
||
const PAD_STATES = {
|
||
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
|
||
pairing: "Pairing with KDE Connect…", ready: "On",
|
||
};
|
||
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
|
||
const PAD_SPECIAL = { Backspace: 1, Tab: 2, ArrowLeft: 4, ArrowUp: 5, ArrowRight: 6, ArrowDown: 7, PageUp: 8,
|
||
PageDown: 9, Home: 10, End: 11, Enter: 12, Delete: 13, Escape: 14,
|
||
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, 21 + i])) };
|
||
const IS_MAC = /^Mac/.test(navigator.platform);
|
||
const TOUCH = matchMedia("(hover: none)").matches;
|
||
|
||
function padShow(status) {
|
||
pad.state = status.state || "off";
|
||
const ready = pad.state === "ready";
|
||
const busy = !ready && pad.state !== "off" && pad.state !== "error";
|
||
$("padState").textContent = pad.state === "error" ? "" : PAD_STATES[pad.state] || "";
|
||
$("padOn").hidden = ready || busy;
|
||
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
|
||
$("padArea").classList.toggle("off", !ready);
|
||
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
|
||
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
|
||
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
|
||
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
|
||
: "Click here to use your mouse and keyboard on the Frame (Esc to stop), or drag like a trackpad.";
|
||
clearTimeout(pad.poll);
|
||
if (ready && pad.queue.length) padFlush();
|
||
if (busy) pad.poll = setTimeout(async () => padShow(await api("/api/input").catch(e => ({ state: "error", message: e.message }))), 1000);
|
||
}
|
||
async function padStart() {
|
||
localStorage.padUsed = "1"; // from now on it turns on with the page
|
||
padShow({ state: "starting" });
|
||
padShow(await api("/api/input?start=1").catch(e => ({ state: "error", message: e.message })));
|
||
}
|
||
$("padOn").onclick = padStart;
|
||
|
||
// Queue events and send them one request at a time, merging pointer moves made meanwhile.
|
||
const isMove = e => Object.keys(e).every(k => k === "dx" || k === "dy");
|
||
function padSend(event) {
|
||
// While it reconnects, keys and clicks wait in the queue; pointer moves would be stale.
|
||
// A button release is always kept, so a drag never leaves the button held on the Frame.
|
||
const room = pad.queue.length < 200 || event.singlerelease;
|
||
const reconnecting = ["starting", "pairing", "installing"].includes(pad.state) && room;
|
||
// Even after an error: a button pressed on the Frame must be let go once it's back.
|
||
const release = event.singlerelease && pad.state !== "off";
|
||
if (pad.state !== "ready" && !(reconnecting && !isMove(event)) && !release) return;
|
||
const last = pad.queue[pad.queue.length - 1];
|
||
if (last && isMove(last) && isMove(event)) { last.dx = (last.dx || 0) + (event.dx || 0); last.dy = (last.dy || 0) + (event.dy || 0); }
|
||
else pad.queue.push(event);
|
||
padFlush();
|
||
}
|
||
// Events that didn't reach the Frame (the link had dropped, and is reconnecting): keep
|
||
// the keys and clicks for when it's back, but not pointer moves, which would be stale.
|
||
// Button releases are kept whatever the queue's length.
|
||
function padKeep(events) {
|
||
pad.queue = [...events, ...pad.queue].filter(e => !isMove(e)).filter((e, i) => i < 200 || e.singlerelease);
|
||
}
|
||
async function padFlush() {
|
||
if (pad.sending || !pad.queue.length || pad.state !== "ready") return;
|
||
pad.sending = true;
|
||
const events = pad.queue.splice(0, 200);
|
||
try {
|
||
const status = await api("/api/input", { events });
|
||
if (!status.sent) padKeep(events);
|
||
if (status.state !== "ready") padShow(status);
|
||
} catch (e) {
|
||
padKeep(events);
|
||
padShow({ state: "error", message: e.message });
|
||
} finally {
|
||
pad.sending = false;
|
||
if (pad.queue.length) padFlush();
|
||
}
|
||
}
|
||
function padKey(e) {
|
||
const mods = { ...(e.ctrlKey || (IS_MAC && e.metaKey) ? { ctrl: true } : {}), ...(e.altKey ? { alt: true } : {}),
|
||
...(!IS_MAC && e.metaKey ? { super: true } : {}) };
|
||
if (PAD_SPECIAL[e.key]) return { specialKey: PAD_SPECIAL[e.key], ...mods, ...(e.shiftKey ? { shift: true } : {}) };
|
||
if (e.key.length === 1) return { key: e.key, ...mods }; // already shifted, so no shift flag
|
||
return null;
|
||
}
|
||
|
||
// Touch: one finger moves, a quick tap clicks; two fingers scroll, or right-click when tapped.
|
||
const PAD_SPEED = 1.6, PAD_SCROLL_STEP = 18;
|
||
const touches = new Map();
|
||
let gesture = null;
|
||
$("padArea").addEventListener("pointerdown", e => {
|
||
if (pad.state === "off" || pad.state === "error") { padStart(); return; }
|
||
if (pad.state !== "ready") return;
|
||
if (e.pointerType === "mouse") {
|
||
if (pad.captured) return; // mousedown and mouseup below send the clicks
|
||
// Lock the pointer to the pad and pass the whole mouse and keyboard through; where
|
||
// that isn't allowed, the mouse drags like a finger on a trackpad instead.
|
||
if (e.button === 0 && !pad.noLock && $("padArea").requestPointerLock) { $("padArea").requestPointerLock(); return; }
|
||
// Only the left button drags and taps like a finger; the others are clicks of their own.
|
||
if (e.button === 1 || e.button === 2) { padSend(e.button === 2 ? { rightclick: true } : { middleclick: true }); return; }
|
||
if (e.button !== 0) return;
|
||
}
|
||
try { $("padArea").setPointerCapture(e.pointerId); } catch {} // keeps the drag if the finger leaves the pad
|
||
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||
if (touches.size === 1) gesture = { start: performance.now(), moved: 0, fingers: 1, scroll: 0 };
|
||
else if (gesture) gesture.fingers = Math.max(gesture.fingers, touches.size);
|
||
e.preventDefault();
|
||
});
|
||
$("padArea").addEventListener("pointermove", e => {
|
||
if (pad.captured) { padSend({ dx: e.movementX, dy: e.movementY }); return; }
|
||
const t = touches.get(e.pointerId);
|
||
if (!t || !gesture) return;
|
||
const dx = e.clientX - t.x, dy = e.clientY - t.y;
|
||
t.x = e.clientX; t.y = e.clientY;
|
||
gesture.moved += Math.abs(dx) + Math.abs(dy);
|
||
if (touches.size >= 2) {
|
||
// Scroll with the lead finger only, a notch per step, like a laptop trackpad (content follows the fingers).
|
||
if (e.pointerId !== touches.keys().next().value) return;
|
||
gesture.scroll += dy;
|
||
while (Math.abs(gesture.scroll) >= PAD_SCROLL_STEP) {
|
||
const up = gesture.scroll > 0;
|
||
padSend({ scroll: true, dy: up ? 1 : -1 });
|
||
gesture.scroll -= up ? PAD_SCROLL_STEP : -PAD_SCROLL_STEP;
|
||
}
|
||
} else if (gesture.fingers === 1) {
|
||
padSend({ dx: dx * PAD_SPEED, dy: dy * PAD_SPEED });
|
||
}
|
||
});
|
||
function padLift(e) {
|
||
if (!touches.delete(e.pointerId) || touches.size || !gesture) return;
|
||
const quick = performance.now() - gesture.start < 250 && gesture.moved < 10;
|
||
if (quick) padSend(gesture.fingers >= 2 ? { rightclick: true } : { singleclick: true });
|
||
gesture = null;
|
||
}
|
||
$("padArea").addEventListener("pointerup", padLift);
|
||
$("padArea").addEventListener("pointercancel", e => { touches.delete(e.pointerId); if (!touches.size) gesture = null; });
|
||
|
||
// A computer's mouse and keyboard: captured while the pointer is locked to the pad.
|
||
document.addEventListener("pointerlockerror", () => { pad.noLock = true; });
|
||
document.addEventListener("pointerlockchange", () => {
|
||
pad.captured = document.pointerLockElement === $("padArea");
|
||
window.frameApp?.captureKeys?.(pad.captured); // the desktop app's menu shortcuts go to the Frame too
|
||
// Esc or switching windows mid-drag: let go of the button on the Frame as well.
|
||
if (!pad.captured && pad.held) { padSend({ singlerelease: true }); pad.held = false; }
|
||
$("padArea").classList.toggle("captured", pad.captured);
|
||
padShow({ state: pad.state });
|
||
});
|
||
$("padArea").addEventListener("mousedown", e => {
|
||
if (!pad.captured) return;
|
||
if (e.button === 0) { padSend({ singlehold: true }); pad.held = true; }
|
||
else if (e.button === 1) padSend({ middleclick: true });
|
||
else if (e.button === 2) padSend({ rightclick: true });
|
||
});
|
||
$("padArea").addEventListener("mouseup", e => { if (pad.held && e.button === 0) { padSend({ singlerelease: true }); pad.held = false; } });
|
||
$("padArea").addEventListener("contextmenu", e => e.preventDefault());
|
||
$("padArea").addEventListener("wheel", e => {
|
||
if (pad.state !== "ready") return;
|
||
e.preventDefault();
|
||
if (e.deltaY) padSend({ scroll: true, dy: e.deltaY > 0 ? -1 : 1 });
|
||
}, { passive: false });
|
||
// Capture phase, so the page's own shortcuts (tabs 1-4, R, zoom) don't also fire.
|
||
window.addEventListener("keydown", e => {
|
||
if (!pad.captured) return;
|
||
e.preventDefault(); e.stopImmediatePropagation();
|
||
const event = padKey(e);
|
||
if (event) padSend(event);
|
||
}, true);
|
||
|
||
// The text field: each keystroke goes to the Frame. While focused it holds one space,
|
||
// so Backspace always has something to delete; empty otherwise, to show its prompt.
|
||
const padType = $("padType");
|
||
padType.addEventListener("beforeinput", e => {
|
||
if (e.inputType === "insertCompositionText") return; // IME composing; sent on compositionend
|
||
e.preventDefault();
|
||
if (pad.state === "off" || pad.state === "error") padStart(); // what's typed meanwhile waits in the queue
|
||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||
if (/^insert(Text|ReplacementText|FromPaste|FromDrop)$/.test(e.inputType) && text) padText(text);
|
||
else if (e.inputType === "deleteContentBackward") padSend({ specialKey: 1 });
|
||
else if (e.inputType === "deleteWordBackward") padSend({ specialKey: 1, ctrl: true }); // Ctrl+Backspace: a word, on Linux
|
||
else if (e.inputType === "deleteContentForward") padSend({ specialKey: 13 });
|
||
else if (e.inputType === "deleteWordForward") padSend({ specialKey: 13, ctrl: true });
|
||
else if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") padSend({ specialKey: 12 });
|
||
});
|
||
padType.addEventListener("compositionend", e => { if (e.data) padText(e.data); padType.value = " "; padCaret(); });
|
||
// Text in pieces the server takes (500 characters), with Enter for each line break.
|
||
function padText(text) {
|
||
text.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
||
if (i) padSend({ specialKey: 12 });
|
||
const chars = Array.from(line); // whole characters, so an emoji isn't cut in two
|
||
for (let at = 0; at < chars.length; at += 500) padSend({ key: chars.slice(at, at + 500).join("") });
|
||
});
|
||
}
|
||
padType.addEventListener("keydown", e => {
|
||
const special = PAD_SPECIAL[e.key];
|
||
// Text and deletions arrive as beforeinput; this takes the keys that don't edit the field.
|
||
if (!special || e.key === "Backspace" || e.key === "Delete" || e.isComposing) return;
|
||
e.preventDefault();
|
||
padSend(padKey(e));
|
||
});
|
||
// The caret stays after the space, so Backspace always has something to delete.
|
||
const padCaret = () => requestAnimationFrame(() => padType.setSelectionRange(1, 1));
|
||
padType.addEventListener("focus", () => { padType.value = " "; padCaret(); if (pad.state === "off") padStart(); });
|
||
padType.addEventListener("blur", () => { padType.value = ""; });
|
||
padType.addEventListener("input", e => {
|
||
if (e.isComposing) return; // an input method is still composing; compositionend sends it
|
||
if (padType.value !== " ") padType.value = " ";
|
||
padCaret();
|
||
});
|
||
padType.addEventListener("click", padCaret);
|
||
document.querySelector("#pad .pad-keys").addEventListener("click", e => {
|
||
const b = e.target.closest("button");
|
||
if (!b) return;
|
||
if (b.dataset.padClick) padSend({ [b.dataset.padClick]: true });
|
||
else if (b.dataset.padKey) padSend({ specialKey: +b.dataset.padKey });
|
||
});
|
||
api("/api/input").then(s => s.state === "off" && localStorage.padUsed ? padStart() : padShow(s), () => padShow({ state: "off" }));
|
||
showPage();
|
||
document.addEventListener("keydown", e => {
|
||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||
const n = PAGES[+e.key - 1];
|
||
if (n) location.hash = n;
|
||
});
|
||
|
||
// ---- after an APK install: test it, so the compatibility database learns whether it runs ----
|
||
async function offerTest(m) {
|
||
if (!m.package || !confirm(`${m.label || m.package} is installed. Test it now?\n\nIt opens in the headset for about 20 seconds `
|
||
+ "and records whether it stays up.")) return;
|
||
toast(`Testing ${m.label || m.package}: launching it and watching for 20 s…`);
|
||
await act(`Test ${m.label || m.package}`, () => api("/api/android", { action: "probe", package: m.package }));
|
||
loadReports();
|
||
}
|
||
|
||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||
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) 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"; };
|
||
loadTelemetry();
|
||
function pageEvent(event, properties) {
|
||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||
}
|
||
// Which tabs get used: once per tab per session.
|
||
const tabsSeen = new Set();
|
||
document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => {
|
||
const tab = a.getAttribute("href").slice(1);
|
||
if (!tabsSeen.has(tab)) { tabsSeen.add(tab); pageEvent("tab_viewed", { tab }); }
|
||
}));
|
||
|
||
// ---- report a problem (ui/frame_report.py): sent privately to PostHog, with diagnostics ----
|
||
const bug = { preview: "" };
|
||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||
function bugReportText() {
|
||
const contact = $("bugContact").value.trim();
|
||
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||
return { title: $("bugTitleIn").value.trim(), body };
|
||
}
|
||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||
async function loadBugPreview() {
|
||
if (!$("bugDiag").checked) { bug.preview = ""; $("bugDiagText").textContent = "Nothing: diagnostics are off."; return; }
|
||
$("bugDiagText").textContent = "Loading…";
|
||
try { bug.preview = (await api("/api/report/preview", { activity: activityLines(), includeLogs: $("bugLogs").checked })).text; }
|
||
catch (e) { bug.preview = ""; $("bugDiagText").textContent = `Couldn't collect diagnostics: ${e.message}`; return; }
|
||
$("bugDiagText").textContent = bug.preview;
|
||
}
|
||
function openBugReport() {
|
||
$("bugForm").reset();
|
||
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
||
$("bugCancel").textContent = "Cancel";
|
||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||
$("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;
|
||
$("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,
|
||
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
||
$("bugCancel").textContent = "Close";
|
||
log(res.message, "ok");
|
||
} catch (err) {
|
||
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
||
$("bugSend").disabled = false;
|
||
}
|
||
};
|
||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||
|
||
// ---- updates (the desktop app only: app/updater.js) ----
|
||
const upd = { dismissed: false, offered: null };
|
||
function renderUpdate(s) {
|
||
if (!s) return;
|
||
$("appVersion").textContent = `Version ${s.current}`;
|
||
const r = s.latest;
|
||
const show = r && ["available", "downloading", "ready", "error"].includes(s.status) && !upd.dismissed
|
||
&& !(s.status === "error" && !r);
|
||
$("updateBar").hidden = !show;
|
||
if (!show) return;
|
||
if (s.status === "available" && upd.offered !== r.version) { upd.offered = r.version; pageEvent("update_offered", { to_version: r.version }); }
|
||
const busy = s.status === "downloading" || s.status === "ready";
|
||
$("updateText").innerHTML = s.status === "error"
|
||
? `Updating to ${esc(r.version)} didn't work: ${esc(s.error || "unknown error")}`
|
||
: busy ? `Downloading Frame Control ${esc(r.version)}… It restarts when it's ready.`
|
||
: `<b>Frame Control ${esc(r.version)} is available.</b> You have ${esc(s.current)}.`
|
||
+ (s.canInstall ? "" : ` ${esc(s.why ? "It can't update itself here (" + s.why + ")," : "")} download it from the release page.`);
|
||
$("updateProg").hidden = !busy;
|
||
$("updateProg").firstElementChild.style.width = Math.round((s.progress || 0) * 100) + "%";
|
||
$("updateGo").textContent = s.canInstall ? (s.status === "error" ? "Try again" : "Update and restart") : "Open release page";
|
||
$("updateGo").disabled = busy; $("updateLater").hidden = busy;
|
||
}
|
||
if (window.frameApp && window.frameApp.update) {
|
||
window.frameApp.update.onState(renderUpdate);
|
||
window.frameApp.update.get().then(renderUpdate);
|
||
$("updateCheck").hidden = false;
|
||
$("updateCheck").onclick = async () => {
|
||
const btn = $("updateCheck");
|
||
btn.disabled = true;
|
||
let s;
|
||
try { s = await window.frameApp.update.check(); } finally { btn.disabled = false; }
|
||
upd.dismissed = false; renderUpdate(s);
|
||
if (s && s.status === "none") toast(`You have the newest version (${s.current})`);
|
||
if (s && s.status === "check-failed") toast(`Couldn't check for updates: ${s.error}`, true);
|
||
};
|
||
$("updateGo").onclick = () => {
|
||
pageEvent("update_started", { to_version: upd.offered || "" });
|
||
window.frameApp.update.install();
|
||
};
|
||
$("updateLater").onclick = () => { upd.dismissed = true; $("updateBar").hidden = true; };
|
||
$("updateNotes").onclick = async () => {
|
||
const s = await window.frameApp.update.get();
|
||
if (s && s.latest) window.open(s.latest.page, "_blank");
|
||
};
|
||
}
|
||
|
||
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||
// The app passes each link here. The server checks it and reads the manifest;
|
||
// nothing downloads until the user clicks Install in this dialog.
|
||
const wi = { queue: [], open: false, gen: 0, plan: null, job: null, starting: false };
|
||
function wiSize(n) {
|
||
if (n == null) return "Not given";
|
||
return n >= 1e9 ? gb(n) : n >= 1e6 ? (n / 1e6).toFixed(1) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
|
||
}
|
||
function wiFacts(rows) { $("wiFacts").innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join(""); }
|
||
function wiButtons(cancel, go) {
|
||
$("wiCancel").textContent = cancel[0]; $("wiCancel").disabled = !cancel[1];
|
||
$("wiGo").hidden = !go; $("wiGo").disabled = go !== "on";
|
||
}
|
||
async function wiNext() {
|
||
if (wi.open || !wi.queue.length) return;
|
||
const req = wi.queue.shift(), gen = ++wi.gen;
|
||
wi.open = true; wi.plan = null; wi.job = null;
|
||
let host = "";
|
||
try { host = new URL(req.target).hostname; } catch {}
|
||
wiFacts([["From", host], [req.kind === "manifest" ? "Manifest" : "File", req.target]]);
|
||
$("wiProg").hidden = true; $("wiMsg").textContent = "Checking the link…";
|
||
wiButtons(["Cancel", true], "off");
|
||
$("wiDlg").showModal();
|
||
log(`Install link from ${host}`);
|
||
try {
|
||
const p = await api("/api/webinstall/check", { [req.kind]: req.target });
|
||
if (gen !== wi.gen) return;
|
||
wi.plan = p;
|
||
const from = p.host === p.linkHost ? p.host : `${p.linkHost} (file on ${p.host})`;
|
||
wiFacts([["Title", p.name], ["From", from], ["File", p.file], ["Type", p.kindLabel], ["Size", wiSize(p.size)],
|
||
["SHA-256", p.sha256 ? "Given; checked after downloading" : "Not given; the download can't be checked"]]);
|
||
$("wiMsg").textContent = "";
|
||
wiButtons(["Cancel", true], "on");
|
||
$("wiGo").focus();
|
||
} catch (e) {
|
||
if (gen !== wi.gen) return;
|
||
$("wiMsg").textContent = e.message;
|
||
wiButtons(["Close", true], null);
|
||
log(`Install link refused: ${e.message}`, "e");
|
||
}
|
||
}
|
||
async function wiPoll(gen) {
|
||
if (gen !== wi.gen || !wi.job) return;
|
||
let j;
|
||
try {
|
||
j = await api(`/api/webinstall/job?id=${encodeURIComponent(wi.job)}`);
|
||
} catch (e) {
|
||
$("wiMsg").textContent = e.message;
|
||
return setTimeout(() => wiPoll(gen), 1500);
|
||
}
|
||
if (gen !== wi.gen) return;
|
||
const bar = $("wiProg").firstElementChild;
|
||
$("wiProg").hidden = false;
|
||
if (j.phase === "download") {
|
||
bar.style.width = j.total ? (100 * j.done / j.total) + "%" : "0";
|
||
$("wiMsg").textContent = `Downloading ${wiSize(j.done)}` + (j.total ? ` of ${wiSize(j.total)}` : "");
|
||
wiButtons(["Stop download", true], "off");
|
||
} else if (j.phase === "install") {
|
||
bar.style.width = "100%";
|
||
$("wiMsg").textContent = j.detail || "Installing on the Frame…";
|
||
wiButtons(["Stop download", false], "off");
|
||
} else {
|
||
wi.job = null;
|
||
$("wiProg").hidden = true;
|
||
if (j.phase === "done") {
|
||
log(j.message, "ok"); toast(j.message);
|
||
$("wiDlg").close();
|
||
loadAndroid(); loadTitles(); refresh();
|
||
} else {
|
||
$("wiMsg").textContent = j.error;
|
||
log(`Install from link failed: ${j.error}`, "e"); toast(`Install failed: ${j.error}`, true);
|
||
wiButtons(["Close", true], null);
|
||
}
|
||
return;
|
||
}
|
||
setTimeout(() => wiPoll(gen), 500);
|
||
}
|
||
$("wiGo").onclick = async () => {
|
||
const p = wi.plan, gen = wi.gen;
|
||
if (!p) return;
|
||
wi.plan = null; // one click, one install
|
||
wiButtons(["Cancel", false], "off");
|
||
$("wiMsg").textContent = "Starting…";
|
||
wi.starting = true; // the dialog stays open until the job is known
|
||
try {
|
||
wi.job = (await api("/api/webinstall/start", { id: p.id })).job;
|
||
log(`Installing ${p.name} from ${p.host}…`);
|
||
wiPoll(gen);
|
||
} catch (e) {
|
||
$("wiMsg").textContent = e.message;
|
||
wiButtons(["Close", true], null);
|
||
} finally { wi.starting = false; }
|
||
};
|
||
$("wiCancel").onclick = async () => {
|
||
if (!wi.job) return $("wiDlg").close();
|
||
try { await api("/api/webinstall/cancel", { job: wi.job }); } catch (e) { $("wiMsg").textContent = e.message; }
|
||
};
|
||
// Escape doesn't close the dialog while an install runs; it keeps showing progress.
|
||
$("wiDlg").addEventListener("cancel", e => { if (wi.job || wi.starting) e.preventDefault(); });
|
||
$("wiDlg").addEventListener("close", () => { wi.gen++; wi.open = false; wi.plan = null; wi.job = null; setTimeout(wiNext); });
|
||
if (window.frameApp && window.frameApp.onInstallLink) {
|
||
window.frameApp.onInstallLink(req => { if (wi.queue.length < 5) wi.queue.push(req); wiNext(); });
|
||
}
|
||
|
||
setView("headset");
|
||
refresh().then(loadShots); // after status, so app names resolve
|
||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||
</script>
|
||
</body>
|
||
</html>
|