mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 00:00:21 +02:00
The keyboard and trackpad no longer fetch KDE Connect from Valve's package repository on the Frame. The desktop apps and the iPhone app's Frame bundle carry Valve's arm64 build of kdeconnect 24.02.2-1 and the five libraries it links (kcontacts, kpeople, modemmanager-qt, pulseaudio-qt, libfakekey), pinned by SHA-256 in frame/kdeconnect/packages.json and downloaded at build time from the kdeconnect-frame-24.02.2-1 release, which also holds Valve's complete source package for each. On first use the computer copies them over its SSH connection (the iPhone bundle already has them on the Frame); the agent checks each SHA-256, unpacks them and stamps which build it is, so later starts copy nothing. No internet on the Frame, 3.6 MB instead of 8 MB, 18 MB unpacked instead of 82 MB (ModemManager and friends were packaging-only dependencies). GPL/LGPL compliance: frame/kdeconnect/NOTICE.md names each exact version, licence and source; per-project licence texts in frame/kdeconnect/LICENSES; THIRD_PARTY_NOTICES.md; an About and licences dialog in the app. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2591 lines
150 KiB
HTML
2591 lines
150 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 ---- */
|
||
.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; }
|
||
.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; }
|
||
.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; }
|
||
.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; }
|
||
#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); }
|
||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||
#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 ---- */
|
||
.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] { 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; }
|
||
.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="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="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="gamescope's 2D layer: the desktop panel and Steam's flat UI">Desktop panel</button>
|
||
</div>
|
||
<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>
|
||
<button class="action" id="shotBtn">Capture</button>
|
||
<button id="liveBtn" title="Keep updating: video of the headset view, or repeated captures of the desktop panel">Live</button>
|
||
<button id="saveBtn" disabled>Save</button>
|
||
</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="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="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">
|
||
<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>
|
||
<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="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>
|
||
</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="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: "gamescope's 2D layer: the desktop panel and Steam's flat UI, without the room or VR scene.",
|
||
};
|
||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", 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;
|
||
}
|
||
|
||
// ---- 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";
|
||
$("viewHint").textContent = HINTS[v];
|
||
if (live && changed) { toggleLive(false); toggleLive(true); } // video for the headset, captures for the panel
|
||
}
|
||
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);
|
||
$("fullBtn").onclick = () => document.fullscreenElement ? document.exitFullscreen() : $("viewer").requestFullscreen();
|
||
document.addEventListener("fullscreenchange", () => setZoom(1));
|
||
$("viewer").addEventListener("wheel", e => {
|
||
if ($("canvas").hidden) 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 => {
|
||
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) 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" || $("canvas").hidden || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) 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 {
|
||
const r = await fetch(view === "headset" ? "/api/screenshot?view=headset" : "/api/screenshot",
|
||
{ 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();
|
||
$("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;
|
||
async function liveLoop() {
|
||
if (!live) return;
|
||
liveFailures = (await capture()) ? 0 : liveFailures + 1;
|
||
if (liveFailures >= 5) {
|
||
toggleLive(false);
|
||
toast("Live stopped after 5 failed captures. Is the headset on?", true);
|
||
return;
|
||
}
|
||
if (live) liveTimer = setTimeout(liveLoop, view === "headset" ? 300 : 700);
|
||
}
|
||
function toggleLive(on) {
|
||
live = on;
|
||
liveFailures = 0;
|
||
$("liveBtn").classList.toggle("on", live);
|
||
$("liveBadge").hidden = !live;
|
||
clearTimeout(liveTimer);
|
||
stopVideo();
|
||
if (!live) return;
|
||
if (view === "headset" && "VideoDecoder" in window) {
|
||
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;
|
||
$("viewer").classList.remove("busy");
|
||
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
|
||
$("srcBadge").hidden = false; $("srcBadge").textContent = "Headset view · 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 r = await fetch(`/api/stream?${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 = "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));
|
||
|
||
// ---- 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);
|
||
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);
|
||
});
|
||
}
|
||
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 (!$("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");
|
||
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||
}
|
||
$("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>`;
|
||
await done;
|
||
} 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" };
|
||
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; }
|
||
$("repHint").textContent = shared
|
||
? "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."
|
||
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||
$("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 };
|
||
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);
|
||
|
||
// ---- 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" };
|
||
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);
|
||
}
|
||
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;
|
||
});
|
||
|
||
// ---- 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>
|