From 8cff20c13eb85b093aee5738bb58f62fda872ae1 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 29 Jun 2026 00:17:04 +0800 Subject: [PATCH] feat(plugin): thumbnail previews for screenshot picker The screenshot picker now shows a live thumbnail next to each recent Steam screenshot so users can identify the right one before selecting. - read_screenshot() Python RPC: returns a base64 data URL for inline display. Tries Pillow downscale (320px), falls back to raw bytes (Steam Deck screenshots are ~78KB so this is fine without Pillow). - Thumbnail component: 56x56 rounded image with loading placeholder. - Thumbnails load progressively in both the picker list and the selected-shots rows via a useEffect. --- plugins/decky-vault/main.py | 33 +++++++++ .../src/components/session-form.tsx | 67 +++++++++++++++++-- plugins/decky-vault/src/lib/api.ts | 5 ++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/plugins/decky-vault/main.py b/plugins/decky-vault/main.py index 838f4db..2e92f7d 100644 --- a/plugins/decky-vault/main.py +++ b/plugins/decky-vault/main.py @@ -646,6 +646,39 @@ exec mangohud "$@" except Exception as e: return {"screenshots": [], "error": str(e)} + async def read_screenshot(self, path: str, max_width: int = 320) -> dict: + """RPC: Read a screenshot file and return it as a base64 data URL, + downscaled to a thumbnail for preview display in the plugin UI. + Returns {dataUrl: str, error?: str}.""" + import base64 + try: + if not os.path.exists(path): + return {"dataUrl": "", "error": "File not found"} + with open(path, "rb") as f: + raw = f.read() + # Downscale via Pillow if available, else return raw bytes as-is + try: + from PIL import Image + import io + img = Image.open(io.BytesIO(raw)) + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + if img.width > max_width: + ratio = max_width / float(img.width) + img = img.resize((max_width, int(img.height * ratio))) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=70) + b64 = base64.b64encode(buf.getvalue()).decode("ascii") + return {"dataUrl": f"data:image/jpeg;base64,{b64}"} + except ImportError: + # No Pillow — return the raw file as a data URL + ext = os.path.splitext(path)[1].lower() + mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg") + b64 = base64.b64encode(raw).decode("ascii") + return {"dataUrl": f"data:{mime};base64,{b64}"} + except Exception as e: + return {"dataUrl": "", "error": str(e)} + async def upload_screenshots(self, entry_id: str, screenshot_paths: list, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict: """RPC: Upload up to 2 screenshots to a performance entry as multipart/form-data. Returns {success: bool, uploaded: int, error?: str, status?: int}. diff --git a/plugins/decky-vault/src/components/session-form.tsx b/plugins/decky-vault/src/components/session-form.tsx index 414bdd8..9cab570 100644 --- a/plugins/decky-vault/src/components/session-form.tsx +++ b/plugins/decky-vault/src/components/session-form.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { useState, useEffect } from "react" import { ButtonItem, PanelSection, @@ -24,6 +24,7 @@ import { exportToFile, uploadToDeckyvault, listScreenshots, + readScreenshot, uploadScreenshots, } from "../lib/api" @@ -77,6 +78,41 @@ function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } +function Thumbnail({ src, size = 56 }: { src?: string; size?: number }) { + if (src) { + return ( + + ) + } + return ( +
+ +
+ ) +} + export default function SessionForm({ session, error, @@ -96,6 +132,8 @@ export default function SessionForm({ const [availableShots, setAvailableShots] = useState([]) const [shotsLoading, setShotsLoading] = useState(false) const [shotsError, setShotsError] = useState("") + // path → base64 data URL thumbnail for preview display + const [thumbnails, setThumbnails] = useState>({}) async function openPicker() { if (selectedShots.length >= MAX_SCREENSHOTS) return @@ -108,9 +146,30 @@ export default function SessionForm({ setShotsError(result.error) } // Filter out already-selected paths - setAvailableShots(result.screenshots.filter((s) => !selectedShots.some((sel) => sel.path === s.path))) + const list = result.screenshots.filter((s) => !selectedShots.some((sel) => sel.path === s.path)) + setAvailableShots(list) + // Load thumbnails for the list (and any selected shots not yet loaded) + loadThumbnails([...list, ...selectedShots]) } + async function loadThumbnails(shots: ScreenshotFile[]) { + for (const shot of shots) { + if (thumbnails[shot.path]) continue // already loaded + const res = await readScreenshot(shot.path, 320) + if (res.dataUrl) { + setThumbnails((prev) => ({ ...prev, [shot.path]: res.dataUrl })) + } + } + } + + // Load thumbnails for any selected shots without one yet + useEffect(() => { + if (selectedShots.length > 0) { + loadThumbnails(selectedShots) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedShots.map((s) => s.path).join(",")]) + function addShot(shot: ScreenshotFile) { if (selectedShots.length >= MAX_SCREENSHOTS) return setSelectedShots((prev) => [...prev, shot]) @@ -305,7 +364,7 @@ export default function SessionForm({ background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.10)", }}> - +
{shot.name} @@ -381,7 +440,7 @@ export default function SessionForm({ addShot(shot)}>
- +
{formatShotTime(shot.mtime)} diff --git a/plugins/decky-vault/src/lib/api.ts b/plugins/decky-vault/src/lib/api.ts index 483e9b6..4881a5f 100644 --- a/plugins/decky-vault/src/lib/api.ts +++ b/plugins/decky-vault/src/lib/api.ts @@ -86,6 +86,11 @@ export const listScreenshots = callable<[limit?: number], { error?: string }>("list_screenshots") +export const readScreenshot = callable<[path: string, maxWidth?: number], { + dataUrl: string + error?: string +}>("read_screenshot") + export const uploadScreenshots = callable<[ entryId: string, screenshotPaths: string[],