From 47a29afb4ce047df7f63bafdaacb846cb314edad Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:19:03 +1000 Subject: [PATCH] Screenshots: recheck fixes - In Control, a right-click on the viewer goes to the Frame only; the copy menu stays out of the way. - Thumbnails no longer hold up the next check: a save shows as saved straight away, and a new shot appears while older previews are still loading. - Refresh (or a save) during a background check reads again after it, so the answer is fresh. - A preview that failed is retried on Refresh, not by every background check. - Copy reports a failure if the app refuses the image, and if the browser can't copy text. - Windows: Show in File Explorer works when the path has spaces. Co-Authored-By: Claude Opus 5.5 (1M context) --- ui/frame_host.py | 2 +- ui/index.html | 44 +++++++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/ui/frame_host.py b/ui/frame_host.py index beb5f8c..7562d42 100644 --- a/ui/frame_host.py +++ b/ui/frame_host.py @@ -159,7 +159,7 @@ def reveal_path(path): if MAC: cmd = ["open", "-R", str(path)] elif WINDOWS: - cmd = ["explorer", f"/select,{path}"] + cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma else: return open_path(path.parent) subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, diff --git a/ui/index.html b/ui/index.html index 1c19901..e1c280a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2029,9 +2029,9 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) : const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob : new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png")))); $("copyBtn").onclick = copyViewer; -// Right-click the viewer to copy or save what it shows. +// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame). $("canvas").oncontextmenu = e => { - if ($("saveBtn").disabled) return; + if (ctrl.on || $("saveBtn").disabled) return; e.preventDefault(); showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]); }; @@ -2039,7 +2039,7 @@ $("canvas").oncontextmenu = e => { // takes PNG only, and the blob is handed over as a promise so the click still counts. async function copyImage(blob) { if (window.frameApp?.writeImage) { - await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer())); + if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image"); return { message: "Copied the image" }; } if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here"); @@ -3534,7 +3534,7 @@ api("/api/host").then(applyHostWording).catch(() => {}); // ---- Steam screenshots from the headset ---- // thumbs: id -> promise of an object URL, kept across reloads so a refresh // that finds a new shot fetches only that one's thumbnail. -const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0 }; +const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 }; const STEAMVR_APPID = "250820"; const SHOTS_POLL_MS = 8000; function shotApp(appid) { @@ -3559,15 +3559,18 @@ function shotThumb(id) { // quiet: a background check. It keeps what's shown if the Frame can't be read, // and redraws only when the shots (or whether they're saved here) changed. function loadShots(quiet) { - if (!shots.loading) { - const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; }); - shots.loading = p; + if (shots.loading) { + // A check already on its way will do for another check; Refresh, or a save + // that just finished, reads again after it so the answer is a fresh one. + const again = () => loadShots(); + return quiet === true ? shots.loading : shots.loading.then(again, again); } - return shots.loading; + const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; }); + return shots.loading = p; } // Forget the shots of a headset we've switched away from, and ignore its answers still on their way. function resetShots() { - shots.gen++; shots.loading = null; shots.list = []; shots.sig = null; + shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null; for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {}); shots.thumbs = new Map(); } @@ -3585,11 +3588,18 @@ async function readShots(quiet) { ({ shots: shots.list, folder: shots.folder } = got); const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)])); if (sig !== shots.sig) { shots.sig = sig; drawShots(); } - // Thumbnails one at a time over the shared SSH connection; one that failed is tried again next time. - for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])")) { + fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails +} +// Thumbnails one at a time over the shared SSH connection. One that failed is +// tried again on Refresh, not by every background check. +async function fillThumbs(quiet) { + const run = ++shots.fill; + for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) { const s = shots.list[+img.dataset.shot]; - try { img.src = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; } - if (!img.isConnected || gen !== shots.gen) return; // redrawn, or another headset, meanwhile + let url; + try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; } + if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over + if (url) img.src = url; } } // "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip). @@ -3710,16 +3720,20 @@ $("shotGrid").oncontextmenu = e => { items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`, () => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]); items.push(["Copy file path", () => act("Copy file path", async () => { - await navigator.clipboard.writeText(shots.folder + sep + s.file); + await copyText(shots.folder + sep + s.file); return { message: "Copied the file path" }; })]); } items.push(null, ["Copy file name", () => act("Copy file name", async () => { - await navigator.clipboard.writeText(s.file); + await copyText(s.file); return { message: "Copied " + s.file }; })]); showMenu(e, items); }; +function copyText(text) { + if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here"); + return navigator.clipboard.writeText(text); +} $("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);