mirror of
https://github.com/immich-app/immich.git
synced 2026-01-26 03:14:39 -08:00
Compare commits
9 Commits
fix/reconc
...
push-zpwso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014defb27c | ||
|
|
3737e4cb19 | ||
|
|
ef25c9ce67 | ||
|
|
7139757383 | ||
|
|
a9a5ad4efb | ||
|
|
2fb2e5d7ae | ||
|
|
66bac749e3 | ||
|
|
fdbf139f3d | ||
|
|
cfada75442 |
@@ -1,10 +1,7 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
@@ -26,31 +23,44 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
await expect(original).toBeInViewport();
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await thumbnail.getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
@@ -103,11 +103,8 @@ export const thumbnailUtils = {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
|
||||
341
web/src/app.css
341
web/src/app.css
@@ -74,6 +74,21 @@
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
|
||||
/* transitions */
|
||||
--immich-split-viewer-nav: enabled;
|
||||
|
||||
/* view transition variables */
|
||||
--vt-duration-default: 250ms;
|
||||
--vt-duration-hero: 200ms;
|
||||
--vt-duration-viewer-navigation: 200ms;
|
||||
--vt-duration-slideshow: 1s;
|
||||
--vt-viewer-slide-easing: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
--vt-viewer-slide-distance: 15%;
|
||||
--vt-viewer-opacity-start: 0.1;
|
||||
--vt-viewer-opacity-mid: 0.4;
|
||||
--vt-viewer-blur-max: 4px;
|
||||
--vt-viewer-blur-mid: 2px;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -171,3 +186,329 @@
|
||||
@apply bg-subtle rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::view-transition {
|
||||
background: var(--color-black);
|
||||
animation-duration: var(--vt-duration-default);
|
||||
}
|
||||
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
mix-blend-mode: normal;
|
||||
animation-duration: inherit;
|
||||
}
|
||||
|
||||
::view-transition-old(*) {
|
||||
animation-name: fadeOut;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
::view-transition-new(*) {
|
||||
animation-name: fadeIn;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
::view-transition-old(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
html:active-view-transition-type(slideshow) {
|
||||
&::view-transition-old(root) {
|
||||
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::view-transition-old(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: var(--vt-duration-hero) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
::view-transition-old(info) {
|
||||
animation: var(--vt-duration-default) 0s flyOutRight forwards;
|
||||
}
|
||||
::view-transition-new(info) {
|
||||
animation: var(--vt-duration-default) 0s flyInRight forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(detail-panel) {
|
||||
z-index: 1;
|
||||
}
|
||||
::view-transition-old(detail-panel),
|
||||
::view-transition-new(detail-panel) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
animation-duration: var(--vt-duration-viewer-navigation);
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom) {
|
||||
background-color: var(--color-black);
|
||||
}
|
||||
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right) {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
background-color: var(--color-black);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
::view-transition-group(exclude-leftbutton),
|
||||
::view-transition-group(exclude-rightbutton),
|
||||
::view-transition-group(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
::view-transition-old(exclude-leftbutton),
|
||||
::view-transition-old(exclude-rightbutton),
|
||||
::view-transition-old(exclude) {
|
||||
visibility: hidden;
|
||||
}
|
||||
::view-transition-new(exclude-leftbutton),
|
||||
::view-transition-new(exclude-rightbutton),
|
||||
::view-transition-new(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
::view-transition-group(hero) {
|
||||
animation-duration: var(--vt-duration-hero);
|
||||
}
|
||||
::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-hero) fadeOut forwards;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-hero) fadeIn forwards;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutLeft forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInRight forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-old(previous) {
|
||||
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutRight forwards;
|
||||
}
|
||||
::view-transition-old(previous-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutRight forwards;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
::view-transition-new(previous) {
|
||||
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInLeft forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(previous-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInLeft forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes flyInLeft {
|
||||
from {
|
||||
/* object-position: -25dvw; */
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
/* object-position: -25dvw; */
|
||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyInRight {
|
||||
from {
|
||||
/* object-position: 25dvw; */
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fly out to right */
|
||||
@keyframes flyOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--vt-viewer-opacity-mid);
|
||||
filter: blur(var(--vt-viewer-blur-mid));
|
||||
}
|
||||
to {
|
||||
/* object-position: 50dvw 0px; */
|
||||
transform: translateX(var(--vt-viewer-slide-distance));
|
||||
opacity: var(--vt-viewer-opacity-start);
|
||||
filter: blur(var(--vt-viewer-blur-max));
|
||||
}
|
||||
}
|
||||
|
||||
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion: when system preference is set */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(hero) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
html:active-view-transition-type(viewer) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
html:active-view-transition-type(timeline) {
|
||||
&::view-transition-old(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(hero) {
|
||||
animation: var(--vt-duration-default) 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
|
||||
::view-transition-group(letterbox-left),
|
||||
::view-transition-group(letterbox-right),
|
||||
::view-transition-group(letterbox-top),
|
||||
::view-transition-group(letterbox-bottom) {
|
||||
animation-name: none;
|
||||
}
|
||||
|
||||
::view-transition-old(letterbox-left),
|
||||
::view-transition-old(letterbox-right),
|
||||
::view-transition-old(letterbox-top),
|
||||
::view-transition-old(letterbox-bottom) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(letterbox-left),
|
||||
::view-transition-new(letterbox-right),
|
||||
::view-transition-new(letterbox-top),
|
||||
::view-transition-new(letterbox-bottom) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(previous),
|
||||
::view-transition-group(previous-old),
|
||||
::view-transition-group(next),
|
||||
::view-transition-group(next-old) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(previous-old),
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(previous-new),
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
207
web/src/lib/actions/image-loader.svelte.ts
Normal file
207
web/src/lib/actions/image-loader.svelte.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
/**
|
||||
* Converts a ClassValue to a string suitable for className assignment.
|
||||
* Handles strings, arrays, and objects similar to how clsx works.
|
||||
*/
|
||||
function classValueToString(value: ClassValue | undefined): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((v) => classValueToString(v))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
// Object/dictionary case
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export interface ImageLoaderProperties {
|
||||
imgClass?: ClassValue;
|
||||
alt?: string;
|
||||
draggable?: boolean;
|
||||
role?: string;
|
||||
style?: string;
|
||||
title?: string | null;
|
||||
loading?: 'lazy' | 'eager';
|
||||
dataAttributes?: Record<string, string>;
|
||||
}
|
||||
export interface ImageSourceProperty {
|
||||
src: string | undefined;
|
||||
}
|
||||
export interface ImageLoaderCallbacks {
|
||||
onStart?: () => void;
|
||||
onLoad?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onElementCreated?: (element: HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
|
||||
if (params.alt !== undefined) {
|
||||
img.alt = params.alt;
|
||||
}
|
||||
if (params.draggable !== undefined) {
|
||||
img.draggable = params.draggable;
|
||||
}
|
||||
if (params.imgClass) {
|
||||
img.className = classValueToString(params.imgClass);
|
||||
}
|
||||
if (params.role) {
|
||||
img.role = params.role;
|
||||
}
|
||||
if (params.style !== undefined) {
|
||||
img.setAttribute('style', params.style);
|
||||
}
|
||||
if (params.title !== undefined && params.title !== null) {
|
||||
img.title = params.title;
|
||||
}
|
||||
if (params.loading !== undefined) {
|
||||
img.loading = params.loading;
|
||||
}
|
||||
if (params.dataAttributes) {
|
||||
for (const [key, value] of Object.entries(params.dataAttributes)) {
|
||||
img.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const destroyImageElement = (
|
||||
imgElement: HTMLImageElement,
|
||||
currentSrc: string | undefined,
|
||||
handleLoad: () => void,
|
||||
handleError: () => void,
|
||||
) => {
|
||||
imgElement.removeEventListener('load', handleLoad);
|
||||
imgElement.removeEventListener('error', handleError);
|
||||
cancelImageUrl(currentSrc);
|
||||
imgElement.remove();
|
||||
};
|
||||
|
||||
const createImageElement = (
|
||||
src: string | undefined,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
onElementCreated?: (imgElement: HTMLImageElement) => void,
|
||||
) => {
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
updateImageAttributes(img, properties);
|
||||
|
||||
img.addEventListener('load', onLoad);
|
||||
img.addEventListener('error', onError);
|
||||
|
||||
onStart?.();
|
||||
|
||||
if (src) {
|
||||
img.src = src;
|
||||
onElementCreated?.(img);
|
||||
}
|
||||
|
||||
return img;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
src: string,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
) {
|
||||
let destroyed = false;
|
||||
const wrapper = (fn: (() => void) | undefined) => () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
fn?.();
|
||||
};
|
||||
const wrappedOnLoad = wrapper(onLoad);
|
||||
const wrappedOnError = wrapper(onError);
|
||||
const wrappedOnStart = wrapper(onStart);
|
||||
const img = createImageElement(src, properties, wrappedOnLoad, wrappedOnError, wrappedOnStart);
|
||||
if (!img) {
|
||||
return () => void 0;
|
||||
}
|
||||
return () => {
|
||||
destroyed = true;
|
||||
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadImageFunction = typeof loadImage;
|
||||
|
||||
/**
|
||||
* 1. Creates and appends an <img> element to the parent
|
||||
* 2. Coordinates with service worker before src triggers fetch
|
||||
* 3. Adds load/error listeners
|
||||
* 4. Cancels SW request when element is removed from DOM
|
||||
*/
|
||||
export function imageLoader(
|
||||
node: HTMLElement,
|
||||
params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
|
||||
) {
|
||||
let currentSrc = params.src;
|
||||
let currentCallbacks = params;
|
||||
let imgElement: HTMLImageElement | undefined = undefined;
|
||||
|
||||
const handleLoad = () => {
|
||||
currentCallbacks.onLoad?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
|
||||
};
|
||||
|
||||
const handleElementCreated = (img: HTMLImageElement) => {
|
||||
if (img) {
|
||||
node.append(img);
|
||||
currentCallbacks.onElementCreated?.(img);
|
||||
}
|
||||
};
|
||||
|
||||
const createImage = () => {
|
||||
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
|
||||
};
|
||||
createImage();
|
||||
|
||||
return {
|
||||
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
|
||||
// If src changed, recreate the image element
|
||||
if (newParams.src !== currentSrc) {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
currentSrc = newParams.src;
|
||||
currentCallbacks = newParams;
|
||||
|
||||
createImage();
|
||||
return;
|
||||
}
|
||||
|
||||
currentCallbacks = newParams;
|
||||
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
updateImageAttributes(imgElement, newParams);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ type ActionMap = {
|
||||
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
|
||||
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
|
||||
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto };
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
|
||||
|
||||
277
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal file
277
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal file
@@ -0,0 +1,277 @@
|
||||
<script lang="ts">
|
||||
import { imageLoader } from '$lib/actions/image-loader.svelte';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
|
||||
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { getDimensions } from '$lib/utils/asset-utils';
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, untrack, type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
zoomDisabled?: boolean;
|
||||
imageClass?: string;
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
slideshowState: SlideshowState;
|
||||
slideshowLook: SlideshowLook;
|
||||
transitionName?: string | null | undefined;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
imgElement?: HTMLImageElement;
|
||||
overlays?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
imgElement = $bindable<HTMLImageElement | undefined>(),
|
||||
asset,
|
||||
sharedLink,
|
||||
zoomDisabled = false,
|
||||
imageClass = '',
|
||||
container,
|
||||
slideshowState,
|
||||
slideshowLook,
|
||||
transitionName,
|
||||
onImageReady,
|
||||
onError,
|
||||
overlays,
|
||||
}: Props = $props();
|
||||
|
||||
let previousLoader = $state<AdaptiveImageLoader>();
|
||||
let previousAssetId: string | undefined;
|
||||
let previousSharedLinkId: string | undefined;
|
||||
|
||||
const resetZoomState = () => {
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
};
|
||||
};
|
||||
|
||||
const zoomTransform = $derived.by(() => {
|
||||
const { currentZoom, currentPositionX, currentPositionY, currentRotation } = assetViewerManager.zoomState;
|
||||
return `translate3d(${currentPositionX}px, ${currentPositionY}px, 0) scale(${currentZoom}) rotate(${currentRotation}deg)`;
|
||||
});
|
||||
|
||||
const adaptiveImageLoader = $derived.by(() => {
|
||||
if (previousAssetId === asset.id && previousSharedLinkId === sharedLink?.id) {
|
||||
return previousLoader!;
|
||||
}
|
||||
|
||||
return untrack(() => {
|
||||
previousAssetId = asset.id;
|
||||
previousSharedLinkId = sharedLink?.id;
|
||||
|
||||
previousLoader?.destroy();
|
||||
resetZoomState();
|
||||
const loader = new AdaptiveImageLoader(asset, sharedLink, {
|
||||
currentZoomFn: () => assetViewerManager.zoom,
|
||||
onImageReady,
|
||||
onError,
|
||||
});
|
||||
previousLoader = loader;
|
||||
return loader;
|
||||
});
|
||||
});
|
||||
onDestroy(() => adaptiveImageLoader.destroy());
|
||||
|
||||
const imageDimensions = $derived.by(() => {
|
||||
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
|
||||
return { width: asset.width!, height: asset.height! };
|
||||
}
|
||||
|
||||
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
|
||||
return getDimensions(asset.exifInfo) as { width: number; height: number };
|
||||
}
|
||||
|
||||
return { width: 1, height: 1 };
|
||||
});
|
||||
|
||||
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
|
||||
|
||||
const renderDimensions = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
left: (container.width - width) / 2 + 'px',
|
||||
top: (container.height - height) / 2 + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
const blurredSlideshow = $derived(
|
||||
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||
);
|
||||
|
||||
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
|
||||
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
|
||||
const thumbnailUrl = $derived(loadState.thumbnailUrl);
|
||||
const previewUrl = $derived(loadState.previewUrl);
|
||||
const originalUrl = $derived(loadState.originalUrl);
|
||||
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
|
||||
const showBrokenAsset = $derived(loadState.hasError);
|
||||
|
||||
// Effect: Upgrade to original when user zooms in
|
||||
$effect(() => {
|
||||
if (assetViewerManager.zoom > 1 && loadState.quality === 'preview') {
|
||||
void adaptiveImageLoader.triggerOriginal();
|
||||
}
|
||||
});
|
||||
let thumbnailElement = $state<HTMLImageElement>();
|
||||
let previewElement = $state<HTMLImageElement>();
|
||||
let originalElement = $state<HTMLImageElement>();
|
||||
|
||||
// Effect: Synchronize highest quality element as main imgElement
|
||||
$effect(() => {
|
||||
imgElement = originalElement ?? previewElement ?? thumbnailElement;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full">
|
||||
<!-- Blurred slideshow background (full viewport) -->
|
||||
{#if blurredSlideshow}
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash! }} class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- Letterbox regions (empty space around image) -->
|
||||
<Letterboxes
|
||||
{transitionName}
|
||||
{slideshowState}
|
||||
{slideshowLook}
|
||||
hasThumbhash={!!asset.thumbhash}
|
||||
{scaledDimensions}
|
||||
{container}
|
||||
/>
|
||||
|
||||
<!-- Main image box with transition -->
|
||||
<div
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
class="absolute"
|
||||
style:left={renderDimensions.left}
|
||||
style:top={renderDimensions.top}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
>
|
||||
{#if asset.thumbhash}
|
||||
<!-- Thumbhash / spinner layer -->
|
||||
<div style:transform-origin="0px 0px" style:transform={zoomTransform} class="h-full w-full absolute">
|
||||
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
|
||||
</div>
|
||||
{:else if showSpinner}
|
||||
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:transform-origin="0px 0px"
|
||||
style:transform={zoomTransform}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: thumbnailUrl,
|
||||
onStart: () => adaptiveImageLoader.onThumbnailStart(),
|
||||
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
|
||||
onError: () => adaptiveImageLoader.onThumbnailError(),
|
||||
onElementCreated: (element) => (thumbnailElement = element),
|
||||
imgClass: ['absolute h-full', 'w-full'],
|
||||
alt: '',
|
||||
role: 'presentation',
|
||||
dataAttributes: {
|
||||
'data-testid': 'thumbnail',
|
||||
},
|
||||
}}
|
||||
></div>
|
||||
|
||||
{#if showBrokenAsset}
|
||||
<div class="h-full w-full absolute" style:transform-origin="0px 0px" style:transform={zoomTransform}>
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:transform-origin="0px 0px"
|
||||
style:transform={zoomTransform}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: previewUrl,
|
||||
onStart: () => adaptiveImageLoader.onPreviewStart(),
|
||||
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
|
||||
onError: () => adaptiveImageLoader.onPreviewError(),
|
||||
onElementCreated: (element) => (previewElement = element),
|
||||
imgClass: ['h-full', 'w-full', imageClass],
|
||||
alt: imageAltText,
|
||||
draggable: false,
|
||||
dataAttributes: {
|
||||
'data-testid': 'preview',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-0"
|
||||
style:transform-origin="0px 0px"
|
||||
style:transform={zoomTransform}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
use:imageLoader={{
|
||||
src: originalUrl,
|
||||
onStart: () => adaptiveImageLoader.onOriginalStart(),
|
||||
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
|
||||
onError: () => adaptiveImageLoader.onOriginalError(),
|
||||
onElementCreated: (element) => (originalElement = element),
|
||||
imgClass: ['h-full', 'w-full', imageClass],
|
||||
alt: imageAltText,
|
||||
draggable: false,
|
||||
dataAttributes: {
|
||||
'data-testid': 'original',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{@render overlays?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Use placeholder empty image to zoomImage so it can monitor mouse-wheel events and update zoom state -->
|
||||
<div
|
||||
class="absolute top-0"
|
||||
use:zoomImageAction={{ disabled: zoomDisabled }}
|
||||
style:width={renderDimensions.width}
|
||||
style:height={renderDimensions.height}
|
||||
>
|
||||
<img alt="" class="absolute h-full w-full hidden" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { focusTrap } from '$lib/actions/focus-trap';
|
||||
import { loadImage } from '$lib/actions/image-loader.svelte';
|
||||
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||
@@ -12,7 +13,7 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
@@ -21,14 +22,15 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
|
||||
import { navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
type AlbumResponseDto,
|
||||
@@ -36,9 +38,9 @@
|
||||
type PersonResponseDto,
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { onDestroy, onMount, tick, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
@@ -87,27 +89,31 @@
|
||||
onRandom,
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const { setAssetId, invisible } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
slideshowNavigation,
|
||||
slideshowState,
|
||||
slideshowTransition,
|
||||
slideshowRepeat,
|
||||
} = slideshowStore;
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
let stack: StackResponseDto | undefined = $state();
|
||||
let selectedStackAsset = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId));
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
|
||||
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
|
||||
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
|
||||
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
let slideshowStartAssetId = $state<string>();
|
||||
@@ -117,35 +123,52 @@
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
if (!cursor.current.stack) {
|
||||
stack = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
imageManager.preload(stack?.assets[1]);
|
||||
});
|
||||
stack = await getStack({ id: cursor.current.stack.id });
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
if (!album || !album.isActivityEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
let transitionName = $state<string | undefined>('hero');
|
||||
let equirectangularTransitionName = $state<string | undefined>('hero');
|
||||
let detailPanelTransitionName = $state<string | undefined>(undefined);
|
||||
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
onMount(() => {
|
||||
const addInfoTransition = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
transitionName = 'hero';
|
||||
equirectangularTransitionName = 'hero';
|
||||
};
|
||||
const finished = () => {
|
||||
detailPanelTransitionName = undefined;
|
||||
transitionName = undefined;
|
||||
};
|
||||
|
||||
unsubscribes.push(
|
||||
eventManager.onMany([
|
||||
['TransitionToAssetViewer', addInfoTransition],
|
||||
['TransitionToTimeline', addInfoTransition],
|
||||
['Finished', finished],
|
||||
]),
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
@@ -162,93 +185,230 @@
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
activityManager.reset();
|
||||
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
destroyNextPreloader();
|
||||
destroyPreviousPreloader();
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
console.error('Error getting album that asset belong to', error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
transitionName = 'hero';
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
console.log(asset);
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
console.log(refreshedAsset);
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const startTransition = async (
|
||||
types: string[],
|
||||
targetTransition: string | null,
|
||||
targetAsset: AssetResponseDto | null,
|
||||
navigateFn: () => Promise<boolean>,
|
||||
) => {
|
||||
transitionName = viewTransitionManager.getTransitionName('old', targetTransition);
|
||||
equirectangularTransitionName = viewTransitionManager.getTransitionName('old', targetTransition);
|
||||
detailPanelTransitionName = 'detail-panel';
|
||||
await tick();
|
||||
const navigationResult = new Promise<boolean>((navigationResolve) => {
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('StartViewTransition', async () => {
|
||||
transitionName = viewTransitionManager.getTransitionName('new', targetTransition);
|
||||
if (targetAsset && isEquirectangular(asset) && !isEquirectangular(targetAsset)) {
|
||||
equirectangularTransitionName = undefined;
|
||||
}
|
||||
await tick();
|
||||
navigationResolve(await navigateFn());
|
||||
});
|
||||
eventManager.once('AssetViewerFree', () => tick().then(resolve));
|
||||
}),
|
||||
types,
|
||||
);
|
||||
});
|
||||
return navigationResult;
|
||||
};
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
let nextPreloader: AdaptiveImageLoader | undefined;
|
||||
let previousPreloader: AdaptiveImageLoader | undefined;
|
||||
let nextPreviewUrl = $state<string | undefined>();
|
||||
let previousPreviewUrl = $state<string | undefined>();
|
||||
|
||||
const setPreviewUrl = (direction: 'next' | 'previous', url: string | undefined) => {
|
||||
if (direction === 'next') {
|
||||
nextPreviewUrl = url;
|
||||
} else {
|
||||
previousPreviewUrl = url;
|
||||
}
|
||||
};
|
||||
|
||||
const startPreloader = (asset: AssetResponseDto | undefined, direction: 'next' | 'previous') => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const loader = new AdaptiveImageLoader(
|
||||
asset,
|
||||
undefined,
|
||||
{
|
||||
currentZoomFn: () => 1,
|
||||
onQualityUpgrade: (url) => setPreviewUrl(direction, url),
|
||||
},
|
||||
loadImage,
|
||||
);
|
||||
loader.start();
|
||||
return loader;
|
||||
};
|
||||
|
||||
const destroyPreviousPreloader = () => {
|
||||
previousPreloader?.destroy();
|
||||
previousPreloader = undefined;
|
||||
previousPreviewUrl = undefined;
|
||||
};
|
||||
|
||||
const destroyNextPreloader = () => {
|
||||
nextPreloader?.destroy();
|
||||
nextPreloader = undefined;
|
||||
nextPreviewUrl = undefined;
|
||||
};
|
||||
|
||||
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
|
||||
setPreviewUrl(direction, undefined);
|
||||
if (direction === 'next') {
|
||||
destroyPreviousPreloader();
|
||||
return;
|
||||
}
|
||||
destroyNextPreloader();
|
||||
};
|
||||
|
||||
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
|
||||
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
|
||||
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
|
||||
|
||||
const shouldDestroyPrevious = movedForward || !movedBackward;
|
||||
const shouldDestroyNext = movedBackward || !movedForward;
|
||||
|
||||
if (movedForward) {
|
||||
// When moving forward: old next becomes current, shift preview URLs
|
||||
const oldNextUrl = nextPreviewUrl;
|
||||
destroyPreviousPreloader();
|
||||
previousPreviewUrl = oldNextUrl;
|
||||
destroyNextPreloader();
|
||||
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
|
||||
} else if (movedBackward) {
|
||||
// When moving backward: old previous becomes current, shift preview URLs
|
||||
const oldPreviousUrl = previousPreviewUrl;
|
||||
destroyNextPreloader();
|
||||
nextPreviewUrl = oldPreviousUrl;
|
||||
destroyPreviousPreloader();
|
||||
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
|
||||
} else {
|
||||
// Non-adjacent navigation (e.g., slideshow random) - clear everything
|
||||
if (shouldDestroyPrevious) {
|
||||
destroyPreviousPreloader();
|
||||
}
|
||||
if (shouldDestroyNext) {
|
||||
destroyNextPreloader();
|
||||
}
|
||||
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
|
||||
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
|
||||
}
|
||||
};
|
||||
|
||||
const getNavigationTarget = (): 'previous' | 'next' | undefined => {
|
||||
if (slideShowPlaying) {
|
||||
return slideShowAscending ? 'previous' : 'next';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
|
||||
cancelPreloadsBeforeNavigation(order);
|
||||
let skipped = false;
|
||||
if (viewTransitionManager.skipTransitions()) {
|
||||
skipped = true;
|
||||
}
|
||||
|
||||
let hasNext = false;
|
||||
if (slideShowPlaying && slideShowShuffle) {
|
||||
const navigate = async () => {
|
||||
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!next) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
next = true;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
// eslint-disable-next-line unicorn/prefer-ternary
|
||||
if (viewTransitionManager.isSupported() && !skipped && !skipTransition) {
|
||||
hasNext = await startTransition(['slideshow'], null, null, navigate);
|
||||
} else {
|
||||
hasNext = await navigate();
|
||||
}
|
||||
} else {
|
||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||
const navigate = async () =>
|
||||
order === 'previous' ? await navigateToAsset(previousAsset) : await navigateToAsset(nextAsset);
|
||||
if (viewTransitionManager.isSupported() && !skipped && !skipTransition && !!targetAsset) {
|
||||
const targetTransition = slideShowPlaying ? null : order;
|
||||
hasNext = await startTransition(
|
||||
slideShowPlaying ? ['slideshow'] : ['viewer-nav'],
|
||||
targetTransition,
|
||||
targetAsset,
|
||||
navigate,
|
||||
);
|
||||
} else {
|
||||
hasNext = await navigate();
|
||||
}
|
||||
}
|
||||
|
||||
if (!slideShowPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
|
||||
if (slideShowPlaying) {
|
||||
order = slideShowAscending ? 'previous' : 'next';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
e?.stopPropagation();
|
||||
imageManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
let hasNext = false;
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
|
||||
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
|
||||
if (!hasNext) {
|
||||
const asset = await onRandom?.();
|
||||
if (asset) {
|
||||
slideshowHistory.queue(asset);
|
||||
hasNext = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasNext =
|
||||
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
// Loop back to starting asset
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
await handleStopSlideshow();
|
||||
}
|
||||
}
|
||||
}, $t('error_while_navigating'));
|
||||
void tracker.invoke(
|
||||
() => completeNavigation(order, skipTransition),
|
||||
(error: unknown) => handleError(error, $t('error_while_navigating')),
|
||||
() => eventManager.emit('AssetViewerAfterNavigate'),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -279,10 +439,11 @@
|
||||
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
if (!document.fullscreenElement) {
|
||||
return;
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_exit_fullscreen'));
|
||||
} finally {
|
||||
@@ -291,16 +452,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? asset : undefined;
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
if (isMouseOver) {
|
||||
previewStackedAsset = stackedAsset;
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetsMouseLeave = () => {
|
||||
previewStackedAsset = undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.ADD_TO_ALBUM: {
|
||||
await handleGetAllAlbums();
|
||||
eventManager.emit('AlbumAddAssets');
|
||||
break;
|
||||
}
|
||||
case AssetAction.DELETE:
|
||||
@@ -310,9 +479,10 @@
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack;
|
||||
if (stack) {
|
||||
cursor.current = stack.assets[0];
|
||||
if (!stack) {
|
||||
break;
|
||||
}
|
||||
cursor.current = stack.assets[0];
|
||||
break;
|
||||
}
|
||||
case AssetAction.STACK:
|
||||
@@ -360,21 +530,46 @@
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
await handleGetAllAlbums();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
if (sharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewStackedAsset) {
|
||||
await ocrManager.getAssetOcr(previewStackedAsset.id);
|
||||
}
|
||||
await ocrManager.getAssetOcr(asset.id);
|
||||
};
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
cursor.current;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
imageManager.preload(cursor.nextAsset);
|
||||
imageManager.preload(cursor.previousAsset);
|
||||
});
|
||||
|
||||
let lastCursor = $state<AssetCursor>();
|
||||
|
||||
$effect(() => {
|
||||
if (cursor.current.id === lastCursor?.current.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastCursor) {
|
||||
selectedStackAsset = undefined;
|
||||
previewStackedAsset = undefined;
|
||||
// After navigation completes, reconcile preloads with full state information
|
||||
updatePreloadsAfterNavigation(lastCursor, cursor);
|
||||
lastCursor = cursor;
|
||||
return;
|
||||
}
|
||||
|
||||
// "first time" load, start preloads
|
||||
if (cursor.nextAsset) {
|
||||
nextPreloader = startPreloader(cursor.nextAsset, 'next');
|
||||
}
|
||||
if (cursor.previousAsset) {
|
||||
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
|
||||
}
|
||||
lastCursor = cursor;
|
||||
});
|
||||
|
||||
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
|
||||
@@ -392,9 +587,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
if (viewerKind !== 'PhotoViewer' && viewerKind !== 'ImagePanaramaViewer') {
|
||||
eventManager.emit('AssetViewerFree');
|
||||
}
|
||||
});
|
||||
|
||||
const isEquirectangular = (asset: AssetResponseDto) => {
|
||||
return (
|
||||
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
|
||||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
|
||||
);
|
||||
};
|
||||
|
||||
const viewerKind = $derived.by(() => {
|
||||
if (previewStackedAsset) {
|
||||
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
|
||||
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
|
||||
}
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return 'VideoViewer';
|
||||
@@ -437,12 +647,16 @@
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
class:invisible={$invisible}
|
||||
use:focusTrap
|
||||
bind:this={assetViewerHtmlElement}
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<div
|
||||
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||
style:view-transition-name="exclude"
|
||||
>
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
@@ -474,68 +688,66 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
|
||||
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
|
||||
<div
|
||||
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
|
||||
style:view-transition-name="exclude-leftbutton"
|
||||
>
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Asset Viewer -->
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
haveFadeTransition={false}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
{#if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
asset={previewStackedAsset!}
|
||||
{transitionName}
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
projectionType={previewStackedAsset!.exifInfo?.projectionType}
|
||||
loopVideo={true}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoEnded={() => navigateAsset(getNavigationTarget())}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
{transitionName}
|
||||
{cursor}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
{sharedLink}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer {asset} />
|
||||
<ImagePanoramaViewer {asset} transitionName={equirectangularTransitionName} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
{transitionName}
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
|
||||
onReady={() => eventManager.emit('AssetViewerFree')}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
{transitionName}
|
||||
{cursor}
|
||||
{sharedLink}
|
||||
cacheKey={asset.thumbhash}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
|
||||
onClose={closeViewer}
|
||||
onVideoEnded={() => navigateAsset()}
|
||||
onVideoEnded={() => navigateAsset(getNavigationTarget())}
|
||||
onVideoStarted={handleVideoStarted}
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
@@ -561,19 +773,23 @@
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
|
||||
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
|
||||
<div
|
||||
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
|
||||
style:view-transition-name="exclude-rightbutton"
|
||||
>
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
style:view-transition-name={detailPanelTransitionName}
|
||||
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -591,10 +807,14 @@
|
||||
{#if stack && withStacked}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
<div
|
||||
role="presentation"
|
||||
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
|
||||
onmouseleave={handleStackedAssetsMouseLeave}
|
||||
>
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||
class={['inline-block px-1 relative transition-all pb-2']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
@@ -603,7 +823,7 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
selectedStackAsset = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { timeToLoadTheMap } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
@@ -17,9 +18,16 @@
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@@ -34,6 +42,7 @@
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
@@ -43,11 +52,10 @@
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@@ -74,14 +82,40 @@
|
||||
let previousId: string | undefined = $state();
|
||||
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
|
||||
|
||||
let albums = $state<AlbumResponseDto[]>([]);
|
||||
|
||||
const refreshAlbums = async () => {
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
albums = await getAllAlbums({ assetId: asset.id });
|
||||
} catch (error) {
|
||||
handleError(error, 'Error getting asset album membership');
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => eventManager.on('AlbumAddAssets', () => void refreshAlbums()));
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset;
|
||||
untrack(() => void refreshAlbums());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
return;
|
||||
}
|
||||
if (asset.id !== previousId) {
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
|
||||
if (asset.id === previousId) {
|
||||
return;
|
||||
}
|
||||
|
||||
showEditFaces = false;
|
||||
previousId = asset.id;
|
||||
});
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
@@ -78,6 +78,9 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!htmlElement) {
|
||||
return;
|
||||
}
|
||||
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
|
||||
const offsetArea = {
|
||||
width: (containerWidth - actualWidth) / 2,
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
|
||||
let { asset }: Props = $props();
|
||||
let { transitionName, asset }: Props = $props();
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||
@@ -18,11 +19,15 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-dvh w-dvw select-none place-content-center place-items-center">
|
||||
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={data}
|
||||
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
|
||||
/>
|
||||
{:catch}
|
||||
{$t('errors.failed_to_load_asset')}
|
||||
{/await}
|
||||
|
||||
114
web/src/lib/components/asset-viewer/letterboxes.svelte
Normal file
114
web/src/lib/components/asset-viewer/letterboxes.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null | undefined;
|
||||
slideshowState: SlideshowState;
|
||||
slideshowLook: SlideshowLook;
|
||||
hasThumbhash: boolean;
|
||||
scaledDimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
let { transitionName, slideshowState, slideshowLook, hasThumbhash, scaledDimensions, container }: Props = $props();
|
||||
|
||||
const blurredSlideshow = $derived(
|
||||
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && hasThumbhash,
|
||||
);
|
||||
|
||||
const shouldShowLetterboxes = $derived(!!transitionName && transitionName !== 'hero' && !blurredSlideshow);
|
||||
|
||||
const transitionLetterboxLeft = $derived(shouldShowLetterboxes ? 'letterbox-left' : null);
|
||||
const transitionLetterboxRight = $derived(shouldShowLetterboxes ? 'letterbox-right' : null);
|
||||
const transitionLetterboxTop = $derived(shouldShowLetterboxes ? 'letterbox-top' : null);
|
||||
const transitionLetterboxBottom = $derived(shouldShowLetterboxes ? 'letterbox-bottom' : null);
|
||||
|
||||
// Letterbox regions (the empty space around the main box)
|
||||
const letterboxLeft = $derived.by(() => {
|
||||
const { width } = scaledDimensions;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
return {
|
||||
width: leftOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxRight = $derived.by(() => {
|
||||
const { width } = scaledDimensions;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
const rightOffset = leftOffset;
|
||||
return {
|
||||
width: rightOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: container.width - rightOffset + 'px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxTop = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const topOffset = (container.height - height) / 2;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: topOffset + 'px',
|
||||
left: leftOffset + 'px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxBottom = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const topOffset = (container.height - height) / 2;
|
||||
const bottomOffset = topOffset;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: bottomOffset + 'px',
|
||||
left: leftOffset + 'px',
|
||||
top: container.height - bottomOffset + 'px',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Letterbox regions (empty space around image) -->
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxLeft}
|
||||
style:left={letterboxLeft.left}
|
||||
style:top={letterboxLeft.top}
|
||||
style:width={letterboxLeft.width}
|
||||
style:height={letterboxLeft.height}
|
||||
></div>
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxRight}
|
||||
style:left={letterboxRight.left}
|
||||
style:top={letterboxRight.top}
|
||||
style:width={letterboxRight.width}
|
||||
style:height={letterboxRight.height}
|
||||
></div>
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxTop}
|
||||
style:left={letterboxTop.left}
|
||||
style:top={letterboxTop.top}
|
||||
style:width={letterboxTop.width}
|
||||
style:height={letterboxTop.height}
|
||||
></div>
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxBottom}
|
||||
style:left={letterboxBottom.left}
|
||||
style:top={letterboxBottom.top}
|
||||
style:width={letterboxBottom.width}
|
||||
style:height={letterboxBottom.height}
|
||||
></div>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@
|
||||
};
|
||||
|
||||
type Props = {
|
||||
transitionName?: string;
|
||||
panorama: string | { source: string };
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
@@ -35,7 +37,14 @@
|
||||
navbar?: boolean;
|
||||
};
|
||||
|
||||
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
|
||||
let {
|
||||
transitionName,
|
||||
panorama,
|
||||
originalPanorama,
|
||||
adapter = EquirectangularAdapter,
|
||||
plugins = [],
|
||||
navbar = false,
|
||||
}: Props = $props();
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
let viewer: Viewer;
|
||||
@@ -144,6 +153,13 @@
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
viewer.addEventListener(
|
||||
'ready',
|
||||
() => {
|
||||
eventManager.emit('AssetViewerFree');
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel is 0-100
|
||||
@@ -175,4 +191,9 @@
|
||||
<AssetViewerEvents {onZoom} />
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||
<div class="h-full w-full mb-0" bind:this={container}></div>
|
||||
<div
|
||||
id="sphere"
|
||||
class="h-full w-full h-dvh w-dvw mb-0"
|
||||
bind:this={container}
|
||||
style:view-transition-name={transitionName}
|
||||
></div>
|
||||
|
||||
@@ -1,68 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
transitionName?: string;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, transitionName, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
@@ -110,29 +84,11 @@
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
let currentPreviewUrl = $state<string>();
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,35 +106,18 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
let swipeFeedbackReset = $state<(() => void) | undefined>();
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
// Reset swipe feedback when asset changes
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
untrack(() => swipeFeedbackReset?.());
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -192,46 +131,35 @@
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<div id="broken-asset" class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
<div
|
||||
bind:this={element}
|
||||
|
||||
<SwipeFeedback
|
||||
bind:element
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
disabled={isOcrActive || assetViewerManager.zoom > 1}
|
||||
disableSwipeLeft={!cursor.nextAsset}
|
||||
disableSwipeRight={!cursor.previousAsset}
|
||||
bind:reset={swipeFeedbackReset}
|
||||
{onSwipe}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={isOcrActive}
|
||||
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
onImageReady={() => onReady?.()}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgElement={assetViewerManager.imgRef}
|
||||
{transitionName}
|
||||
>
|
||||
{#snippet overlays()}
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
||||
<div
|
||||
@@ -243,23 +171,38 @@
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
{#snippet leftPreview()}
|
||||
{#if cursor.previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.previousAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if cursor.nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.nextAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
395
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
395
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
@@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
disableSwipeLeft?: boolean;
|
||||
disableSwipeRight?: boolean;
|
||||
onSwipeEnd?: (offsetX: number) => void;
|
||||
onSwipeMove?: (offsetX: number) => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
swipeThreshold?: number;
|
||||
class?: string;
|
||||
transitionName?: string;
|
||||
element?: HTMLDivElement;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
reset?: () => void;
|
||||
children: Snippet;
|
||||
leftPreview?: Snippet;
|
||||
rightPreview?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
disableSwipeLeft = false,
|
||||
disableSwipeRight = false,
|
||||
onSwipeEnd,
|
||||
onSwipeMove,
|
||||
onSwipe,
|
||||
swipeThreshold = 45,
|
||||
class: className = '',
|
||||
transitionName,
|
||||
element = $bindable(),
|
||||
clientWidth = $bindable(),
|
||||
clientHeight = $bindable(),
|
||||
reset = $bindable(),
|
||||
children,
|
||||
leftPreview,
|
||||
rightPreview,
|
||||
}: Props = $props();
|
||||
|
||||
interface SwipeAnimations {
|
||||
currentImageAnimation: Animation;
|
||||
previewAnimation: Animation | null;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION_MS = 300;
|
||||
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
|
||||
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
|
||||
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
|
||||
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
|
||||
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
|
||||
const MIN_PROGRESS_THRESHOLD = 0.25;
|
||||
const ENABLE_SCALE_ANIMATION = false;
|
||||
|
||||
let contentElement: HTMLElement | undefined = $state();
|
||||
let leftPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
let rightPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let startX = $state(0);
|
||||
let currentOffsetX = $state(0);
|
||||
let dragStartTime: number | null = $state(null);
|
||||
|
||||
let leftAnimations: SwipeAnimations | null = $state(null);
|
||||
let rightAnimations: SwipeAnimations | null = $state(null);
|
||||
let isSwipeInProgress = $state(false);
|
||||
|
||||
const cursorStyle = $derived(disabled ? '' : isSwipeInProgress ? 'wait' : isDragging ? 'grabbing' : 'grab');
|
||||
|
||||
const isValidPointerEvent = (event: PointerEvent) =>
|
||||
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
|
||||
|
||||
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
|
||||
if (!contentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
|
||||
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
|
||||
const sign = direction === 'left' ? -1 : 1;
|
||||
|
||||
if (isPreview) {
|
||||
return [
|
||||
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
|
||||
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
|
||||
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
|
||||
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
|
||||
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
|
||||
];
|
||||
};
|
||||
|
||||
contentElement.style.transformOrigin = 'center';
|
||||
|
||||
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
|
||||
// Preview slides in from opposite side of swipe direction
|
||||
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
|
||||
let previewAnimation: Animation | null = null;
|
||||
|
||||
if (previewContainer) {
|
||||
previewContainer.style.transformOrigin = 'center';
|
||||
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
}
|
||||
|
||||
currentImageAnimation.pause();
|
||||
previewAnimation?.pause();
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
return { currentImageAnimation, previewAnimation, abortController };
|
||||
};
|
||||
|
||||
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
|
||||
animations.currentImageAnimation.currentTime = time;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.currentTime = time;
|
||||
}
|
||||
};
|
||||
|
||||
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
|
||||
animations.currentImageAnimation.playbackRate = playbackRate;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.playbackRate = playbackRate;
|
||||
}
|
||||
animations.currentImageAnimation.play();
|
||||
animations.previewAnimation?.play();
|
||||
};
|
||||
|
||||
const cancelAnimations = (animations: SwipeAnimations | null) => {
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
animations.abortController.abort();
|
||||
animations.currentImageAnimation.cancel();
|
||||
animations.previewAnimation?.cancel();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
startDrag(event);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
startX = event.clientX;
|
||||
currentOffsetX = 0;
|
||||
|
||||
element.setPointerCapture(event.pointerId);
|
||||
dragStartTime = Date.now();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawOffsetX = event.clientX - startX;
|
||||
const direction = rawOffsetX < 0 ? 'left' : 'right';
|
||||
|
||||
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
|
||||
currentOffsetX = 0;
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentOffsetX = rawOffsetX;
|
||||
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
|
||||
|
||||
if (direction === 'left') {
|
||||
if (!leftAnimations) {
|
||||
leftAnimations = createSwipeAnimations('left');
|
||||
}
|
||||
if (leftAnimations) {
|
||||
setAnimationTime(leftAnimations, animationTime);
|
||||
}
|
||||
if (rightAnimations) {
|
||||
cancelAnimations(rightAnimations);
|
||||
rightAnimations = null;
|
||||
}
|
||||
} else {
|
||||
if (!rightAnimations) {
|
||||
rightAnimations = createSwipeAnimations('right');
|
||||
}
|
||||
if (rightAnimations) {
|
||||
setAnimationTime(rightAnimations, animationTime);
|
||||
}
|
||||
if (leftAnimations) {
|
||||
cancelAnimations(leftAnimations);
|
||||
leftAnimations = null;
|
||||
}
|
||||
}
|
||||
onSwipeMove?.(currentOffsetX);
|
||||
event.preventDefault(); // Prevent scrolling during drag
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
if (element.hasPointerCapture(event.pointerId)) {
|
||||
element.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
|
||||
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
|
||||
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
|
||||
|
||||
if (
|
||||
Math.abs(currentOffsetX) < swipeThreshold ||
|
||||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
|
||||
) {
|
||||
resetPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
isSwipeInProgress = true;
|
||||
|
||||
onSwipeEnd?.(currentOffsetX);
|
||||
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = currentOffsetX < 0 ? 'left' : 'right';
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
|
||||
if (!animations) {
|
||||
currentOffsetX = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, -1);
|
||||
|
||||
const handleFinish = () => {
|
||||
cancelAnimations(animations);
|
||||
if (direction === 'left') {
|
||||
leftAnimations = null;
|
||||
} else {
|
||||
rightAnimations = null;
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const completeTransition = (direction: 'left' | 'right') => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
|
||||
|
||||
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
|
||||
onSwipe?.(direction);
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, 1);
|
||||
|
||||
const handleFinish = () => {
|
||||
if (contentElement) {
|
||||
onSwipe?.(direction);
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
};
|
||||
|
||||
const resetPreviewContainers = () => {
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
|
||||
if (contentElement) {
|
||||
contentElement.style.transform = '';
|
||||
contentElement.style.transition = '';
|
||||
contentElement.style.opacity = '';
|
||||
}
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const finishSwipeInProgress = () => {
|
||||
isSwipeInProgress = false;
|
||||
};
|
||||
|
||||
const resetSwipeFeedback = () => {
|
||||
resetPreviewContainers();
|
||||
finishSwipeInProgress();
|
||||
};
|
||||
|
||||
reset = resetSwipeFeedback;
|
||||
onMount(() =>
|
||||
eventManager.onMany([
|
||||
['ViewerFinishNavigate', finishSwipeInProgress],
|
||||
['ResetSwipeFeedback', resetSwipeFeedback],
|
||||
]),
|
||||
);
|
||||
|
||||
onDestroy(() => {
|
||||
resetSwipeFeedback();
|
||||
if (element) {
|
||||
element.style.cursor = '';
|
||||
}
|
||||
if (contentElement) {
|
||||
contentElement.style.cursor = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
|
||||
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
bind:clientWidth
|
||||
bind:clientHeight
|
||||
class={className}
|
||||
style:cursor={cursorStyle}
|
||||
style:view-transition-name={transitionName}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
role="presentation"
|
||||
>
|
||||
{#if leftPreview}
|
||||
<!-- Swiping right reveals left preview -->
|
||||
<div
|
||||
bind:this={leftPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={rightAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render leftPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rightPreview}
|
||||
<!-- Swiping left reveals right preview -->
|
||||
<div
|
||||
bind:this={rightPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={leftAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render rightPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import {
|
||||
autoPlayVideo,
|
||||
@@ -10,56 +14,88 @@
|
||||
videoViewerMuted,
|
||||
videoViewerVolume,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
import { getDimensions } from '$lib/utils/asset-utils';
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
assetId: string;
|
||||
transitionName?: string;
|
||||
cursor: AssetCursor;
|
||||
assetId?: string;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
loopVideo: boolean;
|
||||
cacheKey: string | null;
|
||||
playOriginalVideo: boolean;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
cursor,
|
||||
assetId,
|
||||
sharedLink,
|
||||
loopVideo,
|
||||
cacheKey,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset = () => {},
|
||||
onNextAsset = () => {},
|
||||
onSwipe,
|
||||
onVideoEnded = () => {},
|
||||
onVideoStarted = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const asset = $derived(cursor.current);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const effectiveAssetId = $derived(assetId ?? asset.id);
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let assetFileUrl = $derived(
|
||||
playOriginalVideo
|
||||
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
|
||||
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
|
||||
? getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Original, cacheKey })
|
||||
: getAssetPlaybackUrl({ id: effectiveAssetId, cacheKey }),
|
||||
);
|
||||
let previousAssetFileUrl = $state<string | undefined>();
|
||||
let isScrubbing = $state(false);
|
||||
let showVideo = $state(false);
|
||||
|
||||
let containerWidth = $state(document.documentElement.clientWidth);
|
||||
let containerHeight = $state(document.documentElement.clientHeight);
|
||||
|
||||
const exifDimensions = $derived(
|
||||
asset?.exifInfo?.exifImageHeight && asset?.exifInfo.exifImageHeight
|
||||
? (getDimensions(asset.exifInfo) as { width: number; height: number })
|
||||
: null,
|
||||
);
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
|
||||
const scaledDimensions = $derived(scaleToFit(dimensions, container));
|
||||
|
||||
onMount(() => {
|
||||
// Show video after mount to ensure fading in.
|
||||
showVideo = true;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// reactive on `assetFileUrl` changes
|
||||
if (assetFileUrl) {
|
||||
videoPlayer?.load();
|
||||
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
|
||||
previousAssetFileUrl = assetFileUrl;
|
||||
untrack(() => {
|
||||
isLoading = true;
|
||||
videoPlayer?.load();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,6 +105,14 @@
|
||||
}
|
||||
});
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
dimensions = {
|
||||
width: videoPlayer?.videoWidth ?? 1,
|
||||
height: videoPlayer?.videoHeight ?? 1,
|
||||
};
|
||||
eventManager.emit('AssetViewerFree');
|
||||
};
|
||||
|
||||
const handleCanPlay = async (video: HTMLVideoElement) => {
|
||||
try {
|
||||
if (!video.paused && !isScrubbing) {
|
||||
@@ -100,76 +144,119 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
if (event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
|
||||
const calculateSize = () => {
|
||||
const { width, height } = scaledDimensions;
|
||||
|
||||
const size = {
|
||||
width: width + 'px',
|
||||
height: height + 'px',
|
||||
};
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
const box = $derived(calculateSize());
|
||||
</script>
|
||||
|
||||
{#if showVideo}
|
||||
<div
|
||||
transition:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<video
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
class="h-full object-contain"
|
||||
{...useSwipe(onSwipe)}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
<SwipeFeedback
|
||||
class="flex select-none h-full w-full place-content-center place-items-center"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
{onSwipe}
|
||||
>
|
||||
{#if showVideo}
|
||||
<div
|
||||
in:fade={{ duration: assetViewerFadeDuration }}
|
||||
class="flex h-full w-full place-content-center place-items-center"
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
{onVideoEnded}
|
||||
{assetFileUrl}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
style:height={box.height}
|
||||
style:width={box.width}
|
||||
bind:this={videoPlayer}
|
||||
loop={$loopVideoPreference && loopVideo}
|
||||
autoplay={$autoPlayVideo}
|
||||
playsinline
|
||||
controls
|
||||
disablePictureInPicture
|
||||
onloadedmetadata={() => handleLoadedMetadata()}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
|
||||
onseeking={() => (isScrubbing = true)}
|
||||
onseeked={() => (isScrubbing = false)}
|
||||
onplaying={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onclose={() => onClose()}
|
||||
muted={$videoViewerMuted}
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} assetId={effectiveAssetId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#snippet leftPreview()}
|
||||
{#if previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={previousAsset}
|
||||
{sharedLink}
|
||||
container={{ width: containerWidth, height: containerHeight }}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={nextAsset}
|
||||
{sharedLink}
|
||||
container={{ width: containerWidth, height: containerHeight }}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
<style>
|
||||
video:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string;
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
const { asset }: Props = $props();
|
||||
const { asset, transitionName }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
@@ -24,6 +25,7 @@
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
|
||||
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
|
||||
plugins={[videoPlugin]}
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import type { SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
transitionName?: string;
|
||||
cursor: AssetCursor;
|
||||
assetId?: string;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
projectionType: string | null | undefined;
|
||||
cacheKey: string | null;
|
||||
loopVideo: boolean;
|
||||
playOriginalVideo: boolean;
|
||||
onClose?: () => void;
|
||||
onPreviousAsset?: () => void;
|
||||
onNextAsset?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
onVideoEnded?: () => void;
|
||||
onVideoStarted?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
asset,
|
||||
transitionName,
|
||||
cursor,
|
||||
assetId,
|
||||
sharedLink,
|
||||
projectionType,
|
||||
cacheKey,
|
||||
loopVideo,
|
||||
playOriginalVideo,
|
||||
onPreviousAsset,
|
||||
onSwipe,
|
||||
onClose,
|
||||
onNextAsset,
|
||||
onVideoEnded,
|
||||
onVideoStarted,
|
||||
}: Props = $props();
|
||||
|
||||
const effectiveAssetId = $derived(assetId ?? asset.id);
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {asset} />
|
||||
<VideoPanoramaViewer {transitionName} asset={cursor.current} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{transitionName}
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
assetId={effectiveAssetId}
|
||||
{cursor}
|
||||
{assetId}
|
||||
{sharedLink}
|
||||
{playOriginalVideo}
|
||||
{onPreviousAsset}
|
||||
{onNextAsset}
|
||||
{onSwipe}
|
||||
{onVideoEnded}
|
||||
{onVideoStarted}
|
||||
{onClose}
|
||||
|
||||
@@ -2,24 +2,34 @@
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiImageBrokenVariant } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
class?: ClassValue;
|
||||
hideMessage?: boolean;
|
||||
width?: string | undefined;
|
||||
height?: string | undefined;
|
||||
}
|
||||
|
||||
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
|
||||
|
||||
let clientWidth = $state(0);
|
||||
let textClass = $derived(clientWidth < 100 ? 'text-xs' : clientWidth < 150 ? 'text-sm' : 'text-base');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
|
||||
bind:clientWidth
|
||||
class={[
|
||||
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
||||
className,
|
||||
]}
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
||||
{#if clientWidth >= 75}
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
|
||||
{/if}
|
||||
{#if !hideMessage}
|
||||
<span class="text-center">{$t('error_loading_image')}</span>
|
||||
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { imageLoader } from '$lib/actions/image-loader.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiEyeOffOutline } from '@mdi/js';
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
@@ -49,53 +48,46 @@
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
};
|
||||
|
||||
const setErrored = () => {
|
||||
errored = true;
|
||||
onComplete?.(true);
|
||||
};
|
||||
|
||||
function mount(elem: HTMLImageElement): ActionReturn {
|
||||
if (elem.complete) {
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
}
|
||||
return {
|
||||
destroy: () => imageManager.cancelPreloadUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
let optionalClasses = $derived(
|
||||
let optionalClasses = $derived([
|
||||
[
|
||||
curve && 'rounded-xl',
|
||||
circle && 'rounded-full',
|
||||
shadow && 'shadow-lg',
|
||||
(circle || !heightStyle) && 'aspect-square',
|
||||
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
|
||||
brokenAssetClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
brokenAssetClass,
|
||||
]);
|
||||
|
||||
let style = $derived(
|
||||
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if errored}
|
||||
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
|
||||
{:else}
|
||||
<img
|
||||
use:mount
|
||||
onload={setLoaded}
|
||||
onerror={setErrored}
|
||||
style:width={widthStyle}
|
||||
style:height={heightStyle}
|
||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||
style:opacity={hidden ? '0.5' : '1'}
|
||||
src={url}
|
||||
alt={loaded || errored ? altText : ''}
|
||||
{title}
|
||||
class={['object-cover', optionalClasses, imageClass]}
|
||||
draggable="false"
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div
|
||||
use:imageLoader={{
|
||||
src: url,
|
||||
onLoad: setLoaded,
|
||||
onError: setErrored,
|
||||
imgClass: ['object-cover', imageClass],
|
||||
style,
|
||||
alt: loaded || errored ? altText : '',
|
||||
draggable: false,
|
||||
title,
|
||||
loading: preload ? 'eager' : 'lazy',
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
|
||||
@@ -200,8 +200,9 @@
|
||||
|
||||
<div
|
||||
class={[
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
'group focus-visible:outline-none focus-visible:rounded-lg flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
@@ -223,18 +224,10 @@
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
data-selected={selected ? true : undefined}
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
@@ -247,13 +240,81 @@
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
>
|
||||
<ImageThumbnail
|
||||
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
|
||||
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- icon overlay -->
|
||||
<div>
|
||||
<!-- Gradient overlay on hover -->
|
||||
{#if !usingMobileDevice && !disabled}
|
||||
<div
|
||||
class={[
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:rounded-lg',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
></div>
|
||||
@@ -261,7 +322,10 @@
|
||||
|
||||
<!-- Dimmed support -->
|
||||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
<div
|
||||
id="a"
|
||||
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
@@ -329,72 +393,6 @@
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectionCandidate}
|
||||
@@ -427,11 +425,14 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:rounded-lg group-focus-visible:outline-2 group-focus-visible:-outline-offset-2',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { Duration } from 'luxon';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
@@ -12,6 +13,7 @@
|
||||
curve?: boolean;
|
||||
playIcon?: string;
|
||||
pauseIcon?: string;
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -23,6 +25,7 @@
|
||||
curve = false,
|
||||
playIcon = mdiPlayCircleOutline,
|
||||
pauseIcon = mdiPauseCircleOutline,
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
let remainingSeconds = $state(durationInSeconds);
|
||||
@@ -57,7 +60,7 @@
|
||||
{#if enablePlayback}
|
||||
<video
|
||||
bind:this={player}
|
||||
class="h-full w-full object-cover"
|
||||
class={['h-full w-full object-cover', className]}
|
||||
class:rounded-xl={curve}
|
||||
muted
|
||||
autoplay
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -44,12 +45,17 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
let isAssetViewer = $derived(appManager.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
@@ -58,13 +64,15 @@
|
||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||
>
|
||||
{#if sidebar}
|
||||
{#if isAssetViewer}
|
||||
<div></div>
|
||||
{:else if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<main class="relative w-full">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
type Props = {
|
||||
animationTargetAssetId?: string | null;
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
manager: VirtualScrollManager;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
@@ -26,10 +20,7 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const { animationTargetAssetId, viewerAssets, width, height, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
@@ -41,18 +32,20 @@
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
|
||||
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
data-transition-name={transitionName}
|
||||
style:view-transition-name={transitionName}
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
<!-- animate:flip={{ duration: transitionDuration }} -->
|
||||
{@render thumbnail({ asset, position })}
|
||||
{@render customThumbnailLayout?.(asset)}
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount, tick, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
toAssetViewerTransitionId?: string | null;
|
||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
singleSelect: boolean;
|
||||
assetInteraction: AssetInteraction;
|
||||
monthGroup: MonthGroup;
|
||||
manager: VirtualScrollManager;
|
||||
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
toAssetViewerTransitionId,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
assetInteraction,
|
||||
monthGroup,
|
||||
manager,
|
||||
onDayGroupSelect,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -51,6 +54,32 @@
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
let toTimelineTransitionAssetId = $state<string | null>(null);
|
||||
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
|
||||
|
||||
const transitionToTimelineCallback = ({ id }: { id: string }) => {
|
||||
const asset = monthGroup.findAssetById({ id });
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('TimelineLoaded', (event: { id: string | null }) => {
|
||||
toTimelineTransitionAssetId = event.id;
|
||||
void tick().then(resolve);
|
||||
});
|
||||
}),
|
||||
['timeline'],
|
||||
() => {
|
||||
toTimelineTransitionAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
);
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on('TransitionToTimeline', transitionToTimelineCallback));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -95,7 +124,7 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
{animationTargetAssetId}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
width={dayGroup.width}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -437,7 +439,7 @@
|
||||
next = forward
|
||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||
next.focus();
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +510,7 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
class:invisible
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
@@ -28,7 +30,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
isSelectionMode?: boolean;
|
||||
singleSelect?: boolean;
|
||||
@@ -104,6 +105,7 @@
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
let toAssetViewerTransitionId = $state<string | null>(null);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||
@@ -211,7 +213,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -223,7 +225,7 @@
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -241,10 +243,13 @@
|
||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||
});
|
||||
|
||||
const getScrollTarget = () => {
|
||||
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||
};
|
||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||
// after successful navigation.
|
||||
afterNavigate(({ complete }) => {
|
||||
void complete.finally(() => {
|
||||
void complete.finally(async () => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -253,8 +258,13 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,7 +274,7 @@
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -561,19 +571,6 @@
|
||||
|
||||
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
|
||||
};
|
||||
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -604,6 +601,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -683,11 +681,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
manager={timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
@@ -701,12 +699,56 @@
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
onClick={async (asset) => {
|
||||
const onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const dispatchClick = () => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, onClick);
|
||||
} else {
|
||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
};
|
||||
|
||||
const hasThumbnailClick = typeof onThumbnailClick === 'function';
|
||||
const selectingAssets = isSelectionMode || assetInteraction.selectionActive;
|
||||
|
||||
if (!viewTransitionManager.isSupported() || hasThumbnailClick || selectingAssets) {
|
||||
dispatchClick();
|
||||
return;
|
||||
}
|
||||
|
||||
// tag target on the 'old' snapshot
|
||||
toAssetViewerTransitionId = asset.id;
|
||||
await tick();
|
||||
|
||||
eventManager.once('StartViewTransition', () => {
|
||||
toAssetViewerTransitionId = null;
|
||||
dispatchClick();
|
||||
});
|
||||
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('AssetViewerFree', () => {
|
||||
void tick().then(() => {
|
||||
eventManager.emit('TransitionToAssetViewer');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}),
|
||||
['viewer'],
|
||||
);
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -97,6 +98,10 @@
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
const awaitInit = new Promise<void>((resolve) => eventManager.once('StartViewTransition', resolve));
|
||||
eventManager.emit('TransitionToTimeline', { id: asset.id });
|
||||
await awaitInit;
|
||||
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
$locale = newLocale;
|
||||
}
|
||||
};
|
||||
|
||||
let editedLocale = $derived(findLocale($locale).code);
|
||||
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
|
||||
let selectedOption = $derived({
|
||||
|
||||
@@ -4,19 +4,46 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||
|
||||
type AssetLoadState = 'loading' | 'cancelled';
|
||||
|
||||
class ImageManager {
|
||||
// track recently canceled assets, so know if an load "error" is due to
|
||||
// cancelation
|
||||
private assetStates = new Map<string, AssetLoadState>();
|
||||
private readonly MAX_TRACKED_ASSETS = 10;
|
||||
|
||||
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
|
||||
// Remove if exists to reset insertion order
|
||||
this.assetStates.delete(asset.id);
|
||||
this.assetStates.set(asset.id, action);
|
||||
|
||||
// Only keep recent assets (Map maintains insertion order)
|
||||
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
|
||||
const firstKey = this.assetStates.keys().next().value!;
|
||||
this.assetStates.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
isCanceled(asset: AssetResponseDto) {
|
||||
return 'cancelled' === this.assetStates.get(asset.id);
|
||||
}
|
||||
|
||||
trackLoad(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'loading');
|
||||
}
|
||||
|
||||
trackCancelled(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'cancelled');
|
||||
}
|
||||
|
||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
this.trackLoad(asset);
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
||||
@@ -24,6 +51,8 @@ class ImageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackCancelled(asset);
|
||||
|
||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||
for (const size of sizes) {
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
@@ -32,12 +61,6 @@ class ImageManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined) {
|
||||
if (url) {
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
|
||||
127
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
127
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function traceTransitionEvents(msg: string, error?: unknown) {
|
||||
// console.log(msg, error);
|
||||
}
|
||||
class ViewTransitionManager {
|
||||
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||
#finishedCallbacks: (() => void)[] = [];
|
||||
|
||||
#splitViewerNavTransitionNames = true;
|
||||
|
||||
constructor() {
|
||||
const root = document.documentElement;
|
||||
const value = getComputedStyle(root).getPropertyValue('--immich-split-viewer-nav').trim();
|
||||
this.#splitViewerNavTransitionNames = value === 'enabled';
|
||||
}
|
||||
|
||||
getTransitionName = (kind: 'old' | 'new', name: string | null | undefined) => {
|
||||
if (name === 'previous' || name === 'next') {
|
||||
return this.#splitViewerNavTransitionNames ? name + '-' + kind : name;
|
||||
} else if (name) {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
get activeViewTransition() {
|
||||
return this.#activeViewTransition;
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
return 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
skipTransitions() {
|
||||
const skippedTransitions = !!this.#activeViewTransition;
|
||||
this.#activeViewTransition?.skipTransition();
|
||||
this.#notifyFinished();
|
||||
return skippedTransitions;
|
||||
}
|
||||
|
||||
startTransition(domUpdateComplete: Promise<unknown>, types?: string[], finishedCallback?: () => unknown) {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('View transition API not available');
|
||||
}
|
||||
if (this.#activeViewTransition) {
|
||||
traceTransitionEvents('Can not start transition - one already active');
|
||||
return;
|
||||
}
|
||||
|
||||
// good time to add view-transition-name styles (if needed)
|
||||
traceTransitionEvents('emit BeforeStartViewTransition');
|
||||
eventManager.emit('BeforeStartViewTransition');
|
||||
|
||||
// next call will create the 'old' view snapshot
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition({
|
||||
update: async () => {
|
||||
// Good time to remove any view-transition-name styles created during
|
||||
// BeforeStartViewTransition, then trigger the actual view transition.
|
||||
traceTransitionEvents('emit StartViewTransition');
|
||||
eventManager.emit('StartViewTransition');
|
||||
|
||||
await domUpdateComplete;
|
||||
traceTransitionEvents('awaited domUpdateComplete');
|
||||
},
|
||||
types,
|
||||
});
|
||||
} catch {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition(async () => {
|
||||
// Good time to remove any view-transition-name styles created during
|
||||
// BeforeStartViewTransition, then trigger the actual view transition.
|
||||
traceTransitionEvents('emit StartViewTransition');
|
||||
eventManager.emit('StartViewTransition');
|
||||
await domUpdateComplete;
|
||||
traceTransitionEvents('awaited domUpdateComplete');
|
||||
});
|
||||
}
|
||||
this.#activeViewTransition = transition;
|
||||
this.#finishedCallbacks.push(() => {
|
||||
this.#activeViewTransition = null;
|
||||
});
|
||||
if (finishedCallback) {
|
||||
this.#finishedCallbacks.push(finishedCallback);
|
||||
}
|
||||
// UpdateCallbackDone is a good time to add any view-transition-name styles
|
||||
// to the new DOM state, before the 'new' view snapshot is creatd
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.updateCallbackDone
|
||||
.then(() => {
|
||||
traceTransitionEvents('emit UpdateCallbackDone');
|
||||
eventManager.emit('UpdateCallbackDone');
|
||||
})
|
||||
.catch((error: unknown) => traceTransitionEvents('error in UpdateCallbackDone', error));
|
||||
// Both old/new snapshots are taken - pseudo elements are created, transition is
|
||||
// about to start
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.ready
|
||||
.then(() => eventManager.emit('Ready'))
|
||||
.catch((error: unknown) => {
|
||||
this.#notifyFinished();
|
||||
traceTransitionEvents('error in Ready', error);
|
||||
});
|
||||
// Transition is complete
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.finished
|
||||
.then(() => {
|
||||
traceTransitionEvents('emit Finished');
|
||||
eventManager.emit('Finished');
|
||||
})
|
||||
.catch((error: unknown) => traceTransitionEvents('error in Finished', error));
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.finished.then(() => this.#notifyFinished());
|
||||
}
|
||||
|
||||
#notifyFinished() {
|
||||
for (const callback of this.#finishedCallbacks) {
|
||||
callback();
|
||||
}
|
||||
this.#finishedCallbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const viewTransitionManager = new ViewTransitionManager();
|
||||
13
web/src/lib/managers/app-manager.svelte.ts
Normal file
13
web/src/lib/managers/app-manager.svelte.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
class AppManager {
|
||||
#isAssetViewer = $state<boolean>(false);
|
||||
|
||||
set isAssetViewer(value: boolean) {
|
||||
this.#isAssetViewer = value;
|
||||
}
|
||||
|
||||
get isAssetViewer() {
|
||||
return this.#isAssetViewer;
|
||||
}
|
||||
}
|
||||
|
||||
export const appManager = new AppManager();
|
||||
@@ -21,6 +21,10 @@ import type {
|
||||
export type Events = {
|
||||
AppInit: [];
|
||||
|
||||
ResetSwipeFeedback: [];
|
||||
ViewerFinishNavigate: [];
|
||||
AssetViewerAfterNavigate: [];
|
||||
|
||||
AuthLogin: [LoginResponseDto];
|
||||
AuthLogout: [];
|
||||
AuthUserLoaded: [UserAdminResponseDto];
|
||||
@@ -73,6 +77,19 @@ export type Events = {
|
||||
|
||||
SessionLocked: [];
|
||||
|
||||
TransitionToTimeline: [{ id: string }];
|
||||
TimelineLoaded: [{ id: string | null }];
|
||||
|
||||
TransitionToAssetViewer: [];
|
||||
AssetViewerLoaded: [];
|
||||
AssetViewerFree: [];
|
||||
|
||||
BeforeStartViewTransition: [];
|
||||
Finished: [];
|
||||
Ready: [];
|
||||
UpdateCallbackDone: [];
|
||||
StartViewTransition: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
|
||||
@@ -6,8 +6,10 @@ class UploadManager {
|
||||
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AppInit', () => this.#loadExtensions());
|
||||
eventManager.on('AuthLogout', () => this.reset());
|
||||
eventManager.onMany([
|
||||
['AppInit', () => this.#loadExtensions()],
|
||||
['AuthLogout', () => this.reset()],
|
||||
]);
|
||||
}
|
||||
|
||||
reset() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const invisible = writable<boolean>(false);
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
|
||||
setAsset,
|
||||
setAssetId,
|
||||
showAssetViewer,
|
||||
invisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoViewerImgElement = writable<HTMLImageElement>();
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@@ -23,8 +23,10 @@ class MemoryStoreSvelte {
|
||||
#loading: Promise<void> | undefined;
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogout', () => this.clearCache());
|
||||
eventManager.on('AuthUserLoaded', () => this.initialize());
|
||||
eventManager.onMany([
|
||||
['AuthLogout', () => this.clearCache()],
|
||||
['AuthUserLoaded', () => this.initialize()],
|
||||
]);
|
||||
}
|
||||
|
||||
ready() {
|
||||
|
||||
@@ -8,8 +8,10 @@ class NotificationStore {
|
||||
notifications = $state<NotificationDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogin', () => this.refresh());
|
||||
eventManager.on('AuthLogout', () => this.clear());
|
||||
eventManager.onMany([
|
||||
['AuthLogin', () => this.refresh()],
|
||||
['AuthLogout', () => this.clear()],
|
||||
]);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
||||
@@ -200,13 +200,10 @@ export const getAssetUrl = ({
|
||||
sharedLink,
|
||||
forceOriginal = false,
|
||||
}: {
|
||||
asset: AssetResponseDto | undefined;
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
forceOriginal?: boolean;
|
||||
}) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const id = asset.id;
|
||||
const cacheKey = asset.thumbhash;
|
||||
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
||||
|
||||
247
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
247
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetMediaUrl, getAssetUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
/**
|
||||
* Quality levels for progressive image loading
|
||||
*/
|
||||
type ImageQuality =
|
||||
| 'basic'
|
||||
| 'loading-thumbnail'
|
||||
| 'thumbnail'
|
||||
| 'loading-preview'
|
||||
| 'preview'
|
||||
| 'loading-original'
|
||||
| 'original';
|
||||
|
||||
export interface ImageLoaderState {
|
||||
previewUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
originalUrl?: string;
|
||||
quality: ImageQuality;
|
||||
hasError: boolean;
|
||||
thumbnailImage: ImageStatus;
|
||||
previewImage: ImageStatus;
|
||||
originalImage: ImageStatus;
|
||||
}
|
||||
enum ImageStatus {
|
||||
Unloaded = 'Unloaded',
|
||||
Success = 'Success',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates adaptive loading of a single asset image:
|
||||
* thumbhash → thumbnail → preview → original (on zoom)
|
||||
*
|
||||
*/
|
||||
export class AdaptiveImageLoader {
|
||||
private state = $state<ImageLoaderState>({
|
||||
quality: 'basic',
|
||||
hasError: false,
|
||||
thumbnailImage: ImageStatus.Unloaded,
|
||||
previewImage: ImageStatus.Unloaded,
|
||||
originalImage: ImageStatus.Unloaded,
|
||||
});
|
||||
|
||||
private readonly currentZoomFn?: () => number;
|
||||
|
||||
private readonly imageLoader?: LoadImageFunction;
|
||||
private readonly destroyFunctions: (() => void)[] = [];
|
||||
readonly thumbnailUrl: string;
|
||||
readonly previewUrl: string;
|
||||
readonly originalUrl: string;
|
||||
readonly asset: AssetResponseDto;
|
||||
readonly callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
|
||||
};
|
||||
destroyed = false;
|
||||
|
||||
constructor(
|
||||
asset: AssetResponseDto,
|
||||
sharedLink: SharedLinkResponseDto | undefined,
|
||||
callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
|
||||
},
|
||||
imageLoader?: LoadImageFunction,
|
||||
) {
|
||||
imageManager.trackLoad(asset);
|
||||
this.asset = asset;
|
||||
this.callbacks = callbacks;
|
||||
|
||||
this.imageLoader = imageLoader;
|
||||
this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail });
|
||||
this.previewUrl = getAssetUrl({ asset, sharedLink });
|
||||
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
|
||||
this.state.thumbnailUrl = this.thumbnailUrl;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.imageLoader) {
|
||||
throw new Error('Start requires imageLoader to be specified');
|
||||
}
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.thumbnailUrl,
|
||||
{},
|
||||
() => this.onThumbnailLoad(),
|
||||
() => this.onThumbnailError(),
|
||||
() => this.onThumbnailStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get adaptiveLoaderState(): ImageLoaderState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onThumbnailStart() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-thumbnail';
|
||||
}
|
||||
|
||||
onThumbnailLoad() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'thumbnail';
|
||||
this.state.thumbnailImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
this.callbacks?.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail');
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
onThumbnailError() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.hasError = true;
|
||||
this.state.thumbnailUrl = undefined;
|
||||
this.state.thumbnailImage = ImageStatus.Error;
|
||||
this.callbacks?.onError?.();
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
triggerMainImage() {
|
||||
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
|
||||
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
|
||||
}
|
||||
|
||||
triggerPreview() {
|
||||
if (!this.previewUrl) {
|
||||
// no preview, try original?
|
||||
this.triggerOriginal();
|
||||
return false;
|
||||
}
|
||||
this.state.hasError = false;
|
||||
this.state.previewUrl = this.previewUrl;
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.previewUrl,
|
||||
{},
|
||||
() => this.onPreviewLoad(),
|
||||
() => this.onPreviewError(),
|
||||
() => this.onPreviewStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onPreviewStart() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-preview';
|
||||
}
|
||||
|
||||
onPreviewLoad() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'preview';
|
||||
this.state.previewImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
this.callbacks?.onQualityUpgrade?.(this.previewUrl, 'preview');
|
||||
}
|
||||
|
||||
onPreviewError() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.hasError = true;
|
||||
this.state.previewImage = ImageStatus.Error;
|
||||
this.state.previewUrl = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
this.triggerOriginal();
|
||||
}
|
||||
|
||||
triggerOriginal() {
|
||||
if (!this.originalUrl) {
|
||||
return false;
|
||||
}
|
||||
this.state.hasError = false;
|
||||
|
||||
this.state.originalUrl = this.originalUrl;
|
||||
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.originalUrl,
|
||||
{},
|
||||
() => this.onOriginalLoad(),
|
||||
() => this.onOriginalError(),
|
||||
() => this.onOriginalStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOriginalStart() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-original';
|
||||
}
|
||||
|
||||
onOriginalLoad() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'original';
|
||||
this.state.originalImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
}
|
||||
|
||||
onOriginalError() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.hasError = true;
|
||||
this.state.originalImage = ImageStatus.Error;
|
||||
this.state.originalUrl = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
if (this.imageLoader) {
|
||||
for (const destroy of this.destroyFunctions) {
|
||||
destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
imageManager.cancel(this.asset);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,23 @@ export class BaseEventManager<Events extends EventMap> {
|
||||
};
|
||||
}
|
||||
|
||||
onMany<T extends keyof Events>(subscriptions: Array<[T, EventCallback<Events, T>?]>) {
|
||||
const cleanups = subscriptions.map(([event, callback]) => this.on(event, callback));
|
||||
return () => {
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
|
||||
const unsubscribe = this.on(event, (...args: Events[T]) => {
|
||||
unsubscribe();
|
||||
return callback(...args);
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
/**
|
||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||
* This class helps manage concurrent operations by tracking which invocations are active
|
||||
@@ -53,14 +51,19 @@ export class InvocationTracker {
|
||||
return this.invocationsStarted !== this.invocationsEnded;
|
||||
}
|
||||
|
||||
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
|
||||
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, localizedMessage);
|
||||
if (catchCallback) {
|
||||
catchCallback(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
finallyCallback?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,3 +129,19 @@ export type CommonPosition = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Scales dimensions to fit within a container (like object-fit: contain)
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
) => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
import { ServiceWorkerMessenger } from './sw-messenger';
|
||||
|
||||
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
|
||||
// eslint-disable-next-line compat/compat
|
||||
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
|
||||
|
||||
export function cancelImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
if (!url || !messenger) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
}
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
messenger.send('cancel', { url });
|
||||
}
|
||||
|
||||
17
web/src/lib/utils/sw-messenger.ts
Normal file
17
web/src/lib/utils/sw-messenger.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class ServiceWorkerMessenger {
|
||||
readonly #serviceWorker: ServiceWorkerContainer;
|
||||
|
||||
constructor(serviceWorker: ServiceWorkerContainer) {
|
||||
this.#serviceWorker = serviceWorker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-way message to the service worker.
|
||||
*/
|
||||
send(type: string, data: Record<string, unknown>) {
|
||||
this.#serviceWorker.controller?.postMessage({
|
||||
type,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:display-none={$showAssetViewer}>
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<UploadCover />
|
||||
@@ -33,7 +33,4 @@
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||
@@ -8,6 +8,7 @@
|
||||
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
|
||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
@@ -58,6 +59,8 @@
|
||||
|
||||
let showNavigationLoadingBar = $state(false);
|
||||
|
||||
appManager.isAssetViewer = isAssetViewerRoute(page);
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
@@ -83,8 +86,15 @@
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
onNavigate(({ to }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
});
|
||||
|
||||
afterNavigate(({ to, complete }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
void complete.finally(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
});
|
||||
|
||||
const { serverRestarting } = websocketStore;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { handleCancel, handlePreload } from './request';
|
||||
|
||||
export const installBroadcastChannelListener = () => {
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
broadcast.onmessage = (event) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.data.url, event.origin);
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'preload': {
|
||||
handlePreload(url);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
handleCancel(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { version } from '$service-worker';
|
||||
|
||||
const CACHE = `cache-${version}`;
|
||||
|
||||
let _cache: Cache | undefined;
|
||||
const getCache = async () => {
|
||||
if (_cache) {
|
||||
return _cache;
|
||||
}
|
||||
_cache = await caches.open(CACHE);
|
||||
return _cache;
|
||||
};
|
||||
|
||||
export const get = async (key: string) => {
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
return cache.match(key);
|
||||
};
|
||||
|
||||
export const put = async (key: string, response: Response) => {
|
||||
if (response.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.put(key, response.clone());
|
||||
};
|
||||
|
||||
export const prune = async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,9 +2,9 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||
import { prune } from './cache';
|
||||
import { handleRequest } from './request';
|
||||
|
||||
import { installMessageListener } from './messaging';
|
||||
import { handleFetch as handleAssetFetch } from './request';
|
||||
|
||||
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||
|
||||
@@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const handleActivate = (event: ExtendableEvent) => {
|
||||
event.waitUntil(sw.clients.claim());
|
||||
event.waitUntil(prune());
|
||||
};
|
||||
|
||||
const handleInstall = (event: ExtendableEvent) => {
|
||||
event.waitUntil(sw.skipWaiting());
|
||||
// do not preload app resources
|
||||
};
|
||||
|
||||
const handleFetch = (event: FetchEvent): void => {
|
||||
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
// Cache requests for thumbnails
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
|
||||
event.respondWith(handleRequest(event.request));
|
||||
event.respondWith(handleAssetFetch(event.request));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
sw.addEventListener('install', handleInstall, { passive: true });
|
||||
sw.addEventListener('activate', handleActivate, { passive: true });
|
||||
sw.addEventListener('fetch', handleFetch, { passive: true });
|
||||
installBroadcastChannelListener();
|
||||
installMessageListener();
|
||||
|
||||
53
web/src/service-worker/messaging.ts
Normal file
53
web/src/service-worker/messaging.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { handleCancel } from './request';
|
||||
|
||||
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
/**
|
||||
* Send acknowledgment for a request
|
||||
*/
|
||||
function sendAck(client: Client, requestId: string) {
|
||||
client.postMessage({
|
||||
type: 'ack',
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 'cancel' request: cancel a pending request
|
||||
*/
|
||||
const handleCancelRequest = (client: Client, url: URL, requestId: string) => {
|
||||
sendAck(client, requestId);
|
||||
handleCancel(url);
|
||||
};
|
||||
|
||||
export const installMessageListener = () => {
|
||||
sw.addEventListener('message', (event) => {
|
||||
if (!event.data?.requestId || !event.data?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = event.data.requestId;
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'cancel': {
|
||||
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = event.source;
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleCancelRequest(client, url, requestId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,73 +1,69 @@
|
||||
import { get, put } from './cache';
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
|
||||
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||
|
||||
const assertResponse = (response: Response) => {
|
||||
if (!(response instanceof Response)) {
|
||||
throw new TypeError('Fetch did not return a valid Response object');
|
||||
}
|
||||
type PendingRequest = {
|
||||
controller: AbortController;
|
||||
promise: Promise<Response>;
|
||||
cleanupTimeout?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
|
||||
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
|
||||
|
||||
const CANCELATION_MESSAGE = 'Request canceled by application';
|
||||
const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export const handleFetch = (request: URL | Request): Promise<Response> => {
|
||||
const requestKey = getRequestKey(request);
|
||||
const existing = pendingRequests.get(requestKey);
|
||||
|
||||
if (existing) {
|
||||
// Clone the response since response bodies can only be read once
|
||||
// Each caller gets an independent clone they can consume
|
||||
return existing.promise.then((response) => response.clone());
|
||||
}
|
||||
|
||||
if (isRequest(request)) {
|
||||
return request.url;
|
||||
}
|
||||
const pendingRequest: PendingRequest = {
|
||||
controller: new AbortController(),
|
||||
promise: undefined as unknown as Promise<Response>,
|
||||
cleanupTimeout: undefined,
|
||||
};
|
||||
pendingRequests.set(requestKey, pendingRequest);
|
||||
|
||||
throw new Error(`Invalid request: ${request}`);
|
||||
};
|
||||
// NOTE: fetch returns after headers received, not the body
|
||||
pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal })
|
||||
.catch((error: unknown) => {
|
||||
const standardError = error instanceof Error ? error : new Error(String(error));
|
||||
if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
throw standardError;
|
||||
})
|
||||
.finally(() => {
|
||||
// Schedule cleanup after timeout to allow response body streaming to complete
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestKey);
|
||||
}, CLEANUP_TIMEOUT_MS);
|
||||
pendingRequest.cleanupTimeout = cleanupTimeout;
|
||||
});
|
||||
|
||||
export const handlePreload = async (request: URL | Request) => {
|
||||
try {
|
||||
return await handleRequest(request);
|
||||
} catch (error) {
|
||||
console.error(`Preload failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRequest = async (request: URL | Request) => {
|
||||
const cacheKey = getCacheKey(request);
|
||||
const cachedResponse = await get(cacheKey);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const cancelToken = new AbortController();
|
||||
pendingRequests.set(cacheKey, cancelToken);
|
||||
const response = await fetch(request, { signal: cancelToken.signal });
|
||||
|
||||
assertResponse(response);
|
||||
put(cacheKey, response);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
|
||||
console.log('Not an abort error', error);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRequests.delete(cacheKey);
|
||||
}
|
||||
// Clone for the first caller to keep the original response unconsumed for future callers
|
||||
return pendingRequest.promise.then((response) => response.clone());
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
const requestKey = getRequestKey(url);
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
const pendingRequest = pendingRequests.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
pendingRequest.controller.abort(CANCELATION_MESSAGE);
|
||||
if (pendingRequest.cleanupTimeout) {
|
||||
clearTimeout(pendingRequest.cleanupTimeout);
|
||||
}
|
||||
pendingRequests.delete(requestKey);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user