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.
This commit is contained in:
2026-06-29 00:17:04 +08:00
parent 59ecb4e4a6
commit 8cff20c13e
3 changed files with 101 additions and 4 deletions
+33
View File
@@ -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}.
@@ -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 (
<img
src={src}
alt=""
style={{
width: size,
height: size,
objectFit: "cover",
borderRadius: "6px",
flexShrink: 0,
background: "#000",
}}
/>
)
}
return (
<div
style={{
width: size,
height: size,
borderRadius: "6px",
flexShrink: 0,
background: "rgba(255,255,255,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FaImages style={{ opacity: 0.4 }} />
</div>
)
}
export default function SessionForm({
session,
error,
@@ -96,6 +132,8 @@ export default function SessionForm({
const [availableShots, setAvailableShots] = useState<ScreenshotFile[]>([])
const [shotsLoading, setShotsLoading] = useState(false)
const [shotsError, setShotsError] = useState("")
// path → base64 data URL thumbnail for preview display
const [thumbnails, setThumbnails] = useState<Record<string, string>>({})
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)",
}}>
<FaImages style={{ opacity: 0.6, flexShrink: 0 }} />
<Thumbnail src={thumbnails[shot.path]} />
<div className={staticClasses.Text} style={{ flex: 1, minWidth: 0, fontSize: "12px" }}>
<div style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{shot.name}
@@ -381,7 +440,7 @@ export default function SessionForm({
<PanelSectionRow key={shot.path}>
<ButtonItem layout="below" onClick={() => addShot(shot)}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaImages style={{ opacity: 0.6, flexShrink: 0 }} />
<Thumbnail src={thumbnails[shot.path]} />
<div style={{ flex: 1, minWidth: 0, textAlign: "left" }}>
<div style={{ fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{formatShotTime(shot.mtime)}
+5
View File
@@ -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[],