fix thumbnails
This commit is contained in:
@@ -136,12 +136,12 @@ func setStretchBltMode(hdc uintptr, mode int32) {
|
||||
}
|
||||
|
||||
func bitBltWithFallback(hdcMem, hdcScreen uintptr, bounds image.Rectangle, w, h int) bool {
|
||||
return bitBlt(hdcMem, 0, 0, int32(w), int32(h), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), SRCCOPY)
|
||||
return bitBlt(hdcMem, 0, 0, int32(w), int32(h), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), SRCCOPY|CAPTUREBLT)
|
||||
}
|
||||
|
||||
func stretchBltCapture(hdcMem, hdcScreen uintptr, bounds image.Rectangle, srcW, srcH, dstW, dstH int) bool {
|
||||
setStretchBltMode(hdcMem, COLORONCOLOR)
|
||||
return stretchBlt(hdcMem, 0, 0, int32(dstW), int32(dstH), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), int32(srcW), int32(srcH), SRCCOPY)
|
||||
return stretchBlt(hdcMem, 0, 0, int32(dstW), int32(dstH), hdcScreen, int32(bounds.Min.X), int32(bounds.Min.Y), int32(srcW), int32(srcH), SRCCOPY|CAPTUREBLT)
|
||||
}
|
||||
|
||||
var dpiAwareOnce sync.Once
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
monitorsBadge,
|
||||
shortId,
|
||||
} from "./viewUtils.js";
|
||||
import { applyImageSrcSmooth } from "./thumbnail-loader.js";
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
@@ -433,6 +434,23 @@ export function createRenderer({
|
||||
return `${currentLayout}|${c.id}|${!!c.online}|${c.lastSeen}|${c.pingMs}|${c.host}|${c.user}|${c.os}|${c.arch}|${c.version}|${c.monitors}|${c.country}|${c.nickname}|${c.customTag}|${c.customTagNote}|${!!c.bookmarked}|${!!c.isAdmin}|${c.elevation}|${c.cpu}|${c.gpu}|${c.ram}|${c.hwid}|${c.disconnectReason}|${c.disconnectDetail}|${c.groupId}|${c.groupName}|${c.groupColor}|${!!c.notificationsMuted}`;
|
||||
}
|
||||
|
||||
function cardThumbDigest(c) {
|
||||
return `${!!c.hasThumbnail}|${Number(c.thumbnailVersion) || 0}`;
|
||||
}
|
||||
|
||||
function softThumbUpdate(card, client) {
|
||||
const host = card.querySelector("[data-thumb-host]");
|
||||
if (!host) return;
|
||||
const newVersion = Number(client.thumbnailVersion) || 0;
|
||||
host.dataset.thumbVersion = String(newVersion);
|
||||
host.dataset.thumbOnline = client.online ? "1" : "0";
|
||||
if (!client.hasThumbnail) return;
|
||||
const img = host.querySelector("img[data-thumb-img]");
|
||||
if (!img) return;
|
||||
const url = `/api/clients/${encodeURIComponent(client.id)}/thumbnail${newVersion ? `?v=${newVersion}` : ""}`;
|
||||
applyImageSrcSmooth(img, url);
|
||||
}
|
||||
|
||||
function renderMerge(data, options = {}) {
|
||||
setupGridDelegation();
|
||||
ensureLayoutScaffold();
|
||||
@@ -463,9 +481,18 @@ export function createRenderer({
|
||||
if (existing) {
|
||||
if (existing.classList.contains("card-uninstalling")) return;
|
||||
const digest = cardDigest(client);
|
||||
if (existing._cardDigest === digest) return;
|
||||
existing._cardDigest = digest;
|
||||
updateCard(existing, client);
|
||||
const thumbDigest = cardThumbDigest(client);
|
||||
const structureChanged = existing._cardDigest !== digest;
|
||||
const thumbChanged = existing._thumbDigest !== thumbDigest;
|
||||
if (!structureChanged && !thumbChanged) return;
|
||||
if (structureChanged) {
|
||||
existing._cardDigest = digest;
|
||||
existing._thumbDigest = thumbDigest;
|
||||
updateCard(existing, client);
|
||||
} else {
|
||||
existing._thumbDigest = thumbDigest;
|
||||
softThumbUpdate(existing, client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
newClients.push(client);
|
||||
@@ -550,6 +577,7 @@ export function createRenderer({
|
||||
: buildRowA(client, options);
|
||||
|
||||
node._cardDigest = cardDigest(client);
|
||||
node._thumbDigest = cardThumbDigest(client);
|
||||
|
||||
if (options.animate && currentLayout !== "table") {
|
||||
node.classList.add("card-animate");
|
||||
|
||||
@@ -794,7 +794,7 @@ if (window.CodeMirror && scriptEditor) {
|
||||
tabSize: 2,
|
||||
lineWrapping: true,
|
||||
});
|
||||
editorInstance.setSize(null, "100%");
|
||||
editorInstance.setSize(null, "24rem");
|
||||
window._vbCodeMirror = editorInstance;
|
||||
}
|
||||
|
||||
@@ -803,6 +803,36 @@ const modeToggleVisual = document.getElementById("mode-toggle-visual");
|
||||
const codeEditorSection = document.getElementById("code-editor-section");
|
||||
const visualBuilderSection = document.getElementById("visual-builder-section");
|
||||
const rightColumn = codeEditorSection?.parentElement?.querySelector(".lg\\:col-span-1");
|
||||
|
||||
const lgMediaQuery = window.matchMedia("(min-width: 1024px)");
|
||||
function syncEditorHeight() {
|
||||
if (!editorInstance || !codeEditorSection || !rightColumn) return;
|
||||
if (codeEditorSection.classList.contains("hidden")) return;
|
||||
if (!lgMediaQuery.matches) {
|
||||
editorInstance.setSize(null, "24rem");
|
||||
return;
|
||||
}
|
||||
const rightHeight = rightColumn.getBoundingClientRect().height;
|
||||
const card = codeEditorSection.querySelector(":scope > div");
|
||||
if (!card) return;
|
||||
const cs = getComputedStyle(card);
|
||||
const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
|
||||
const header = card.querySelector(":scope > .flex.items-center.justify-between");
|
||||
const headerH = header ? header.getBoundingClientRect().height : 0;
|
||||
const headerStyle = header ? getComputedStyle(header) : null;
|
||||
const headerMb = headerStyle ? parseFloat(headerStyle.marginBottom) || 0 : 0;
|
||||
const target = Math.max(384, rightHeight - padY - headerH - headerMb);
|
||||
editorInstance.setSize(null, `${target}px`);
|
||||
}
|
||||
if (editorInstance && rightColumn) {
|
||||
syncEditorHeight();
|
||||
if (typeof ResizeObserver === "function") {
|
||||
const ro = new ResizeObserver(() => syncEditorHeight());
|
||||
ro.observe(rightColumn);
|
||||
}
|
||||
window.addEventListener("resize", syncEditorHeight);
|
||||
lgMediaQuery.addEventListener?.("change", syncEditorHeight);
|
||||
}
|
||||
let visualBuilderInited = false;
|
||||
|
||||
function setMode(mode) {
|
||||
@@ -825,7 +855,10 @@ function setMode(mode) {
|
||||
visualBuilderSection?.classList.add("hidden");
|
||||
modeToggleCode.className = "flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm transition-colors bg-emerald-600 text-white";
|
||||
modeToggleVisual.className = "flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-semibold text-sm transition-colors bg-slate-800 text-slate-300 hover:bg-slate-700";
|
||||
if (editorInstance) editorInstance.refresh();
|
||||
if (editorInstance) {
|
||||
editorInstance.refresh();
|
||||
syncEditorHeight();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,24 @@ function buildThumbnailUrl(clientId, version) {
|
||||
return version > 0 ? `${base}?v=${version}` : base;
|
||||
}
|
||||
|
||||
export function applyImageSrcSmooth(img, url) {
|
||||
if (!img || !url) return;
|
||||
if (img.dataset.thumbUrl === url) return;
|
||||
const newImg = img.cloneNode(false);
|
||||
newImg.dataset.thumbUrl = url;
|
||||
newImg.style.display = "block";
|
||||
const swap = () => {
|
||||
if (img.parentNode && img.dataset.thumbUrl !== url) img.replaceWith(newImg);
|
||||
};
|
||||
newImg.addEventListener("error", () => {}, { once: true });
|
||||
newImg.src = url;
|
||||
if (typeof newImg.decode === "function") {
|
||||
newImg.decode().then(swap).catch(() => {});
|
||||
} else {
|
||||
newImg.addEventListener("load", swap, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
export class ThumbnailLoader {
|
||||
constructor(options = {}) {
|
||||
this.refreshIntervalMs = Math.max(500, Number(options.refreshIntervalMs) || DEFAULT_REFRESH_MS);
|
||||
@@ -147,10 +165,7 @@ export class ThumbnailLoader {
|
||||
const img = element.querySelector("img[data-thumb-img]");
|
||||
if (!img) return;
|
||||
const url = buildThumbnailUrl(rec.clientId, rec.version);
|
||||
if (img.dataset.thumbUrl !== url) {
|
||||
img.dataset.thumbUrl = url;
|
||||
img.src = url;
|
||||
}
|
||||
applyImageSrcSmooth(img, url);
|
||||
}
|
||||
|
||||
handleIntersection(entries) {
|
||||
|
||||
@@ -94,8 +94,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Script Editor -->
|
||||
<div id="code-editor-section" class="lg:col-span-2">
|
||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 h-full flex flex-col">
|
||||
<div id="code-editor-section" class="lg:col-span-2 min-h-0">
|
||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 h-full flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Script Editor</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -119,14 +119,14 @@
|
||||
</div>
|
||||
<textarea
|
||||
id="script-editor"
|
||||
class="w-full flex-1 min-h-[24rem] px-4 py-3 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
class="w-full flex-1 min-h-[24rem] px-4 py-3 rounded-lg border border-slate-700 bg-slate-950 text-slate-100 font-mono text-sm resize-none overflow-auto focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder="Enter your script here..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Scripts + Templates (right column) -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="lg:col-span-1 self-start">
|
||||
<div class="bg-slate-900/70 rounded-xl border border-slate-800 p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Saved Scripts</h2>
|
||||
<div class="flex gap-2 mb-3">
|
||||
|
||||
@@ -253,9 +253,10 @@ export async function handleClientRoutes(
|
||||
if (!canUserAccessClient(user.userId, user.role, clientId)) {
|
||||
return new Response("Forbidden: Client access denied", { status: 403 });
|
||||
}
|
||||
const { generateThumbnail, markThumbnailRequested, getThumbnailVersion } = await import("../../thumbnails");
|
||||
const { requestThumbnailRegen, markThumbnailRequested, getThumbnailVersion, waitForThumbnail, clearThumbnailRequest } = await import("../../thumbnails");
|
||||
markThumbnailRequested(clientId);
|
||||
const target = clientManager.getClient(clientId);
|
||||
const beforeVersion = getThumbnailVersion(clientId);
|
||||
if (target?.online) {
|
||||
const commandId = uuidv4();
|
||||
target.ws.send(
|
||||
@@ -268,10 +269,16 @@ export async function handleClientRoutes(
|
||||
);
|
||||
metrics.recordCommand("screenshot");
|
||||
}
|
||||
const success = await generateThumbnail(clientId);
|
||||
const fresh = target?.online ? await waitForThumbnail(clientId, 2500) : false;
|
||||
if (fresh) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
} else {
|
||||
await requestThumbnailRegen(clientId);
|
||||
}
|
||||
clearThumbnailRequest(clientId);
|
||||
const version = getThumbnailVersion(clientId);
|
||||
return Response.json(
|
||||
{ ok: true, updated: success, version },
|
||||
{ ok: true, updated: version > beforeVersion, version },
|
||||
{ headers: deps.CORS_HEADERS },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,9 @@ export async function generateThumbnail(id: string): Promise<boolean> {
|
||||
updatedAt: now,
|
||||
});
|
||||
evictThumbnailsIfFull();
|
||||
latestFrames.delete(id);
|
||||
if (latestFrames.get(id) === frameData) {
|
||||
latestFrames.delete(id);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[thumbnails] Failed to generate thumbnail for client ${id}:`, err);
|
||||
@@ -136,10 +138,54 @@ export async function generateThumbnail(id: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailGenState = new Map<string, { inFlight: boolean; pending: boolean }>();
|
||||
|
||||
export async function requestThumbnailRegen(id: string): Promise<boolean> {
|
||||
let state = thumbnailGenState.get(id);
|
||||
if (!state) {
|
||||
state = { inFlight: false, pending: false };
|
||||
thumbnailGenState.set(id, state);
|
||||
}
|
||||
if (state.inFlight) {
|
||||
state.pending = true;
|
||||
return false;
|
||||
}
|
||||
state.inFlight = true;
|
||||
let didGenerate = false;
|
||||
try {
|
||||
while (true) {
|
||||
state.pending = false;
|
||||
const ok = await generateThumbnail(id);
|
||||
if (ok) didGenerate = true;
|
||||
if (!state.pending) break;
|
||||
}
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
if (thumbnailGenState.get(id) === state && !state.pending) {
|
||||
thumbnailGenState.delete(id);
|
||||
}
|
||||
}
|
||||
return didGenerate;
|
||||
}
|
||||
|
||||
export function markThumbnailRequested(id: string) {
|
||||
thumbnailRequests.set(id, Date.now());
|
||||
}
|
||||
|
||||
export function isThumbnailRequested(id: string, windowMs = 5000): boolean {
|
||||
const ts = thumbnailRequests.get(id);
|
||||
if (!ts) return false;
|
||||
if (Date.now() - ts > windowMs) {
|
||||
thumbnailRequests.delete(id);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearThumbnailRequest(id: string) {
|
||||
thumbnailRequests.delete(id);
|
||||
}
|
||||
|
||||
export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean {
|
||||
const ts = thumbnailRequests.get(id);
|
||||
if (!ts) return false;
|
||||
@@ -150,3 +196,40 @@ export function consumeThumbnailRequest(id: string, windowMs = 5000): boolean {
|
||||
thumbnailRequests.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const thumbnailWaiters = new Map<string, Set<() => void>>();
|
||||
|
||||
export function notifyThumbnailGenerated(id: string) {
|
||||
const waiters = thumbnailWaiters.get(id);
|
||||
if (!waiters) return;
|
||||
thumbnailWaiters.delete(id);
|
||||
for (const cb of waiters) {
|
||||
try { cb(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForThumbnail(id: string, timeoutMs = 2500): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let callback: () => void;
|
||||
const finish = (fresh: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
const set = thumbnailWaiters.get(id);
|
||||
if (set) {
|
||||
set.delete(callback);
|
||||
if (set.size === 0) thumbnailWaiters.delete(id);
|
||||
}
|
||||
resolve(fresh);
|
||||
};
|
||||
callback = () => finish(true);
|
||||
const timer = setTimeout(() => finish(false), timeoutMs);
|
||||
let set = thumbnailWaiters.get(id);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
thumbnailWaiters.set(id, set);
|
||||
}
|
||||
set.add(callback);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,10 +9,11 @@ async function getGeoip() {
|
||||
}
|
||||
import { ClientInfo } from "./types";
|
||||
import {
|
||||
consumeThumbnailRequest,
|
||||
isThumbnailRequested,
|
||||
hasThumbnail,
|
||||
generateThumbnail,
|
||||
setLatestFrame,
|
||||
notifyThumbnailGenerated,
|
||||
requestThumbnailRegen,
|
||||
} from "./thumbnails";
|
||||
import { upsertClientRow, type ClientDbRow } from "./db";
|
||||
import { metrics } from "./metrics";
|
||||
@@ -296,10 +297,12 @@ export function handleFrame(info: ClientInfo, payload: any) {
|
||||
|
||||
if (safeFormat) {
|
||||
const now = Date.now();
|
||||
const thumbnailRequested = consumeThumbnailRequest(info.id);
|
||||
const thumbnailRequested = isThumbnailRequested(info.id);
|
||||
if (thumbnailRequested || !hasThumbnail(info.id)) {
|
||||
setLatestFrame(info.id, bytes, safeFormat);
|
||||
void generateThumbnail(info.id);
|
||||
void requestThumbnailRegen(info.id).then((ok) => {
|
||||
if (ok) notifyThumbnailGenerated(info.id);
|
||||
});
|
||||
}
|
||||
info.lastSeen = now;
|
||||
info.online = true;
|
||||
|
||||
Reference in New Issue
Block a user