mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
iPhone and iPad app: the same features, served from the Frame
An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI) connects with its own SSH key (Citadel), copies the server and helpers to ~/.cache/frame-control/<version> on the Frame once per version, starts ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the page through an SSH tunnel. The server exits when the phone disconnects. Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each `ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and phone share one code path. Android display goes through podman exec there, as the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a per-session key. Power actions take the Developer Mode password via sudo -S. --port 0 now prints the port it took. Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop opening in their iOS apps, and a password dialog for power. App: pairing with the Developer Mode password once (never stored) or with a key the user adds; host key pinned on first use; plain-language connection errors with quiet retries; frame-control://install links; alerts and confirms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0f770dc88a
commit
13187e8f6a
33 files changed
+2459
-34
No files matched your search
+135
-26
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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. */
|
||||
@@ -61,7 +61,16 @@
|
||||
.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: 0 18px; } nav a { padding: 0 10px; } }
|
||||
@media (max-width: 820px) { .brand b, #battChip { display: none; } }
|
||||
@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; }
|
||||
/* 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; } }
|
||||
|
||||
|
||||
/* ---- pages: one per tab; the header nav switches between them ---- */
|
||||
.page { display: flex; flex-direction: column; gap: 26px; }
|
||||
@@ -114,7 +123,7 @@
|
||||
.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], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
input[type=text], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; padding: 9px 11px; font: inherit; }
|
||||
textarea { resize: vertical; min-height: 76px; }
|
||||
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
|
||||
@@ -247,10 +256,10 @@
|
||||
.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 { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg { 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 { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg 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;
|
||||
@@ -313,6 +322,41 @@
|
||||
.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; }
|
||||
/* ---- phones: tabs move to a bottom bar, as in iOS apps; the page clears the notch and home indicator ---- */
|
||||
@media (max-width: 640px) {
|
||||
/* 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) 14px 0; 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-bottom: env(safe-area-inset-bottom);
|
||||
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: 14px; right: 14px; max-width: none; bottom: calc(70px + env(safe-area-inset-bottom)); }
|
||||
main, .page { gap: 16px; }
|
||||
main { padding: 12px 12px 20px; }
|
||||
.panel { padding: 14px; }
|
||||
.grid-top, .grid-3, .and-grid, .and-col { gap: 14px; }
|
||||
.toolbar { gap: 8px; }
|
||||
.toolbar .spacer { display: none; }
|
||||
.shelf-head { flex-wrap: wrap; row-gap: 8px; }
|
||||
.shelf { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }
|
||||
.cat-grid { grid-template-columns: 1fr; }
|
||||
.shot-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.and-btns { padding-left: 0; }
|
||||
.banner { flex-wrap: wrap; }
|
||||
.banner .s { white-space: normal; }
|
||||
button { height: 38px; }
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -327,10 +371,10 @@
|
||||
<b>FRAME CONTROL</b>
|
||||
</a>
|
||||
<nav id="nav">
|
||||
<a href="#home" class="on" title="Headset view, status and screenshots (1)">Home<kbd>1</kbd></a>
|
||||
<a href="#games" title="Steam games and sideloaded titles (2)">Games<kbd>2</kbd></a>
|
||||
<a href="#android" title="Android apps and their display (3)">Android<kbd>3</kbd></a>
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)">Tools<kbd>4</kbd></a>
|
||||
<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>
|
||||
@@ -547,10 +591,10 @@
|
||||
<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>Drop files here, or click to choose</b>
|
||||
<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.
|
||||
You can also drop files anywhere in this window.
|
||||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||||
<input type="file" id="fileInput" multiple hidden>
|
||||
</div>
|
||||
<div class="progress" id="prog"><i></i></div>
|
||||
@@ -585,8 +629,10 @@
|
||||
<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">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="links">
|
||||
<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>
|
||||
@@ -661,10 +707,23 @@
|
||||
<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";
|
||||
@@ -711,14 +770,32 @@ $("bottombar").onclick = () => {
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
$("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = `Save new to ${h.computer}`;
|
||||
// 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": "1"} } : {
|
||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": "1"}, body: JSON.stringify(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("Frame Control's local server isn't running. Restart the app (Frame → Restart Server)."); }
|
||||
@@ -1005,7 +1082,7 @@ async function capture() {
|
||||
let url = null;
|
||||
try {
|
||||
const r = await fetch(view === "headset" ? "/api/screenshot?view=headset" : "/api/screenshot",
|
||||
{ headers: {"X-Frame-UI": "1"} });
|
||||
{ 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());
|
||||
@@ -1104,7 +1181,7 @@ async function startVideo() {
|
||||
},
|
||||
error: e => log("Video decoder: " + e.message, "e"),
|
||||
});
|
||||
const r = await fetch(`/api/stream?${STREAM_QUERY}`, { headers: {"X-Frame-UI": "1"}, signal: ctl.signal });
|
||||
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;
|
||||
@@ -1159,6 +1236,7 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
|
||||
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;
|
||||
@@ -1182,8 +1260,12 @@ document.body.addEventListener("click", async (e) => {
|
||||
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;
|
||||
act(b.textContent.trim(), () => api("/api/open", { what: b.dataset.open }), b);
|
||||
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
|
||||
@@ -1214,6 +1296,29 @@ async function runJob(label, key, start) {
|
||||
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…`);
|
||||
@@ -1289,7 +1394,7 @@ function upload(file, mode) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/upload");
|
||||
xhr.setRequestHeader("X-Frame-UI", "1");
|
||||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader("X-Mode", mode);
|
||||
const bar = $("prog").firstElementChild;
|
||||
@@ -1915,7 +2020,7 @@ function shotApp(appid) {
|
||||
}
|
||||
async function shotBlob(id, thumb) {
|
||||
const r = await fetch(`/api/shots/image?id=${encodeURIComponent(id)}${thumb ? "&thumb=1" : ""}`,
|
||||
{ headers: {"X-Frame-UI": "1"} });
|
||||
{ headers: {"X-Frame-UI": UI_KEY} });
|
||||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||
return r.blob();
|
||||
}
|
||||
@@ -1928,14 +2033,14 @@ async function loadShots() {
|
||||
} 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 ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
|
||||
$("shotsSaveNew").disabled = !unsaved;
|
||||
$("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 ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
|
||||
${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.
|
||||
@@ -1975,6 +2080,10 @@ async function openShot(s) {
|
||||
}
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user