Retrying every few seconds. Everything fills in when it answers.
+
+
+
+
+
@@ -401,12 +432,21 @@
Loading…
Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to ~/Pictures/SteamFrame.
+
+
-
Library
+
Installed
Loading…
+
+
Sideloaded titles
+
+
Loading…
+
Your own Linux or Windows builds, added to the Steam library as Devkit Games. Drop a game's
+ .zip, folder or .exe anywhere in this window.
+
Get games
@@ -429,7 +469,9 @@
compatibility categories.
+
+
Android apps
@@ -442,62 +484,13 @@
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.
-
-
Recent reports
-
Loading…
-
Reports change the verdicts in the catalogue. Any APK can be
- reported, including ones not on F-Droid.
-
-
-
-
Find apps
-
-
-
-
-
-
Loading the catalogue…
-
-
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.
-
-
-
-
-
-
-
Send to Frame
-
- Drop files here
- Files land in ~/Downloads. .apk files install as their own Android app;
- a game's .zip, folder or .exe becomes a title in the Steam library.
-
-
-
-
Sideloaded titles
-
Loading…
-
-
-
-
-
-
Clipboard needs the desktop panel open in the headset.
-
-
-
-
Linux apps
Flatpak
-
Loading…
-
-
-
-
-
-
-
-
+
Android display
Lepton
+
Open an Android app in the headset, then Refresh, to change its
+ resolution, UI scale and text size here.
+
Resolution
—
UI scale
—
@@ -523,7 +516,63 @@
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.
+
+
+
Recent reports
+
Loading…
+
+
Reports change the verdicts in the catalogue. Any APK can be
+ reported, including ones not on F-Droid.
+
+
+
+
Find apps
+
+
+
+
+
+
Loading the catalogue…
+
+
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.
+
+
+
+
+
+
+
+
+
Send to Frame
+
+ Drop files here, or click to choose
+ Files land in ~/Downloads. .apk files install as their own Android app;
+ a game's .zip, folder or .exe becomes a title in the Steam library.
+ You can also drop files anywhere in this window.
+
+
+
+
Clipboard
+
+
+
+
+
+
Clipboard needs the desktop panel open in the headset.
+
+
+
+
Linux apps
Flatpak
+
Loading…
+
+
+
+
+
+
+
Remote & power
@@ -544,11 +593,16 @@
+
+
Drop to send to the Frame
+ Files go to ~/Downloads; .apk files install as Android apps;
+ a game's .zip, folder or .exe becomes a Steam library title.
ACTIVITY
+ ReadyShow ▴
@@ -665,11 +719,23 @@ function applyHostWording(h) {
async function api(path, body) {
const opts = body === undefined ? { headers: {"X-Frame-UI": "1"} } : {
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(body) };
- const r = await fetch(path, opts);
+ let r;
+ try { r = await fetch(path, opts); }
+ catch { throw new Error("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) throw new Error(data.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 ? `
Waiting for the Frame
` : `
${esc(e.message)}
`;
+}
async function act(label, fn, btn) {
if (btn) btn.disabled = true;
log(label + "…");
@@ -687,17 +753,54 @@ function setConn(ok, text) {
$("conn").innerHTML = `${esc(text)}`;
}
-async function refresh() {
- $("refreshAll").disabled = true;
- try {
- state = await api("/api/status");
- render(state);
- setConn(true, state.hostname);
- } catch (e) {
+// 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").textContent = "—";
- log("Status failed: " + e.message, "e");
- } finally { $("refreshAll").disabled = false; }
+ $("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 ----
@@ -706,7 +809,9 @@ function battery(b, power) {
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.
- const draining = !full && !charging && (!power || b?.status === "Discharging" || b?.watts < 0);
+ // 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));
@@ -738,6 +843,7 @@ function battery(b, power) {
$("battChip").innerHTML = pct == null ? "—" :
`${bolt}${pct}%`;
$("battChip").title = label;
+ $("battChip").hidden = pct == null;
}
function render(s) {
@@ -795,7 +901,8 @@ function render(s) {
`).join("") : `
No Flatpak apps.
`;
const have = new Set(s.flatpaks.map(f => f.id));
$("fpQuick").innerHTML = QUICK.filter(([, id]) => !have.has(id))
- .map(([n, id]) => ``).join("");
+ .map(([n, id]) => installing.has(id) ? ``
+ : ``).join("");
}
// ---- view ----
@@ -855,7 +962,7 @@ $("canvas").addEventListener("pointerdown", e => {
$("canvas").addEventListener("pointerup", up, { once: true });
});
document.addEventListener("keydown", e => {
- if ($("canvas").hidden || /INPUT|TEXTAREA/.test(document.activeElement.tagName) || e.metaKey || e.ctrlKey) return;
+ 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);
@@ -1073,17 +1180,48 @@ document.body.addEventListener("click", async (e) => {
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, b);
+ } else if (b.dataset.quick) installFlatpak(b.dataset.quick);
else if (b.dataset.open) {
if (b.dataset.confirm && !confirm(b.dataset.confirm)) return;
act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b);
}
});
-function installFlatpak(id, btn) {
- toast(`Installing ${id}. This can take a few minutes…`);
- return act(`Install ${id}`, () => api("/api/flatpak", { action: "install", id }), btn).then(refresh);
+// 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`;
}
-$("fpInstall").onclick = () => { const id = $("fpId").value.trim(); if (id) installFlatpak(id, $("fpInstall")); };
+async function runJob(label, key, start) {
+ installing.add(key); jobsRunning++; showJobs();
+ log(label + "…");
+ try {
+ const { job } = await start();
+ for (let misses = 0; ;) {
+ await new Promise(ok => setTimeout(ok, 1500));
+ let j;
+ try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
+ catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
+ if (!j.done) continue;
+ if (j.error) throw new Error(j.error);
+ log(j.message, "ok"); toast(j.message);
+ return j.result || {};
+ }
+ } catch (e) {
+ log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
+ } finally { installing.delete(key); jobsRunning--; showJobs(); }
+}
+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) {
@@ -1123,10 +1261,26 @@ 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]);
-["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); }));
-["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); }));
-// Folders only show up as directory entries, and only during the drop event itself.
-drop.addEventListener("drop", e => {
+// 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);
@@ -1284,7 +1438,7 @@ async function loadTitles() {
let list, err;
try { list = (await api("/api/titles")).titles; } catch (e) { err = e; }
if (seq !== titlesSeq) return;
- if (err) { $("titleList").innerHTML = `