feat: generate client-side thumbnails
This commit is contained in:
@@ -1123,6 +1123,94 @@ function htmlPage(): string {
|
|||||||
})[character]);
|
})[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) {
|
function setTheme(theme) {
|
||||||
const resolvedTheme = theme === "light" ? "light" : "dark";
|
const resolvedTheme = theme === "light" ? "light" : "dark";
|
||||||
document.body.dataset.theme = resolvedTheme;
|
document.body.dataset.theme = resolvedTheme;
|
||||||
@@ -1452,6 +1540,7 @@ function htmlPage(): string {
|
|||||||
|
|
||||||
function clearGallery() {
|
function clearGallery() {
|
||||||
cancelScheduledRender();
|
cancelScheduledRender();
|
||||||
|
closeOverlayView();
|
||||||
state.photos = [];
|
state.photos = [];
|
||||||
state.visiblePhotos = [];
|
state.visiblePhotos = [];
|
||||||
state.activePhotoId = null;
|
state.activePhotoId = null;
|
||||||
@@ -1501,6 +1590,7 @@ function htmlPage(): string {
|
|||||||
function closeOverlayView() {
|
function closeOverlayView() {
|
||||||
overlay.classList.remove("open");
|
overlay.classList.remove("open");
|
||||||
overlay.setAttribute("aria-hidden", "true");
|
overlay.setAttribute("aria-hidden", "true");
|
||||||
|
overlayImage.removeAttribute("src");
|
||||||
}
|
}
|
||||||
|
|
||||||
closeOverlay.addEventListener("click", closeOverlayView);
|
closeOverlay.addEventListener("click", closeOverlayView);
|
||||||
@@ -1950,11 +2040,8 @@ function htmlPage(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readRemoteImage(entry, shareUrlValue, signal) {
|
async function readRemoteImage(entry, shareUrlValue, signal) {
|
||||||
const params = new URLSearchParams({
|
const fullUrl = buildRemoteImageUrl(shareUrlValue, entry.href);
|
||||||
share: shareUrlValue.trim(),
|
const response = await fetch(fullUrl, {
|
||||||
url: entry.href
|
|
||||||
});
|
|
||||||
const response = await fetch("/api/nextcloud/blob?" + params.toString(), {
|
|
||||||
signal
|
signal
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1966,8 +2053,8 @@ function htmlPage(): string {
|
|||||||
const capturedAt =
|
const capturedAt =
|
||||||
normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) ||
|
normalizeExifDate(tags?.DateTimeOriginal?.value ?? tags?.DateTimeOriginal) ||
|
||||||
parseDateOrNull(entry.lastModified);
|
parseDateOrNull(entry.lastModified);
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const thumbUrl = await createThumbnailObjectUrl(blob);
|
||||||
state.objectUrls.push(objectUrl);
|
state.objectUrls.push(thumbUrl);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entry.href,
|
id: entry.href,
|
||||||
@@ -1975,8 +2062,8 @@ function htmlPage(): string {
|
|||||||
latitude: gps?.latitude ?? null,
|
latitude: gps?.latitude ?? null,
|
||||||
longitude: gps?.longitude ?? null,
|
longitude: gps?.longitude ?? null,
|
||||||
capturedAt,
|
capturedAt,
|
||||||
thumbUrl: objectUrl,
|
thumbUrl,
|
||||||
fullUrl: objectUrl,
|
fullUrl,
|
||||||
source: "nextcloud"
|
source: "nextcloud"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user