mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Merge main into family-comfort; a session state without 'started' can't crash status
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
1203ec0a9e
180 files changed
+62069
-234
No files matched your search
+708
-9
@@ -87,6 +87,7 @@
|
||||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
|
||||
/* ---- drop anywhere ---- */
|
||||
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
||||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||||
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
||||
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
||||
@@ -227,6 +228,17 @@
|
||||
.actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.actions button { justify-content: flex-start; height: 40px; }
|
||||
.actions svg { width: 16px; height: 16px; flex: none; opacity: .85; }
|
||||
.notice { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-radius: 4px;
|
||||
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
|
||||
.notice .grow { flex: 1; min-width: 260px; }
|
||||
.notice .progress { width: 160px; margin-top: 0; display: block; }
|
||||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||||
.popt b { font-weight: 600; color: var(--text); }
|
||||
.popt .sub { grid-column: 2; line-height: 1.45; }
|
||||
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
|
||||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
|
||||
details summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 12px; }
|
||||
.links { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 13px; color: var(--muted); }
|
||||
.links a { color: var(--link); text-decoration: none; } .links a:hover { color: #fff; }
|
||||
.links div { margin: 5px 0; }
|
||||
@@ -255,10 +267,21 @@
|
||||
.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;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#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); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
#bugForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.about-text { max-height: 55vh; overflow: auto; }
|
||||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -310,6 +333,13 @@
|
||||
.game-card .bar > i { display: block; height: 100%; background: var(--action); }
|
||||
.f-0 { color: var(--muted); }
|
||||
/* ---- Android display ---- */
|
||||
#mvQuality { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px; padding: 4px 6px; font: inherit; font-size: 12px; }
|
||||
.mv-windows { max-height: 420px; overflow-y: auto; }
|
||||
.mv-perm { background: rgba(255,190,60,.08); border: 1px solid rgba(255,190,60,.25); border-radius: 4px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; }
|
||||
.mv-perm .row { margin-top: 8px; }
|
||||
.mv-live { color: var(--green, #7fd67f); font-size: 12px; }
|
||||
.mv-opt { display: block; font-size: 13px; margin: 0 0 12px; }
|
||||
.mv-opt .sub { display: block; margin-left: 22px; font-size: 12px; }
|
||||
.disp select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 10px; font: inherit; font-size: 13px; }
|
||||
.disp select:focus { outline: none; border-color: var(--blue); }
|
||||
@@ -323,6 +353,19 @@
|
||||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||||
/* ---- keyboard and trackpad ---- */
|
||||
.pad-area { position: relative; height: 190px; margin: 12px 0; border-radius: 4px; background: rgba(0,0,0,.28);
|
||||
border: 1px dashed rgba(255,255,255,.14); display: grid; place-items: center; cursor: pointer;
|
||||
touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none; }
|
||||
.pad-area:focus-visible, .pad-area.captured { border: 1px solid var(--blue); box-shadow: 0 0 0 1px var(--blue) inset; }
|
||||
.pad-area.off { cursor: default; opacity: .55; }
|
||||
.pad-hint { max-width: 420px; padding: 0 16px; text-align: center; color: var(--muted); font-size: 13px; pointer-events: none; }
|
||||
.pad-keys { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.pad-keys input { flex: 1 1 220px; min-width: 0; height: 32px; padding: 0 10px; border-radius: 3px; border: 1px solid transparent;
|
||||
background: rgba(0,0,0,.28); color: var(--text); font: inherit; }
|
||||
.pad-keys input:focus { outline: none; border-color: var(--blue); }
|
||||
#pad .hint { margin-top: 10px; }
|
||||
|
||||
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||||
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||||
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||||
@@ -359,6 +402,10 @@
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
||||
.pad-area { height: 240px; }
|
||||
.pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); }
|
||||
.pad-keys .seg button { padding: 0 4px; justify-content: center; text-align: center; }
|
||||
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||||
body.typing nav { display: none; }
|
||||
}
|
||||
@@ -384,12 +431,31 @@
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
||||
<span class="chip" id="battChip" title="Battery">—</span>
|
||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
||||
</button>
|
||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="notice" id="updateBar" hidden>
|
||||
<div class="grow" id="updateText"></div>
|
||||
<div class="progress" id="updateProg" hidden><i></i></div>
|
||||
<button class="small" id="updateNotes">What's new</button>
|
||||
<button class="action small" id="updateGo">Update and restart</button>
|
||||
<button class="small" id="updateLater">Later</button>
|
||||
</div>
|
||||
<div class="notice" id="privacyNotice" hidden>
|
||||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||||
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
|
||||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||||
worked and the details of errors, which helps fix problems faster.</div>
|
||||
<button class="small" id="noticeSettings">Privacy settings</button>
|
||||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||||
<button class="action small" id="noticeOk">OK</button>
|
||||
</div>
|
||||
<div class="banner" id="offline" role="alert" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||||
@@ -502,6 +568,37 @@
|
||||
<span id="comfortAnnouncement" class="sr-only" aria-live="polite"></span>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="pad">
|
||||
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
|
||||
<button class="small" id="padOn">Turn on</button>
|
||||
</div>
|
||||
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
|
||||
<div class="pad-hint" id="padHint"></div>
|
||||
</div>
|
||||
<div class="pad-keys" aria-label="Keys and clicks">
|
||||
<input id="padType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||||
<span class="seg pad-clicks">
|
||||
<button data-pad-click="singleclick" title="Left click">Click</button>
|
||||
<button data-pad-click="rightclick" title="Right click">Right-click</button>
|
||||
</span>
|
||||
<span class="seg pad-special">
|
||||
<button data-pad-key="14" title="Escape">Esc</button>
|
||||
<button data-pad-key="2" title="Tab">Tab</button>
|
||||
<button data-pad-key="4" title="Left arrow" aria-label="Left arrow">←</button>
|
||||
<button data-pad-key="5" title="Up arrow" aria-label="Up arrow">↑</button>
|
||||
<button data-pad-key="7" title="Down arrow" aria-label="Down arrow">↓</button>
|
||||
<button data-pad-key="6" title="Right arrow" aria-label="Right arrow">→</button>
|
||||
<button data-pad-key="1" title="Backspace" aria-label="Backspace">⌫</button>
|
||||
<button data-pad-key="12" title="Enter">Enter</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
|
||||
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
|
||||
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
|
||||
else to install. <a href="#" data-about>Licences</a></div>
|
||||
</section>
|
||||
|
||||
<section id="shots">
|
||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="shotsRefresh">Refresh</button>
|
||||
@@ -622,6 +719,7 @@
|
||||
</div>
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<section class="panel"><h2>Assistant and AI agents</h2><p>Use your own model endpoint, or review a proposed MCP action. Nothing is sent to a model until you opt in.</p><a href="/assistant">Open assistant</a></section>
|
||||
<div class="grid-3">
|
||||
<section class="panel" id="transfer">
|
||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||||
@@ -632,6 +730,12 @@
|
||||
<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>
|
||||
@@ -642,6 +746,31 @@
|
||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="media">
|
||||
<div class="shelf-head"><h2>Media in the headset</h2><span class="count">Preview</span></div>
|
||||
<p class="sub">Play your movies and stereo photos with Frame Control's own player.</p>
|
||||
<div class="row"><button class="small" id="mediaChoose">Send media…</button>
|
||||
<button class="small" id="mediaRefresh">Refresh</button></div>
|
||||
<input type="file" id="mediaInput" accept=".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden>
|
||||
<div class="row" style="margin-top:12px"><label for="mediaFile">On the Frame</label>
|
||||
<select id="mediaFile"><option value="">Refresh to load your media</option></select></div>
|
||||
<div class="row" style="margin-top:8px"><label for="mediaLayout">Layout</label>
|
||||
<select id="mediaLayout">
|
||||
<option value="auto">Detect from name or metadata</option><option value="mono">2D</option>
|
||||
<option value="sbs">Half SBS · left / right</option><option value="ou">Half OU · top / bottom</option>
|
||||
<option value="full-sbs">Full SBS · left / right</option><option value="full-ou">Full OU · top / bottom</option>
|
||||
</select></div>
|
||||
<div class="row" style="margin-top:8px"><label><input type="checkbox" id="mediaTheatre" checked>
|
||||
Theatre · bigger screen, dark surround</label></div>
|
||||
<div class="row" style="margin-top:12px"><button class="action small" id="mediaPlay">Play</button>
|
||||
<button class="small" id="mediaStop">Stop</button></div>
|
||||
<p class="sub" id="mediaState" role="status" aria-live="polite">Choose a file to begin.</p>
|
||||
<div class="hint">H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
|
||||
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
|
||||
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
|
||||
Stop works from here; playback also ends after four hours.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="apps">
|
||||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
||||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||||
@@ -667,12 +796,52 @@
|
||||
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
|
||||
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
|
||||
<div class="row" style="margin-top:14px"><button class="small" data-about>About and licences</button></div>
|
||||
<div class="links desk-only">
|
||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||
<div><a href="https://framedropvr.com" target="_blank">FrameDrop</a>: sideloader (Windows only for now)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="macview" hidden>
|
||||
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
|
||||
<select id="mvQuality" title="Picture quality and bandwidth">
|
||||
<option value="sharp">Sharp</option><option value="balanced" selected>Balanced</option>
|
||||
<option value="light">Light</option><option value="compatible">Compatible (JPEG)</option>
|
||||
</select>
|
||||
<button class="small" id="mvRefresh">Refresh</button></div>
|
||||
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
|
||||
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
|
||||
<div class="mv-perm" id="mvPerm" hidden></div>
|
||||
<div class="list" id="mvDisplays"></div>
|
||||
<div class="shelf-head" style="margin-top:16px"><h2>Windows</h2><span class="count" id="mvWinCount"></span></div>
|
||||
<div class="list mv-windows" id="mvWindows"><div class="sub">Loading…</div></div>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button class="small" data-mv="show" data-src="test" data-title="Test pattern">Test pattern</button>
|
||||
<button class="small danger" data-mv="stopall">Stop all</button>
|
||||
</div>
|
||||
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
|
||||
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="privacy">
|
||||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||||
worked (with an error category, never the message). F-Droid package names only.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||||
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
|
||||
compatibility database, so the verdicts get better for everyone.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tDiag"><b>Send error details</b>
|
||||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||||
folder, user name, addresses and keys removed.</span></label>
|
||||
<div class="hint" id="tStatus"></div>
|
||||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||||
<a class="sub" href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md" target="_blank">What's collected, exactly</a></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -697,6 +866,14 @@
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||||
<h2 id="aboutTitle">About and licences</h2>
|
||||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||||
licence texts follow, with where to get the source.</p>
|
||||
<div id="aboutText" class="about-text">Loading…</div>
|
||||
<div class="actions"><button id="aboutClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="repDlg" aria-labelledby="repTitle">
|
||||
<form method="dialog" id="repForm">
|
||||
<h2 id="repTitle">Report an APK</h2>
|
||||
@@ -726,6 +903,29 @@
|
||||
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="bugDlg" aria-labelledby="bugTitle">
|
||||
<form method="dialog" id="bugForm">
|
||||
<h2 id="bugTitle">Report a problem</h2>
|
||||
<label class="field">What kind of report?<select id="bugKind">
|
||||
<option value="bug">Something's broken</option><option value="idea">An idea</option>
|
||||
<option value="question">A question</option><option value="other">Something else</option></select></label>
|
||||
<label class="field">Title<input type="text" id="bugTitleIn" maxlength="120" required minlength="5"
|
||||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||||
<label class="field">How can we reach you? (optional, for a reply)<input type="text" id="bugContact" maxlength="120" placeholder="Email, GitHub or Discord name"></label>
|
||||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||||
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
|
||||
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
|
||||
<p id="bugWarn">Sent privately to the Frame Control developer. Nothing is published.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="bugMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="bugCancel">Cancel</button>
|
||||
<button type="button" class="small" id="bugCopy">Copy report</button>
|
||||
<button type="submit" class="action small" id="bugSend">Send report</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="titleDlg" aria-labelledby="titleTitle">
|
||||
<form method="dialog" id="titleForm">
|
||||
<h2 id="titleTitle">Add to the Steam library</h2>
|
||||
@@ -1540,6 +1740,7 @@ function upload(file, mode) {
|
||||
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) + "%"; };
|
||||
@@ -1553,6 +1754,42 @@ function upload(file, mode) {
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
async function refreshMedia(selectId) {
|
||||
const data = await api("/api/media", { action: "list" });
|
||||
const selected = selectId || $("mediaFile").value;
|
||||
$("mediaFile").replaceChildren();
|
||||
if (!data.files.length) $("mediaFile").add(new Option("Send media to begin", ""));
|
||||
for (const file of data.files) $("mediaFile").add(new Option(file.name, file.id));
|
||||
if (data.files.some(f => f.id === selected)) $("mediaFile").value = selected;
|
||||
const p = data.player;
|
||||
$("mediaState").textContent = p.error || `${p.state}${p.file ? ": " + p.file : ""}`;
|
||||
return data;
|
||||
}
|
||||
$("mediaChoose").onclick = () => $("mediaInput").click();
|
||||
$("mediaInput").onchange = () => act("Send media", async () => {
|
||||
const file = $("mediaInput").files[0];
|
||||
if (!file) return;
|
||||
const result = await upload(file, "media");
|
||||
await refreshMedia(result.id);
|
||||
$("mediaInput").value = "";
|
||||
return result;
|
||||
}, $("mediaChoose"));
|
||||
$("mediaRefresh").onclick = () => act("Refresh media", () => refreshMedia(), $("mediaRefresh"));
|
||||
$("mediaPlay").onclick = () => act("Play media", async () => {
|
||||
const id = $("mediaFile").value;
|
||||
if (!id) throw new Error("Send or select a media file first");
|
||||
const result = await api("/api/media", { action: "play", id,
|
||||
layout: $("mediaLayout").value, theatre: $("mediaTheatre").checked });
|
||||
$("mediaState").textContent = "Starting…";
|
||||
setTimeout(() => refreshMedia().catch(e => { $("mediaState").textContent = e.message; }), 1500);
|
||||
return result;
|
||||
}, $("mediaPlay"));
|
||||
$("mediaStop").onclick = () => act("Stop media", async () => {
|
||||
const result = await api("/api/media", { action: "stop" });
|
||||
await refreshMedia();
|
||||
return result;
|
||||
}, $("mediaStop"));
|
||||
|
||||
let apkLookup = 0;
|
||||
async function checkApkAlternatives(apk) {
|
||||
const lookup = ++apkLookup;
|
||||
@@ -1591,11 +1828,27 @@ function showApkAlternatives(reason, result) {
|
||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||||
await loadAndroid();
|
||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
||||
if (res && res.app) await offerTest(res.app);
|
||||
};
|
||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||||
}
|
||||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
||||
|
||||
// ---- about and licences ----
|
||||
document.addEventListener("click", async e => {
|
||||
if (!e.target.closest("[data-about]")) return;
|
||||
e.preventDefault();
|
||||
$("aboutDlg").showModal();
|
||||
try {
|
||||
const { notices } = await api("/api/licenses");
|
||||
$("aboutText").innerHTML = notices.map((n, i) =>
|
||||
`<details${i ? "" : " open"}><summary>${esc(n.title)}</summary><pre>${esc(n.text)}</pre></details>`).join("");
|
||||
} catch (err) {
|
||||
$("aboutText").textContent = `Couldn't load the licences: ${err.message}. They're also in THIRD_PARTY_NOTICES.md and frame/kdeconnect in Frame Control's source.`;
|
||||
}
|
||||
});
|
||||
$("aboutClose").onclick = () => $("aboutDlg").close();
|
||||
|
||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||
async function sendFiles(files, dirs = new Set()) {
|
||||
for (const [i, f] of files.entries()) {
|
||||
@@ -1608,7 +1861,8 @@ async function sendFiles(files, dirs = new Set()) {
|
||||
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"));
|
||||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||
if (apk && res && res.app) await offerTest(res.app);
|
||||
}
|
||||
$("fileInput").value = "";
|
||||
refresh();
|
||||
@@ -1997,7 +2251,8 @@ document.body.addEventListener("click", async e => {
|
||||
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;
|
||||
const res = await done;
|
||||
if (res && res.app) await offerTest(res.app);
|
||||
} else if (action === "remove") {
|
||||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
@@ -2125,6 +2380,14 @@ loadDisplays();
|
||||
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
|
||||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||
let repShared = false;
|
||||
function setRepHint() {
|
||||
$("repHint").textContent = repShared
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: telemetry.compat && !telemetry.blocked
|
||||
? "Reports change the verdicts you see and are shared with Frame Control's compatibility database (Privacy settings). Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this computer and change the verdicts you see. Turn on Share compatibility results in Privacy settings to add them to the shared database. Any APK can be reported, including ones not on F-Droid.";
|
||||
}
|
||||
const REPORTS_SHOWN = 5;
|
||||
let repsAll = false;
|
||||
$("repMore").onclick = () => { repsAll = true; loadReports(); };
|
||||
@@ -2132,9 +2395,7 @@ 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.";
|
||||
repShared = shared; setRepHint();
|
||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||
$("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
|
||||
$("repMore").textContent = `Show all ${reps.length}`;
|
||||
@@ -2288,10 +2549,85 @@ $("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);
|
||||
|
||||
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
|
||||
let mvSeq = 0, mvTimer = 0;
|
||||
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
|
||||
// How a live stream is doing: frames shown, Mac-to-headset delay, bitrate, and
|
||||
// whether the network made it step down (fewer frames, then fewer pixels).
|
||||
function mvHealth(st) {
|
||||
if (!st) return "";
|
||||
const x = st.stats || {}, c = st.controller || {}, bits = [];
|
||||
if (x.fps) bits.push(`${Math.round(x.fps)} fps`);
|
||||
if (x.total && x.total.p50 != null) bits.push(`${Math.round(x.total.p50)} ms`);
|
||||
if (x.mbps) bits.push(`${x.mbps} Mbit/s`);
|
||||
if (c.tier > 0) bits.push(`weak link: ${c.fps} fps${c.scale < 1 ? `, ${Math.round(c.scale * 100)}% size` : ""}`);
|
||||
return bits.length ? " · " + esc(bits.join(" · ")) : "";
|
||||
}
|
||||
function mvRow(src, title, sub, w, h, streaming, st) {
|
||||
return `<div class="item"><div class="grow"><div class="t">${esc(title)}</div>
|
||||
<div class="s">${esc(sub)}${streaming ? ' · <span class="mv-live">in the headset</span>' + mvHealth(st) : ""}</div></div>
|
||||
${streaming ? `<button class="small" data-mv="stop" data-src="${esc(streaming)}">Stop</button>`
|
||||
: `<button class="small action" data-mv="show" data-src="${esc(src)}" data-title="${esc(title)}" data-w="${w}" data-h="${h}">Show</button>`}
|
||||
</div>`;
|
||||
}
|
||||
async function loadMacView(restart) {
|
||||
const seq = ++mvSeq;
|
||||
let s, err;
|
||||
try { s = await api("/api/macview" + (restart ? "?restart=1" : "")); } catch (e) { err = e; }
|
||||
if (seq !== mvSeq) return;
|
||||
const box = $("macview");
|
||||
if (err) { box.hidden = false; return failed($("mvWindows"), err); }
|
||||
if (!s.available) { box.hidden = true; return; }
|
||||
box.hidden = false;
|
||||
const live = new Set((s.streams || []).map(x => x.src));
|
||||
const byStream = src => (s.streams || []).find(x => x.src === src);
|
||||
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
|
||||
const perm = [];
|
||||
if (!s.screen) perm.push(`Frame Control needs <b>Screen Recording</b> permission to show your windows.`);
|
||||
if (!s.accessibility) perm.push(`Allow <b>Accessibility</b> too, so clicks and keys from the headset reach the Mac.`);
|
||||
$("mvPerm").hidden = !perm.length;
|
||||
$("mvPerm").innerHTML = perm.join(" ") + `<div class="row"><button class="small action" data-mv="perm">Allow…</button>
|
||||
<span class="sub">Then press Refresh. macOS may ask you to reopen Frame Control.</span></div>`;
|
||||
const liveAs = src => live.has(src) ? src : src.startsWith("window:") && live.has("separate:" + src.slice(7)) ? "separate:" + src.slice(7) : "";
|
||||
$("mvDisplays").innerHTML = (s.displays || []).map(d =>
|
||||
mvRow(d.src, `Whole screen: ${d.name}`, `${d.w}×${d.h}${d.main ? " · main display" : ""}`, d.w, d.h, liveAs(d.src), byStream(liveAs(d.src)))).join("");
|
||||
const wins = s.windows || [];
|
||||
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
|
||||
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
|
||||
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
|
||||
: `<div class="sub">No windows open on this Mac's current desktop.</div>`;
|
||||
// Keep the numbers fresh while something is in the headset and the card is on screen.
|
||||
clearTimeout(mvTimer);
|
||||
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
|
||||
if (live.size) mvTimer = setTimeout(tick, 3000);
|
||||
}
|
||||
$("mvRefresh").onclick = () => loadMacView(true);
|
||||
$("mvSeparate").checked = localStorage.getItem("mvSeparate") !== "0";
|
||||
$("mvSeparate").onchange = e => localStorage.setItem("mvSeparate", e.target.checked ? "1" : "0");
|
||||
$("macview").onclick = async e => {
|
||||
const b = e.target.closest("[data-mv]"); if (!b) return;
|
||||
const src = b.dataset.src;
|
||||
if (b.dataset.mv === "show") {
|
||||
const as = $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
|
||||
await act(`Show ${b.dataset.title} in the headset`, async () => {
|
||||
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
|
||||
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
|
||||
return { message: `${b.dataset.title} is a panel in the headset now. Float it with the SteamVR dashboard.`, ...r };
|
||||
}, b);
|
||||
} else if (b.dataset.mv === "stop") {
|
||||
await act("Stop showing it", () => api("/api/macview", { action: "stop", src }), b);
|
||||
} else if (b.dataset.mv === "stopall") {
|
||||
await act("Stop everything in the headset", () => api("/api/macview", { action: "stop" }), b);
|
||||
} else if (b.dataset.mv === "perm") {
|
||||
await act("Ask macOS for permission", () => api("/api/macview", { action: "permissions" }), b);
|
||||
}
|
||||
setTimeout(loadMacView, 800);
|
||||
};
|
||||
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools" };
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -2303,12 +2639,226 @@ function showPage() {
|
||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||
const section = !PAGES.includes(id) && id && $(id);
|
||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||
if (page === "tools") loadMacView();
|
||||
}
|
||||
window.addEventListener("hashchange", showPage);
|
||||
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
|
||||
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||||
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||||
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||||
|
||||
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
|
||||
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
|
||||
const PAD_STATES = {
|
||||
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
|
||||
pairing: "Pairing with KDE Connect…", ready: "On",
|
||||
};
|
||||
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
|
||||
const PAD_SPECIAL = { Backspace: 1, Tab: 2, ArrowLeft: 4, ArrowUp: 5, ArrowRight: 6, ArrowDown: 7, PageUp: 8,
|
||||
PageDown: 9, Home: 10, End: 11, Enter: 12, Delete: 13, Escape: 14,
|
||||
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, 21 + i])) };
|
||||
const IS_MAC = /^Mac/.test(navigator.platform);
|
||||
const TOUCH = matchMedia("(hover: none)").matches;
|
||||
|
||||
function padShow(status) {
|
||||
pad.state = status.state || "off";
|
||||
const ready = pad.state === "ready";
|
||||
const busy = !ready && pad.state !== "off" && pad.state !== "error";
|
||||
$("padState").textContent = pad.state === "error" ? "" : PAD_STATES[pad.state] || "";
|
||||
$("padOn").hidden = ready || busy;
|
||||
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
|
||||
$("padArea").classList.toggle("off", !ready);
|
||||
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
|
||||
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
|
||||
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
|
||||
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
|
||||
: "Click here to use your mouse and keyboard on the Frame (Esc to stop), or drag like a trackpad.";
|
||||
clearTimeout(pad.poll);
|
||||
if (ready && pad.queue.length) padFlush();
|
||||
if (busy) pad.poll = setTimeout(async () => padShow(await api("/api/input").catch(e => ({ state: "error", message: e.message }))), 1000);
|
||||
}
|
||||
async function padStart() {
|
||||
localStorage.padUsed = "1"; // from now on it turns on with the page
|
||||
padShow({ state: "starting" });
|
||||
padShow(await api("/api/input?start=1").catch(e => ({ state: "error", message: e.message })));
|
||||
}
|
||||
$("padOn").onclick = padStart;
|
||||
|
||||
// Queue events and send them one request at a time, merging pointer moves made meanwhile.
|
||||
const isMove = e => Object.keys(e).every(k => k === "dx" || k === "dy");
|
||||
function padSend(event) {
|
||||
// While it reconnects, keys and clicks wait in the queue; pointer moves would be stale.
|
||||
// A button release is always kept, so a drag never leaves the button held on the Frame.
|
||||
const room = pad.queue.length < 200 || event.singlerelease;
|
||||
const reconnecting = ["starting", "pairing", "installing"].includes(pad.state) && room;
|
||||
// Even after an error: a button pressed on the Frame must be let go once it's back.
|
||||
const release = event.singlerelease && pad.state !== "off";
|
||||
if (pad.state !== "ready" && !(reconnecting && !isMove(event)) && !release) return;
|
||||
const last = pad.queue[pad.queue.length - 1];
|
||||
if (last && isMove(last) && isMove(event)) { last.dx = (last.dx || 0) + (event.dx || 0); last.dy = (last.dy || 0) + (event.dy || 0); }
|
||||
else pad.queue.push(event);
|
||||
padFlush();
|
||||
}
|
||||
// Events that didn't reach the Frame (the link had dropped, and is reconnecting): keep
|
||||
// the keys and clicks for when it's back, but not pointer moves, which would be stale.
|
||||
// Button releases are kept whatever the queue's length.
|
||||
function padKeep(events) {
|
||||
pad.queue = [...events, ...pad.queue].filter(e => !isMove(e)).filter((e, i) => i < 200 || e.singlerelease);
|
||||
}
|
||||
async function padFlush() {
|
||||
if (pad.sending || !pad.queue.length || pad.state !== "ready") return;
|
||||
pad.sending = true;
|
||||
const events = pad.queue.splice(0, 200);
|
||||
try {
|
||||
const status = await api("/api/input", { events });
|
||||
if (!status.sent) padKeep(events);
|
||||
if (status.state !== "ready") padShow(status);
|
||||
} catch (e) {
|
||||
padKeep(events);
|
||||
padShow({ state: "error", message: e.message });
|
||||
} finally {
|
||||
pad.sending = false;
|
||||
if (pad.queue.length) padFlush();
|
||||
}
|
||||
}
|
||||
function padKey(e) {
|
||||
const mods = { ...(e.ctrlKey || (IS_MAC && e.metaKey) ? { ctrl: true } : {}), ...(e.altKey ? { alt: true } : {}),
|
||||
...(!IS_MAC && e.metaKey ? { super: true } : {}) };
|
||||
if (PAD_SPECIAL[e.key]) return { specialKey: PAD_SPECIAL[e.key], ...mods, ...(e.shiftKey ? { shift: true } : {}) };
|
||||
if (e.key.length === 1) return { key: e.key, ...mods }; // already shifted, so no shift flag
|
||||
return null;
|
||||
}
|
||||
|
||||
// Touch: one finger moves, a quick tap clicks; two fingers scroll, or right-click when tapped.
|
||||
const PAD_SPEED = 1.6, PAD_SCROLL_STEP = 18;
|
||||
const touches = new Map();
|
||||
let gesture = null;
|
||||
$("padArea").addEventListener("pointerdown", e => {
|
||||
if (pad.state === "off" || pad.state === "error") { padStart(); return; }
|
||||
if (pad.state !== "ready") return;
|
||||
if (e.pointerType === "mouse") {
|
||||
if (pad.captured) return; // mousedown and mouseup below send the clicks
|
||||
// Lock the pointer to the pad and pass the whole mouse and keyboard through; where
|
||||
// that isn't allowed, the mouse drags like a finger on a trackpad instead.
|
||||
if (e.button === 0 && !pad.noLock && $("padArea").requestPointerLock) { $("padArea").requestPointerLock(); return; }
|
||||
// Only the left button drags and taps like a finger; the others are clicks of their own.
|
||||
if (e.button === 1 || e.button === 2) { padSend(e.button === 2 ? { rightclick: true } : { middleclick: true }); return; }
|
||||
if (e.button !== 0) return;
|
||||
}
|
||||
try { $("padArea").setPointerCapture(e.pointerId); } catch {} // keeps the drag if the finger leaves the pad
|
||||
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (touches.size === 1) gesture = { start: performance.now(), moved: 0, fingers: 1, scroll: 0 };
|
||||
else if (gesture) gesture.fingers = Math.max(gesture.fingers, touches.size);
|
||||
e.preventDefault();
|
||||
});
|
||||
$("padArea").addEventListener("pointermove", e => {
|
||||
if (pad.captured) { padSend({ dx: e.movementX, dy: e.movementY }); return; }
|
||||
const t = touches.get(e.pointerId);
|
||||
if (!t || !gesture) return;
|
||||
const dx = e.clientX - t.x, dy = e.clientY - t.y;
|
||||
t.x = e.clientX; t.y = e.clientY;
|
||||
gesture.moved += Math.abs(dx) + Math.abs(dy);
|
||||
if (touches.size >= 2) {
|
||||
// Scroll with the lead finger only, a notch per step, like a laptop trackpad (content follows the fingers).
|
||||
if (e.pointerId !== touches.keys().next().value) return;
|
||||
gesture.scroll += dy;
|
||||
while (Math.abs(gesture.scroll) >= PAD_SCROLL_STEP) {
|
||||
const up = gesture.scroll > 0;
|
||||
padSend({ scroll: true, dy: up ? 1 : -1 });
|
||||
gesture.scroll -= up ? PAD_SCROLL_STEP : -PAD_SCROLL_STEP;
|
||||
}
|
||||
} else if (gesture.fingers === 1) {
|
||||
padSend({ dx: dx * PAD_SPEED, dy: dy * PAD_SPEED });
|
||||
}
|
||||
});
|
||||
function padLift(e) {
|
||||
if (!touches.delete(e.pointerId) || touches.size || !gesture) return;
|
||||
const quick = performance.now() - gesture.start < 250 && gesture.moved < 10;
|
||||
if (quick) padSend(gesture.fingers >= 2 ? { rightclick: true } : { singleclick: true });
|
||||
gesture = null;
|
||||
}
|
||||
$("padArea").addEventListener("pointerup", padLift);
|
||||
$("padArea").addEventListener("pointercancel", e => { touches.delete(e.pointerId); if (!touches.size) gesture = null; });
|
||||
|
||||
// A computer's mouse and keyboard: captured while the pointer is locked to the pad.
|
||||
document.addEventListener("pointerlockerror", () => { pad.noLock = true; });
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
pad.captured = document.pointerLockElement === $("padArea");
|
||||
window.frameApp?.captureKeys?.(pad.captured); // the desktop app's menu shortcuts go to the Frame too
|
||||
// Esc or switching windows mid-drag: let go of the button on the Frame as well.
|
||||
if (!pad.captured && pad.held) { padSend({ singlerelease: true }); pad.held = false; }
|
||||
$("padArea").classList.toggle("captured", pad.captured);
|
||||
padShow({ state: pad.state });
|
||||
});
|
||||
$("padArea").addEventListener("mousedown", e => {
|
||||
if (!pad.captured) return;
|
||||
if (e.button === 0) { padSend({ singlehold: true }); pad.held = true; }
|
||||
else if (e.button === 1) padSend({ middleclick: true });
|
||||
else if (e.button === 2) padSend({ rightclick: true });
|
||||
});
|
||||
$("padArea").addEventListener("mouseup", e => { if (pad.held && e.button === 0) { padSend({ singlerelease: true }); pad.held = false; } });
|
||||
$("padArea").addEventListener("contextmenu", e => e.preventDefault());
|
||||
$("padArea").addEventListener("wheel", e => {
|
||||
if (pad.state !== "ready") return;
|
||||
e.preventDefault();
|
||||
if (e.deltaY) padSend({ scroll: true, dy: e.deltaY > 0 ? -1 : 1 });
|
||||
}, { passive: false });
|
||||
// Capture phase, so the page's own shortcuts (tabs 1-4, R, zoom) don't also fire.
|
||||
window.addEventListener("keydown", e => {
|
||||
if (!pad.captured) return;
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
const event = padKey(e);
|
||||
if (event) padSend(event);
|
||||
}, true);
|
||||
|
||||
// The text field: each keystroke goes to the Frame. While focused it holds one space,
|
||||
// so Backspace always has something to delete; empty otherwise, to show its prompt.
|
||||
const padType = $("padType");
|
||||
padType.addEventListener("beforeinput", e => {
|
||||
if (e.inputType === "insertCompositionText") return; // IME composing; sent on compositionend
|
||||
e.preventDefault();
|
||||
if (pad.state === "off" || pad.state === "error") padStart(); // what's typed meanwhile waits in the queue
|
||||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||||
if (/^insert(Text|ReplacementText|FromPaste|FromDrop)$/.test(e.inputType) && text) padText(text);
|
||||
else if (e.inputType === "deleteContentBackward") padSend({ specialKey: 1 });
|
||||
else if (e.inputType === "deleteWordBackward") padSend({ specialKey: 1, ctrl: true }); // Ctrl+Backspace: a word, on Linux
|
||||
else if (e.inputType === "deleteContentForward") padSend({ specialKey: 13 });
|
||||
else if (e.inputType === "deleteWordForward") padSend({ specialKey: 13, ctrl: true });
|
||||
else if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") padSend({ specialKey: 12 });
|
||||
});
|
||||
padType.addEventListener("compositionend", e => { if (e.data) padText(e.data); padType.value = " "; padCaret(); });
|
||||
// Text in pieces the server takes (500 characters), with Enter for each line break.
|
||||
function padText(text) {
|
||||
text.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
||||
if (i) padSend({ specialKey: 12 });
|
||||
const chars = Array.from(line); // whole characters, so an emoji isn't cut in two
|
||||
for (let at = 0; at < chars.length; at += 500) padSend({ key: chars.slice(at, at + 500).join("") });
|
||||
});
|
||||
}
|
||||
padType.addEventListener("keydown", e => {
|
||||
const special = PAD_SPECIAL[e.key];
|
||||
// Text and deletions arrive as beforeinput; this takes the keys that don't edit the field.
|
||||
if (!special || e.key === "Backspace" || e.key === "Delete" || e.isComposing) return;
|
||||
e.preventDefault();
|
||||
padSend(padKey(e));
|
||||
});
|
||||
// The caret stays after the space, so Backspace always has something to delete.
|
||||
const padCaret = () => requestAnimationFrame(() => padType.setSelectionRange(1, 1));
|
||||
padType.addEventListener("focus", () => { padType.value = " "; padCaret(); if (pad.state === "off") padStart(); });
|
||||
padType.addEventListener("blur", () => { padType.value = ""; });
|
||||
padType.addEventListener("input", e => {
|
||||
if (e.isComposing) return; // an input method is still composing; compositionend sends it
|
||||
if (padType.value !== " ") padType.value = " ";
|
||||
padCaret();
|
||||
});
|
||||
padType.addEventListener("click", padCaret);
|
||||
document.querySelector("#pad .pad-keys").addEventListener("click", e => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
if (b.dataset.padClick) padSend({ [b.dataset.padClick]: true });
|
||||
else if (b.dataset.padKey) padSend({ specialKey: +b.dataset.padKey });
|
||||
});
|
||||
api("/api/input").then(s => s.state === "off" && localStorage.padUsed ? padStart() : padShow(s), () => padShow({ state: "off" }));
|
||||
showPage();
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||||
@@ -2316,6 +2866,155 @@ document.addEventListener("keydown", e => {
|
||||
if (n) location.hash = n;
|
||||
});
|
||||
|
||||
// ---- after an APK install: test it, so the compatibility database learns whether it runs ----
|
||||
async function offerTest(m) {
|
||||
if (!m.package || !confirm(`${m.label || m.package} is installed. Test it now?\n\nIt opens in the headset for about 20 seconds `
|
||||
+ "and records whether it stays up.")) return;
|
||||
toast(`Testing ${m.label || m.package}: launching it and watching for 20 s…`);
|
||||
await act(`Test ${m.label || m.package}`, () => api("/api/android", { action: "probe", package: m.package }));
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||
function renderTelemetry(s) {
|
||||
Object.assign(telemetry, s);
|
||||
setRepHint();
|
||||
$("tUsage").checked = s.usage; $("tCompat").checked = s.compat; $("tDiag").checked = s.diagnostics;
|
||||
$("tStatus").textContent = s.blocked ? `Nothing is being sent: ${s.blocked}.`
|
||||
: `${s.queued ? s.queued + " waiting to send. " : ""}Your anonymous id is ${s.id.slice(0, 8)}…; it isn't linked to you or this computer.`;
|
||||
$("tSent").textContent = s.sent.length ? s.sent.map(e => JSON.stringify({ event: e.event, time: e.timestamp, ...e.properties })).join("\n\n")
|
||||
: "Nothing sent yet.";
|
||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||
$("privacyNotice").hidden = !showNotice;
|
||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||
}
|
||||
async function loadTelemetry() {
|
||||
try { renderTelemetry(await api("/api/telemetry")); } catch {}
|
||||
}
|
||||
async function setTelemetry(change) {
|
||||
try { renderTelemetry(await api("/api/telemetry", change)); }
|
||||
catch (e) { toast(`Couldn't save: ${e.message}`, true); loadTelemetry(); }
|
||||
}
|
||||
$("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
|
||||
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
|
||||
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
|
||||
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||||
$("noticeMore").onclick = async () => {
|
||||
$("privacyNotice").hidden = true;
|
||||
await setTelemetry({ compat: true, diagnostics: true });
|
||||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||||
};
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||||
loadTelemetry();
|
||||
function pageEvent(event, properties) {
|
||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||
}
|
||||
// Which tabs get used: once per tab per session.
|
||||
const tabsSeen = new Set();
|
||||
document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => {
|
||||
const tab = a.getAttribute("href").slice(1);
|
||||
if (!tabsSeen.has(tab)) { tabsSeen.add(tab); pageEvent("tab_viewed", { tab }); }
|
||||
}));
|
||||
|
||||
// ---- report a problem (ui/frame_report.py): sent privately to PostHog, with diagnostics ----
|
||||
const bug = { preview: "" };
|
||||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||||
function bugReportText() {
|
||||
const contact = $("bugContact").value.trim();
|
||||
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||||
return { title: $("bugTitleIn").value.trim(), body };
|
||||
}
|
||||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||||
async function loadBugPreview() {
|
||||
if (!$("bugDiag").checked) { bug.preview = ""; $("bugDiagText").textContent = "Nothing: diagnostics are off."; return; }
|
||||
$("bugDiagText").textContent = "Loading…";
|
||||
try { bug.preview = (await api("/api/report/preview", { activity: activityLines(), includeLogs: $("bugLogs").checked })).text; }
|
||||
catch (e) { bug.preview = ""; $("bugDiagText").textContent = `Couldn't collect diagnostics: ${e.message}`; return; }
|
||||
$("bugDiagText").textContent = bug.preview;
|
||||
}
|
||||
function openBugReport() {
|
||||
$("bugForm").reset();
|
||||
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
||||
$("bugCancel").textContent = "Cancel";
|
||||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||||
$("bugDlg").showModal();
|
||||
loadBugPreview();
|
||||
}
|
||||
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
||||
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
||||
$("bugLogs").onchange = loadBugPreview;
|
||||
$("bugCancel").onclick = () => $("bugDlg").close();
|
||||
$("bugCopy").onclick = async () => {
|
||||
const { title, body } = bugReportText();
|
||||
try { await navigator.clipboard.writeText(`${title}\n\n${body}`); $("bugMsg").textContent = "Copied."; }
|
||||
catch { $("bugMsg").textContent = "Couldn't copy; select the text under Show exactly what's included."; }
|
||||
};
|
||||
$("bugForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!$("bugForm").reportValidity()) return;
|
||||
$("bugSend").disabled = true; $("bugMsg").textContent = "Sending…";
|
||||
try {
|
||||
const res = await api("/api/report", {
|
||||
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
||||
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||||
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
||||
$("bugCancel").textContent = "Close";
|
||||
log(res.message, "ok");
|
||||
} catch (err) {
|
||||
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
||||
$("bugSend").disabled = false;
|
||||
}
|
||||
};
|
||||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||||
|
||||
// ---- updates (the desktop app only: app/updater.js) ----
|
||||
const upd = { dismissed: false, offered: null };
|
||||
function renderUpdate(s) {
|
||||
if (!s) return;
|
||||
$("appVersion").textContent = `Version ${s.current}`;
|
||||
const r = s.latest;
|
||||
const show = r && ["available", "downloading", "ready", "error"].includes(s.status) && !upd.dismissed
|
||||
&& !(s.status === "error" && !r);
|
||||
$("updateBar").hidden = !show;
|
||||
if (!show) return;
|
||||
if (s.status === "available" && upd.offered !== r.version) { upd.offered = r.version; pageEvent("update_offered", { to_version: r.version }); }
|
||||
const busy = s.status === "downloading" || s.status === "ready";
|
||||
$("updateText").innerHTML = s.status === "error"
|
||||
? `Updating to ${esc(r.version)} didn't work: ${esc(s.error || "unknown error")}`
|
||||
: busy ? `Downloading Frame Control ${esc(r.version)}… It restarts when it's ready.`
|
||||
: `<b>Frame Control ${esc(r.version)} is available.</b> You have ${esc(s.current)}.`
|
||||
+ (s.canInstall ? "" : ` ${esc(s.why ? "It can't update itself here (" + s.why + ")," : "")} download it from the release page.`);
|
||||
$("updateProg").hidden = !busy;
|
||||
$("updateProg").firstElementChild.style.width = Math.round((s.progress || 0) * 100) + "%";
|
||||
$("updateGo").textContent = s.canInstall ? (s.status === "error" ? "Try again" : "Update and restart") : "Open release page";
|
||||
$("updateGo").disabled = busy; $("updateLater").hidden = busy;
|
||||
}
|
||||
if (window.frameApp && window.frameApp.update) {
|
||||
window.frameApp.update.onState(renderUpdate);
|
||||
window.frameApp.update.get().then(renderUpdate);
|
||||
$("updateCheck").hidden = false;
|
||||
$("updateCheck").onclick = async () => {
|
||||
const btn = $("updateCheck");
|
||||
btn.disabled = true;
|
||||
let s;
|
||||
try { s = await window.frameApp.update.check(); } finally { btn.disabled = false; }
|
||||
upd.dismissed = false; renderUpdate(s);
|
||||
if (s && s.status === "none") toast(`You have the newest version (${s.current})`);
|
||||
if (s && s.status === "check-failed") toast(`Couldn't check for updates: ${s.error}`, true);
|
||||
};
|
||||
$("updateGo").onclick = () => {
|
||||
pageEvent("update_started", { to_version: upd.offered || "" });
|
||||
window.frameApp.update.install();
|
||||
};
|
||||
$("updateLater").onclick = () => { upd.dismissed = true; $("updateBar").hidden = true; };
|
||||
$("updateNotes").onclick = async () => {
|
||||
const s = await window.frameApp.update.get();
|
||||
if (s && s.latest) window.open(s.latest.page, "_blank");
|
||||
};
|
||||
}
|
||||
|
||||
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||
// The app passes each link here. The server checks it and reads the manifest;
|
||||
// nothing downloads until the user clicks Install in this dialog.
|
||||
|
||||
Reference in new issue
Block a user