mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Searches no longer wait for (or fail on) a refresh of an expired index; the store notes which sources show saved listings. A failed refresh keeps the old index and is retried after 10 minutes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2616 lines
170 KiB
HTML
2616 lines
170 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=search], input[type=url], 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 { 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 { background: rgba(0,0,0,.55); }
|
||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||
#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], .cat-tools input[type=search] { flex: 1 1 240px; width: auto; }
|
||
.cat-tools select, .source-offer select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||
padding: 9px 10px; font: inherit; font-size: 13px; max-width: 220px; }
|
||
.vchips { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0 14px; }
|
||
.vchip { height: 26px; font-size: 12px; padding: 0 10px; opacity: .55; }
|
||
.vchip[aria-pressed=true] { opacity: 1; box-shadow: inset 0 0 0 1px currentColor; }
|
||
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||
.cat-card { background: rgba(0,0,0,.2); border-radius: 3px; padding: 10px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||
.cat-card .top { display: flex; gap: 10px; align-items: flex-start; }
|
||
.cat-card .t { color: var(--bright); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; }
|
||
.cat-card .s { color: var(--muted); font-size: 12px; }
|
||
.cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; }
|
||
.cat-card .row { margin-top: auto; }
|
||
.v-works, .v-likely { 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; } }
|
||
/* ---- 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, .source-offer select { max-width: none; flex: 1 1 100%; }
|
||
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||
body.typing nav { display: none; }
|
||
}
|
||
/* ---- Discover: artwork first, details when you need them ---- */
|
||
.sr-only { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; }
|
||
#androidLibrary { grid-template-columns:1fr; }
|
||
#androidLibrary .and-col { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr)); align-items:start; }
|
||
.store-tabs { display:flex; align-items:center; gap:8px; margin-bottom:20px; border-bottom:1px solid #ffffff12; padding-bottom:14px; }
|
||
.store-tabs > button { background:transparent; color:var(--muted); border-radius:6px; }
|
||
.store-tabs > button.on { color:var(--bright); background:#ffffff0e; }
|
||
.store-heading h1 { font-size:clamp(28px,3.2vw,38px); letter-spacing:-1.2px; line-height:1.15; margin:8px 0 10px; font-weight:700; color:var(--bright); }
|
||
.store-heading p, .sources-intro p { color:#a8b5c2; margin:0 0 24px; font-size:15px; }
|
||
.store-eyebrow { color:var(--link); font-size:11px; text-transform:uppercase; font-weight:700; letter-spacing:1.8px; }
|
||
.store-toolbar { display:grid; grid-template-columns:minmax(0,1fr) minmax(300px,400px); align-items:center; gap:32px; }
|
||
.store-toolbar .store-heading p { margin-bottom:0; }
|
||
@media(max-width:900px) { .store-toolbar { display:block; } .store-toolbar .store-heading p { margin-bottom:20px; } }
|
||
.store-search { display:flex; align-items:center; gap:12px; border:1px solid #ffffff12; background:#0e1723a6; border-radius:8px; padding:6px 8px 6px 16px; }
|
||
.store-search svg { width:21px; height:21px; color:var(--muted); flex:none; }
|
||
.store-search input[type=search] { background:transparent; padding:9px 0; font-size:15px; border:0; box-shadow:none; min-width:0; }
|
||
.store-search:focus-within { border-color:var(--link); }
|
||
.store-search button { border-radius:5px; }
|
||
.store-filters { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:16px 0 24px; }
|
||
.store-chips { display:flex; flex-wrap:wrap; gap:7px; }
|
||
.store-chips button { border-radius:20px; font-size:12px; height:34px; padding:0 14px; background:#ffffff07; color:#b5c0cb; border:1px solid #ffffff0c; }
|
||
.store-chips button[aria-pressed=true] { background:#235779; border-color:#5287a5; color:white; }
|
||
.store-source-select select { max-width:180px; padding:8px 26px 8px 10px; font:inherit; font-size:12px; border:0; border-radius:5px; color:var(--text); background:#1d2b3b; }
|
||
.store-notice { color:#a8b5c2; font-size:12px; margin:12px 0; }
|
||
body:has(.page.on #appStore:not([hidden])) #offline { padding:10px 16px; background:#ffffff06; box-shadow:inset 3px 0 0 var(--dim); }
|
||
body:has(.page.on #appStore:not([hidden])) #offline .s { display:none; }
|
||
.store-result-count { font-size:13px; color:var(--muted); margin-bottom:14px; }
|
||
.store-result-count:empty { display:none; }
|
||
.store-hero { position:relative; display:block; width:100%; height:270px; border:0; padding:0; border-radius:12px; overflow:hidden; text-align:left; margin:0 0 32px; isolation:isolate; }
|
||
.store-art { position:relative; display:block; overflow:hidden; background:radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%),transparent 70%),linear-gradient(140deg,hsl(var(--hue) 35% 18%),#101827); }
|
||
.store-art > img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; z-index:1; }
|
||
.art-initial { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:70px; font-weight:700; color:#ffffff45; letter-spacing:-4px; }
|
||
.store-hero > .store-art { position:absolute; inset:0; }
|
||
.store-hero::after { content:""; position:absolute; inset:0; background:linear-gradient(90deg,#081421ec 0%,#08142175 48%,#08142108 80%),linear-gradient(0deg,#08142170,transparent); z-index:2; }
|
||
.hero-copy { position:relative; z-index:3; padding:30px; max-width:550px; display:flex; align-items:flex-start; flex-direction:column; gap:12px; }
|
||
.hero-copy .store-eyebrow { color:#c2e7ff; }
|
||
.hero-copy strong { color:white; font-size:38px; letter-spacing:-.8px; line-height:1.1; }
|
||
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { color:#e0e8ef; font-size:16px; font-weight:400; max-width:380px; line-height:1.6; }
|
||
.hero-cta { color:white; display:inline-flex; align-items:center; background:#ffffff20; border:1px solid #ffffff40; border-radius:6px; padding:10px 18px; font-size:13px; margin-top:6px; }
|
||
.store-row { margin-bottom:34px; }
|
||
.store-row-head { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
|
||
.store-row-head h2 { text-transform:none; letter-spacing:-.3px; font-size:21px; font-weight:600; color:var(--bright); margin:0; }
|
||
.store-row-head p { color:var(--muted); font-size:12px; margin:0; }
|
||
.store-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(225px,1fr)); gap:18px; }
|
||
.store-card { display:block; width:100%; height:auto; min-width:0; padding:0; border:1px solid #ffffff0b; border-radius:9px; overflow:hidden; text-align:left; background:#1b2938; color:var(--text); transition:transform .18s,box-shadow .18s; font-weight:400; }
|
||
.store-card:hover { transform:translateY(-4px); background:#223448; box-shadow:0 12px 28px #0004; }
|
||
.store-card .store-art { aspect-ratio:16/9; width:100%; }
|
||
.store-card-content { padding:15px 14px; display:block; }
|
||
.store-card-title { display:flex; align-items:center; gap:9px; margin-bottom:9px; min-width:0; }
|
||
.store-icon { display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border-radius:8px; overflow:hidden; flex:none; background:hsl(var(--hue) 35% 28%); color:#fff; font-size:17px; position:relative; }
|
||
.store-icon img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
|
||
.store-card-title strong { font-size:15px; color:var(--bright); text-overflow:ellipsis; overflow:hidden; white-space:nowrap; }
|
||
.store-summary { display:block; color:#a8b5c2; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.store-card-foot { display:flex; justify-content:space-between; align-items:center; gap:6px; margin-top:16px; font-size:11px; color:#a8b5c2; }
|
||
.store-card-foot b { color:#dbeecf; font-weight:500; }
|
||
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
|
||
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
|
||
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
|
||
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
|
||
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
|
||
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
|
||
.store-empty p { max-width:390px; color:var(--muted); margin:0 0 22px; }
|
||
.store-skeleton { aspect-ratio:1.2; background:linear-gradient(110deg,#233446 30%,#30455b 45%,#233446 60%); background-size:250% 100%; border-radius:9px; animation:store-shimmer 1.8s ease infinite; }
|
||
@keyframes store-shimmer { to { background-position:-150% 0; } }
|
||
.store-dialog { padding:0; border:1px solid #ffffff1a; border-radius:14px; background:#172331; color:var(--text); width:min(980px,calc(100vw - 40px)); max-width:none; max-height:calc(100dvh - 48px); overflow:auto; box-shadow:0 30px 100px #0009; }
|
||
.store-dialog::backdrop { background:#030a13c9; backdrop-filter:blur(7px); }
|
||
.store-close { position:sticky; float:right; top:14px; margin:14px 14px -54px 0; z-index:6; height:36px; width:36px; padding:0; border-radius:50%; background:#09131dcc; color:white; font-size:25px; line-height:1; border:1px solid #ffffff30; }
|
||
.detail-banner { height:195px; }
|
||
.detail-banner::after { content:""; position:absolute; inset:0; z-index:2; background:linear-gradient(0deg,#172331,transparent 70%); }
|
||
.detail-content { padding:0 30px 30px; position:relative; }
|
||
.detail-heading { display:flex; gap:16px; align-items:center; margin-top:-24px; position:relative; z-index:3; }
|
||
.detail-heading .store-icon { width:70px; height:70px; border-radius:15px; box-shadow:0 3px 18px #0004; font-size:32px; }
|
||
.detail-heading h1 { font-size:30px; color:white; letter-spacing:-.7px; margin:0 0 4px; }
|
||
.detail-heading p { margin:0; color:#a8b5c2; font-size:13px; }
|
||
.detail-content > .store-badges { margin:20px 0; }
|
||
.detail-layout { display:grid; grid-template-columns:minmax(0,1fr) 290px; gap:30px; margin-top:22px; }
|
||
.detail-layout h2 { text-transform:none; letter-spacing:0; font-size:17px; color:white; margin:0 0 14px; }
|
||
.detail-description { color:#b5c2ce; font-size:14px; line-height:1.75; white-space:pre-line; }
|
||
.detail-gallery { display:flex; gap:10px; overflow-x:auto; scroll-snap-type:x mandatory; margin:0 0 10px; }
|
||
.detail-gallery .store-art { flex:0 0 86%; aspect-ratio:16/9; border-radius:7px; scroll-snap-align:start; }
|
||
.detail-gallery .store-art img { object-fit:contain; background:#0b1420; }
|
||
.gallery-controls { display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; font-size:11px; color:var(--muted); }
|
||
.gallery-controls button { height:30px; width:34px; padding:0; border-radius:5px; }
|
||
.detail-buy { align-self:start; background:#0e1926; padding:18px; border:1px solid #ffffff0a; border-radius:10px; }
|
||
.detail-buy .store-primary { width:100%; min-height:46px; border-radius:7px; margin:8px 0; font-size:15px; }
|
||
.detail-verdict { color:#b6d6a8; font-size:12px; line-height:1.55; margin:0 0 15px; }
|
||
.detail-verdict.blocked { color:#e3b77a; }
|
||
.detail-verdict.unknown { color:#adbac7; }
|
||
.detail-price { font-size:23px; color:white; font-weight:600; }
|
||
.detail-small { font-size:11px; color:var(--muted); margin:4px 0 18px; }
|
||
.offer-choice { display:flex; align-items:flex-start; gap:9px; padding:11px 9px; margin-top:8px; border:1px solid #ffffff12; border-radius:7px; cursor:pointer; }
|
||
.offer-choice:has(input:checked) { background:#1e3c52; border-color:#5386a6; }
|
||
.offer-choice input { margin:4px 0 0; accent-color:var(--blue); }
|
||
.offer-choice strong { font-size:12px; display:block; color:var(--bright); }
|
||
.offer-choice small { display:block; font-size:10px; color:#a8bbc9; margin-top:4px; }
|
||
.offer-mark { display:inline-flex; justify-content:center; align-items:center; height:26px; width:26px; flex:none; border-radius:6px; background:hsl(var(--hue) 30% 30%); color:white; font-size:11px; }
|
||
.detail-technical { border-top:1px solid #ffffff14; margin-top:25px; padding-top:16px; font-size:12px; color:var(--muted); }
|
||
.detail-technical summary { cursor:pointer; }
|
||
.detail-technical dl { display:grid; grid-template-columns:110px minmax(0,1fr); gap:6px; }
|
||
.detail-technical dd { margin:0; overflow-wrap:anywhere; }
|
||
.detail-buy .store-primary:disabled { opacity:1; color:#d6e8f7; background:#274f71; cursor:default; }
|
||
#detailProgress::-webkit-progress-bar { background:#283b4d; border-radius:3px; }
|
||
#detailProgress::-webkit-progress-value { background:var(--blue); border-radius:3px; }
|
||
#detailProgress { height:4px; width:100%; accent-color:var(--blue); display:block; margin-bottom:10px; }
|
||
.sources-dialog { width:min(560px,calc(100vw - 32px)); padding:30px; }
|
||
.sources-dialog .store-close { margin:-16px -16px -20px 0; top:0; }
|
||
.sources-intro h1 { font-size:28px; color:white; letter-spacing:-.7px; margin:9px 0; }
|
||
.source-setting { display:flex; align-items:center; gap:13px; padding:17px 0; border-top:1px solid #ffffff0c; }
|
||
.source-setting .offer-mark { width:42px; height:42px; font-size:16px; border-radius:10px; }
|
||
.source-setting .grow { flex:1; min-width:0; }
|
||
.store-dialog a { color:var(--link); }
|
||
.source-setting strong { display:block; font-size:14px; color:white; }
|
||
.source-setting p { color:#a3b4c3; font-size:12px; margin:3px 0; }
|
||
.source-setting small { color:var(--muted); font-size:10px; }
|
||
.source-toggle { appearance:none; width:38px; height:23px; flex:none; border-radius:15px; background:#465463; position:relative; cursor:pointer; margin:0; }
|
||
.source-toggle::after { content:""; position:absolute; top:3px; left:3px; width:17px; height:17px; background:#fff; border-radius:50%; transition:transform .15s; }
|
||
.source-toggle:checked { background:#208dca; }
|
||
.source-toggle:checked::after { transform:translateX(15px); }
|
||
.source-repo-form { display:grid; gap:12px; border-top:1px solid #ffffff14; margin-top:10px; padding-top:24px; }
|
||
.source-repo-form h2 { font-size:19px; letter-spacing:0; text-transform:none; color:white; margin:0; }
|
||
.source-repo-form p { color:var(--muted); font-size:12px; margin:0; }
|
||
.source-repo-form label { font-size:12px; }
|
||
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
|
||
.source-repo-form details { font-size:11px; color:var(--muted); }
|
||
.source-repo-form button { justify-self:start; border-radius:6px; }
|
||
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||
.store-search input:focus-visible { outline:0!important; }
|
||
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
|
||
@media(max-width:1000px) { .store-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } .detail-layout { grid-template-columns:minmax(0,1fr) 260px; gap:20px; } }
|
||
@media(max-width:680px) {
|
||
.store-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
|
||
.store-tabs { margin-bottom:24px; gap:2px; } .store-tabs button { padding:0 10px; font-size:12px; }
|
||
.store-heading p { font-size:13px; max-width:320px; }
|
||
.store-filters { align-items:flex-start; flex-direction:column; gap:10px; margin-bottom:18px; }
|
||
.store-chips { gap:5px; } .store-chips button { font-size:11px; padding:0 10px; }
|
||
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
|
||
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
|
||
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; }
|
||
.store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
|
||
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
|
||
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
|
||
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
|
||
.store-dialog { width:calc(100vw - 16px); max-height:calc(100dvh - 20px); border-radius:10px; }
|
||
.detail-banner { height:190px; } .detail-content { padding:0 18px 24px; } .detail-heading h1 { font-size:24px; }
|
||
.detail-layout { display:flex; flex-direction:column; gap:24px; } .detail-buy { order:-1; width:100%; }
|
||
.sources-dialog { padding:24px; } .sources-dialog .store-close { margin:-12px -12px -20px 0; }
|
||
}
|
||
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation: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 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="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
|
||
<section id="appStore" aria-label="Discover apps">
|
||
<div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
|
||
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
|
||
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
|
||
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
|
||
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
|
||
<div id="searchGrid" aria-busy="false"></div>
|
||
</section>
|
||
<div class="and-grid" id="androidLibrary" hidden>
|
||
<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 hidden>
|
||
<div hidden> <div class="shelf-head"><h2>Find apps</h2><span class="count" id="catCount"></span></div>
|
||
<div class="cat-tools">
|
||
<input type="text" id="catQ" placeholder="Search F-Droid apps rated for the Frame…">
|
||
<select id="catCat" aria-label="Category"><option value="">All categories</option></select>
|
||
</div>
|
||
<div class="vchips" id="catChips"></div>
|
||
<div class="cat-grid" id="catGrid"><div class="sub">Loading the catalogue…</div></div>
|
||
<div class="row" style="margin-top:12px"><button class="small" id="catMore" hidden>Show more</button></div>
|
||
<div class="hint">Verdicts come from checks of each APK against what Lepton supports (Android 11,
|
||
64-bit ARM, no clipboard or Play Services) and from reports made on a Frame. See docs/apks.md.</div></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</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>
|
||
<label class="sub" for="apkDisplay">Android display</label>
|
||
<select id="apkDisplay" aria-label="Android app display mode">
|
||
<option value="auto">Automatic (detect VR)</option>
|
||
<option value="flat">Flat screen</option>
|
||
<option value="vr">VR app</option>
|
||
</select>
|
||
<div class="progress" id="prog"><i></i></div>
|
||
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
|
||
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
|
||
<div class="row" style="margin-top:8px">
|
||
<button class="action small" id="clipSend">Send text</button>
|
||
<button class="small" id="clipMac">Send this computer's clipboard</button>
|
||
</div>
|
||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||
</section>
|
||
|
||
<section class="panel" id="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="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="appDetail" class="store-dialog" aria-labelledby="detailTitle">
|
||
<button class="store-close" data-close="appDetail" aria-label="Close app details">×</button>
|
||
<div id="detailBody"></div>
|
||
</dialog>
|
||
<dialog id="sourcesDialog" class="store-dialog sources-dialog" aria-labelledby="sourcesTitle">
|
||
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
|
||
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
|
||
<div id="sourcesList"></div>
|
||
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
|
||
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
|
||
</dialog>
|
||
|
||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||
<p id="apkAltReason"></p>
|
||
<div class="list" id="apkAltVersions"></div>
|
||
<p class="sub" id="apkAltNote"></p>
|
||
<div id="apkAltLinks"></div>
|
||
<p class="sub" id="apkAltErrors"></p>
|
||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||
</dialog>
|
||
|
||
<dialog id="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, onProgress = () => {}) {
|
||
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; }
|
||
onProgress(j);
|
||
if (!j.done) continue;
|
||
if (j.error) throw new Error(j.error);
|
||
log(j.message, "ok"); toast(j.message);
|
||
return j.result || {};
|
||
}
|
||
} catch (e) {
|
||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||
} finally { installing.delete(key); jobsRunning--; showJobs(); }
|
||
}
|
||
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
|
||
poweroff: ["Shut the Frame down?", "Shut down"] };
|
||
function askPower(what) {
|
||
const [title, verb] = POWER[what], dlg = $("pwDlg");
|
||
$("pwTitle").textContent = title; $("pwGo").textContent = verb;
|
||
$("pwInput").value = ""; $("pwMsg").textContent = "";
|
||
$("pwCancel").onclick = () => dlg.close();
|
||
$("pwForm").onsubmit = async e => {
|
||
e.preventDefault();
|
||
const password = $("pwInput").value;
|
||
if (!password) return ($("pwMsg").textContent = "Enter the password");
|
||
$("pwGo").disabled = true; $("pwMsg").textContent = "";
|
||
try {
|
||
const res = await api("/api/open", { what, password });
|
||
dlg.close(); log(res.message, "ok"); toast(res.message);
|
||
} catch (err) { $("pwMsg").textContent = err.message; }
|
||
finally { $("pwGo").disabled = false; $("pwInput").value = ""; }
|
||
};
|
||
dlg.showModal();
|
||
$("pwInput").focus();
|
||
}
|
||
$("changeHeadset").onclick = () => window.frameApp?.setUpConnection && window.frameApp.setUpConnection();
|
||
|
||
function installFlatpak(id) {
|
||
if (installing.has(id)) return;
|
||
toast(`Installing ${id}. This can take a few minutes…`);
|
||
const done = runJob(`Install ${id}`, id, () => api("/api/flatpak", { action: "install", id })).then(refresh);
|
||
if (state) render(state); // show it as installing
|
||
return done;
|
||
}
|
||
$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) { installFlatpak(id); $("fpId").value = ""; } };
|
||
$("fpId").addEventListener("keydown", e => { if (e.key === "Enter") $("fpInstall").click(); });
|
||
|
||
function setVolume(pct, muted) {
|
||
$("vol").value = pct;
|
||
$("vol").style.setProperty("--fill", pct + "%");
|
||
$("volNum").textContent = pct + "%";
|
||
if (muted !== undefined) $("volWaves").style.display = muted ? "none" : "";
|
||
}
|
||
$("vol").oninput = () => {
|
||
setVolume(+$("vol").value);
|
||
clearTimeout(volTimer);
|
||
volTimer = setTimeout(async () => {
|
||
try { await api("/api/volume", { level: $("vol").value / 100 }); } catch (e) { toast(e.message, true); }
|
||
volTimer = null;
|
||
}, 250);
|
||
};
|
||
$("muteBtn").onclick = () => {
|
||
const muted = !(state?.volume?.muted);
|
||
act(muted ? "Mute" : "Unmute", () => api("/api/volume", { muted }), $("muteBtn")).then(refresh);
|
||
};
|
||
$("clipSend").onclick = () => {
|
||
const text = $("clipText").value;
|
||
if (!text) return toast("Nothing to send", true);
|
||
act("Send text to clipboard", () => api("/api/clipboard", { text }), $("clipSend"));
|
||
};
|
||
// In the app, Electron reads the clipboard; in a browser, the server does.
|
||
async function sendComputerClipboard() {
|
||
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
|
||
const text = await window.frameApp.readClipboard();
|
||
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
|
||
return api("/api/clipboard", { text });
|
||
}
|
||
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
|
||
|
||
// ---- file drop ----
|
||
const drop = $("drop");
|
||
drop.onclick = () => $("fileInput").click();
|
||
drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); $("fileInput").click(); } };
|
||
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
|
||
// Files can be dropped anywhere in the window; an overlay says what happens.
|
||
// Dialogs keep their own drops (none take files today), so the overlay stays out of them.
|
||
let dragDepth = 0;
|
||
const carriesFiles = e => [...(e.dataTransfer?.types || [])].includes("Files");
|
||
window.addEventListener("dragenter", e => {
|
||
if (!carriesFiles(e) || document.querySelector("dialog[open]")) return;
|
||
e.preventDefault();
|
||
if (dragDepth++ === 0) { $("dropzone").hidden = false; drop.classList.add("over"); }
|
||
});
|
||
window.addEventListener("dragover", e => { if (carriesFiles(e)) e.preventDefault(); });
|
||
window.addEventListener("dragleave", e => {
|
||
if (!carriesFiles(e) || dragDepth === 0) return;
|
||
if (--dragDepth === 0) { $("dropzone").hidden = true; drop.classList.remove("over"); }
|
||
});
|
||
window.addEventListener("drop", e => {
|
||
if (!carriesFiles(e)) return;
|
||
e.preventDefault();
|
||
dragDepth = 0; $("dropzone").hidden = true; drop.classList.remove("over");
|
||
if (document.querySelector("dialog[open]")) return;
|
||
// Folders only show up as directory entries, and only during the drop event itself.
|
||
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
|
||
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||
sendFiles([...e.dataTransfer.files], dirs);
|
||
});
|
||
function upload(file, mode) {
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open("POST", "/api/upload");
|
||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||
xhr.setRequestHeader("X-Mode", mode);
|
||
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||
const bar = $("prog").firstElementChild;
|
||
$("prog").style.display = "block"; bar.style.width = "0";
|
||
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
||
xhr.onload = () => {
|
||
$("prog").style.display = "none";
|
||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
|
||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||
};
|
||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||
xhr.send(file);
|
||
});
|
||
}
|
||
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();
|
||
|
||
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();
|
||
|
||
// ---- Discover apps ----
|
||
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
|
||
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
|
||
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
|
||
const storeInitial = name => [...String(name || "?")][0].toUpperCase();
|
||
const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/ /g, " ");
|
||
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
|
||
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
|
||
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
|
||
function storeArt(o, cls="", eager=false, path=null, alt=null) {
|
||
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
|
||
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
|
||
}
|
||
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
|
||
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
|
||
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
|
||
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
|
||
async function loadSources() {
|
||
try {
|
||
const {sources} = await api("/api/sources"); sourceState.sources = sources;
|
||
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
|
||
$("sourceFilter").value = sourceState.source;
|
||
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
|
||
} catch (e) { $("sourceError").textContent = e.message; }
|
||
}
|
||
function storeCard(app) {
|
||
const i=sourceState.apps.indexOf(app), o=app.offers[0];
|
||
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
|
||
}
|
||
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
|
||
function storeEmpty() {
|
||
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
|
||
}
|
||
function renderStore() {
|
||
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
|
||
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
|
||
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
|
||
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
|
||
const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
|
||
const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
|
||
const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
|
||
const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
|
||
const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
|
||
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true"> ↗</span></span></span></button>` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
|
||
}
|
||
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
|
||
async function searchSources(quiet) {
|
||
const request=++sourceState.request;
|
||
$("searchGrid").setAttribute("aria-busy","true");
|
||
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
|
||
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
|
||
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
|
||
try {
|
||
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
|
||
sourceState.apps=result.apps;
|
||
const unavailable=result.sources.filter(s=>s.status==='error'||s.status==='timed out').map(s=>storeName(s.name));
|
||
const loading=result.sources.filter(s=>s.status==='loading').map(s=>storeName(s.name));
|
||
const notes=[];
|
||
if(loading.length)notes.push(`Still fetching ${loading.join(" and ")}; more results will appear in a moment.`);
|
||
if(unavailable.length)notes.push(`${unavailable.join(" and ")} ${unavailable.length===1 ? "isn't" : "aren't"} responding right now. You can still explore the other sources.`);
|
||
result.sources.filter(s=>s.status==='limited').forEach(s=>notes.push(`${s.error}.`));
|
||
const stale=result.sources.filter(s=>s.stale).map(s=>storeName(s.name));
|
||
if(stale.length)notes.push(`Showing saved listings from ${stale.join(" and ")} while ${stale.length===1 ? "it refreshes" : "they refresh"}.`);
|
||
$("sourceStatus").innerHTML=notes.map(esc).join(" ")+(result.elsewhere||[]).map(x=>` <a href="${esc(x.url)}" target="_blank" rel="noopener">Browse ${esc(storeName(x.name))} ↗</a>`).join("");
|
||
$("sourceStatus").hidden=!notes.length && !(result.elsewhere||[]).length;
|
||
clearTimeout(sourceState.retry);
|
||
if(loading.length)sourceState.retry=setTimeout(()=>{ if(request===sourceState.request)searchSources(true); },6000);
|
||
renderStore();await loadSources();
|
||
} catch(e) { if(request===sourceState.request) { $("searchGrid").innerHTML=storeEmpty();$("sourceStatus").hidden=false;$("sourceStatus").textContent="We couldn't reach the app shelves. Try again in a moment."; } }
|
||
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
|
||
}
|
||
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
|
||
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
|
||
function renderDetail() {
|
||
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
|
||
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
|
||
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
|
||
updateInstall();
|
||
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
|
||
}
|
||
function updateInstall() {
|
||
const o=currentOffer();if(!o || !$("detailInstall"))return;
|
||
const job=sourceState.jobs.get(`${o.source}:${o.id}`), blocked=o.verdict?.tone==='blocked', page=sourcePage(o.page), b=$("detailInstall");
|
||
b.disabled=!!(job && !job.done) || (blocked && !job?.result) || (!o.downloadable && !page);
|
||
b.textContent=job?.result ? 'Open in Steam' : job && !job.done ? (job.stage||'Downloading')+(job.percent!=null?` ${Math.round(job.percent)}%`:'…') : blocked ? 'Not compatible' : o.downloadable ? (job?.error?'Try again':'Install on Frame') : 'Get from the creator ↗';
|
||
$("detailProgress").hidden=!job || job.done;
|
||
if(job?.percent!=null)$("detailProgress").value=job.percent;else $("detailProgress").removeAttribute("value");
|
||
$("detailInstallNote").textContent=job?.result ? '✓ Installed. Find it in your Steam library.' : job?.error || (job && !job.done ? 'Keep exploring. We’ll take care of the installation.' : o.verdict?.tone==='works' ? 'Adds to your Steam library.' : 'Compatibility can vary. Adds to your Steam library.');
|
||
$("detailOffers").querySelectorAll('input').forEach(input=>input.disabled=!!job&&!job.done);
|
||
}
|
||
async function openStoreApp(index) {
|
||
const app=sourceState.apps[index];if(!app)return;
|
||
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
|
||
renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
|
||
await loadOfferDetails();
|
||
}
|
||
async function loadOfferDetails() {
|
||
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
|
||
try {
|
||
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
|
||
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
|
||
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
|
||
d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
|
||
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
|
||
}
|
||
async function installStoreApp() {
|
||
const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key);
|
||
if(existing?.result) { await act(`Open ${o.name}`,()=>api('/api/android',{action:'launch',package:existing.result.package}),$("detailInstall"));return; }
|
||
if(!o.downloadable) { const url=sourcePage(o.page);if(url)window.open(url,'_blank','noopener,noreferrer');return; }
|
||
if(existing&&!existing.done)return;
|
||
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
|
||
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
|
||
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
|
||
if(result)loadAndroid();
|
||
}
|
||
async function changeSource(body) {
|
||
$("sourceError").textContent="Saving your sources…";
|
||
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;sourceState.source='';await loadSources();await searchSources(); }
|
||
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
|
||
}
|
||
let storeSearchTimer;
|
||
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
|
||
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
|
||
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
|
||
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
|
||
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
|
||
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
|
||
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
|
||
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
|
||
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
|
||
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
|
||
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);changeSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
|
||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
|
||
loadSources().then(()=>searchSources());
|
||
|
||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||
const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might work", unlikely: "Probably crashes", no: "Won't work" };
|
||
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
|
||
let androidApps = [];
|
||
|
||
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"));
|
||
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>
|