From 7193457f36afc8b4d85c407b1e4d5723866baa3e Mon Sep 17 00:00:00 2001 From: Arne Baeumler Date: Sat, 13 Jun 2026 07:23:33 +0200 Subject: [PATCH] feat: generate client-side thumbnails --- src/server/request-handler.ts | 105 +++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/src/server/request-handler.ts b/src/server/request-handler.ts index 14b173b..a7da8d1 100644 --- a/src/server/request-handler.ts +++ b/src/server/request-handler.ts @@ -1123,6 +1123,94 @@ function htmlPage(): string { })[character]); } + function buildRemoteImageUrl(shareUrlValue, entryHref) { + const params = new URLSearchParams({ + share: shareUrlValue.trim(), + url: entryHref + }); + return "/api/nextcloud/blob?" + params.toString(); + } + + async function loadImageElement(blob) { + const objectUrl = URL.createObjectURL(blob); + const image = new Image(); + + try { + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Could not decode image.")); + image.src = objectUrl; + }); + return image; + } finally { + URL.revokeObjectURL(objectUrl); + } + } + + async function createThumbnailObjectUrl(blob) { + const maxDimension = 320; + const quality = 0.82; + let width = 0; + let height = 0; + let drawSource = null; + let closeBitmap = null; + let sourceImage = null; + + if (typeof createImageBitmap === "function") { + const bitmap = await createImageBitmap(blob); + width = bitmap.width; + height = bitmap.height; + drawSource = bitmap; + closeBitmap = () => bitmap.close(); + } else { + sourceImage = await loadImageElement(blob); + width = sourceImage.naturalWidth; + height = sourceImage.naturalHeight; + drawSource = sourceImage; + } + + const scale = Math.min(1, maxDimension / Math.max(width, height)); + const targetWidth = Math.max(1, Math.round(width * scale)); + const targetHeight = Math.max(1, Math.round(height * scale)); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + + if (!context) { + if (closeBitmap) { + closeBitmap(); + } + throw new Error("Canvas 2D context unavailable."); + } + + canvas.width = targetWidth; + canvas.height = targetHeight; + context.drawImage(drawSource, 0, 0, targetWidth, targetHeight); + + const thumbnailBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => { + if (result) { + resolve(result); + return; + } + reject(new Error("Could not create thumbnail.")); + }, + "image/jpeg", + quality + ); + }); + + if (closeBitmap) { + closeBitmap(); + } + + if (sourceImage) { + sourceImage.removeAttribute("src"); + } + + return URL.createObjectURL(thumbnailBlob); + } + function setTheme(theme) { const resolvedTheme = theme === "light" ? "light" : "dark"; document.body.dataset.theme = resolvedTheme; @@ -1452,6 +1540,7 @@ function htmlPage(): string { function clearGallery() { cancelScheduledRender(); + closeOverlayView(); state.photos = []; state.visiblePhotos = []; state.activePhotoId = null; @@ -1501,6 +1590,7 @@ function htmlPage(): string { function closeOverlayView() { overlay.classList.remove("open"); overlay.setAttribute("aria-hidden", "true"); + overlayImage.removeAttribute("src"); } closeOverlay.addEventListener("click", closeOverlayView); @@ -1950,11 +2040,8 @@ function htmlPage(): string { } async function readRemoteImage(entry, shareUrlValue, signal) { - const params = new URLSearchParams({ - share: shareUrlValue.trim(), - url: entry.href - }); - const response = await fetch("/api/nextcloud/blob?" + params.toString(), { + const fullUrl = buildRemoteImageUrl(shareUrlValue, entry.href); + const response = await fetch(fullUrl, { signal }); if (!response.ok) { @@ -1966,8 +2053,8 @@ function htmlPage(): string { const capturedAt = normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) || parseDateOrNull(entry.lastModified); - const objectUrl = URL.createObjectURL(blob); - state.objectUrls.push(objectUrl); + const thumbUrl = await createThumbnailObjectUrl(blob); + state.objectUrls.push(thumbUrl); return { id: entry.href, @@ -1975,8 +2062,8 @@ function htmlPage(): string { latitude: gps?.latitude ?? null, longitude: gps?.longitude ?? null, capturedAt, - thumbUrl: objectUrl, - fullUrl: objectUrl, + thumbUrl, + fullUrl, source: "nextcloud" }; }