mirror of
https://github.com/immich-app/immich.git
synced 2025-12-29 09:14:59 +03:00
Compare commits
1 Commits
push-nwxlp
...
push-zpwso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d6b38008 |
@@ -148,6 +148,7 @@ export class MediaRepository {
|
||||
quality: options.quality,
|
||||
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: true,
|
||||
})
|
||||
.toFile(output);
|
||||
}
|
||||
|
||||
258
web/src/app.css
258
web/src/app.css
@@ -74,6 +74,9 @@
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
|
||||
/* transitions */
|
||||
--immich-split-viewer-nav: enabled;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -171,3 +174,258 @@
|
||||
@apply bg-subtle rounded-lg;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
::view-transition {
|
||||
background: black;
|
||||
animation-duration: 250ms;
|
||||
}
|
||||
|
||||
::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: 250ms 0s fadeOut forwards;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: 250ms 0s fadeIn forwards;
|
||||
}
|
||||
html:active-view-transition-type(slideshow) {
|
||||
&::view-transition-old(root) {
|
||||
animation: 1s 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: 1s 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
html:active-view-transition-type(viewer-nav) {
|
||||
&::view-transition-old(root) {
|
||||
animation: 350ms 0s fadeOut forwards;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
animation: 350ms 0s fadeIn forwards;
|
||||
}
|
||||
}
|
||||
::view-transition-old(info) {
|
||||
animation: 250ms 0s flyOutRight forwards;
|
||||
}
|
||||
::view-transition-new(info) {
|
||||
animation: 250ms 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) {
|
||||
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: 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: 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-old(hero) {
|
||||
animation: 350ms fadeOut forwards;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: 350ms fadeIn forwards;
|
||||
align-content: center;
|
||||
}
|
||||
::view-transition-old(next),
|
||||
::view-transition-old(next-old) {
|
||||
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutLeft forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(next),
|
||||
::view-transition-new(next-new) {
|
||||
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInRight forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-old(previous) {
|
||||
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutRight forwards;
|
||||
}
|
||||
::view-transition-old(previous-old) {
|
||||
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyOutRight forwards;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
::view-transition-new(previous) {
|
||||
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInLeft forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(previous-new) {
|
||||
animation: 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94) flyInLeft forwards;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes flyInLeft {
|
||||
from {
|
||||
/* object-position: -25dvw; */
|
||||
transform: translateX(-15%);
|
||||
opacity: 0.1;
|
||||
filter: blur(4px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
filter: blur(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyOutLeft {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
filter: blur(2px);
|
||||
}
|
||||
to {
|
||||
/* object-position: -25dvw; */
|
||||
transform: translateX(-15%);
|
||||
opacity: 0.1;
|
||||
filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyInRight {
|
||||
from {
|
||||
/* object-position: 25dvw; */
|
||||
transform: translateX(15%);
|
||||
opacity: 0.1;
|
||||
filter: blur(4px);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
filter: blur(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fly out to right */
|
||||
@keyframes flyOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
filter: blur(2px);
|
||||
}
|
||||
to {
|
||||
/* object-position: 50dvw 0px; */
|
||||
transform: translateX(15%);
|
||||
opacity: 0.1;
|
||||
filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
::view-transition-group(previous),
|
||||
::view-transition-group(next) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
::view-transition-old(previous),
|
||||
::view-transition-old(next) {
|
||||
animation: 250ms fadeOut forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::view-transition-new(previous),
|
||||
::view-transition-new(next) {
|
||||
animation: 250ms fadeIn forwards;
|
||||
transform-origin: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,23 @@ import { thumbHashToRGBA } from 'thumbhash';
|
||||
* @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString)
|
||||
*/
|
||||
export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(base64ThumbHash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
const render = (hash: string) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const { w, h, rgba } = thumbHashToRGBA(decodeBase64(hash));
|
||||
const pixels = ctx.createImageData(w, h);
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
pixels.data.set(rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
}
|
||||
};
|
||||
|
||||
render(base64ThumbHash);
|
||||
|
||||
return {
|
||||
update({ base64ThumbHash: newHash }: { base64ThumbHash: string }) {
|
||||
render(newHash);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
|
||||
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||
|
||||
import * as luxon from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
@@ -255,7 +257,7 @@
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
}))}
|
||||
class="h-4.5 {disabled
|
||||
class="h-[18px] {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
|
||||
></Textarea>
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { resetZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { InvocationTracker } from '$lib/utils/invocationTracker';
|
||||
@@ -27,7 +29,6 @@
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
getStack,
|
||||
runAssetJobs,
|
||||
@@ -37,9 +38,9 @@
|
||||
type StackResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
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';
|
||||
@@ -90,7 +91,7 @@
|
||||
copyImage = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { setAssetId } = assetViewingStore;
|
||||
const { setAssetId, invisible } = assetViewingStore;
|
||||
const {
|
||||
restartProgress: restartSlideshowProgress,
|
||||
stopProgress: stopSlideshowProgress,
|
||||
@@ -103,7 +104,6 @@
|
||||
let asset = $derived(cursor.current);
|
||||
let nextAsset = $derived(cursor.nextAsset);
|
||||
let previousAsset = $derived(cursor.previousAsset);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let shouldPlayMotionPhoto = $state(false);
|
||||
let sharedLink = getSharedLink();
|
||||
let enableDetailPanel = $derived(asset.hasMetadata);
|
||||
@@ -117,9 +117,15 @@
|
||||
let selectedEditType: string = $state('');
|
||||
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 zoomToggle = $state(() => void 0);
|
||||
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
|
||||
|
||||
let refreshAlbumsSignal = $state(0);
|
||||
|
||||
const setPlayOriginalVideo = (value: boolean) => {
|
||||
playOriginalVideo = value;
|
||||
};
|
||||
@@ -154,7 +160,26 @@
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
let transitionName = $state<string | null>('hero');
|
||||
let equirectangularTransitionName = $state<string | null>('hero');
|
||||
let detailPanelTransitionName = $state<string | null>(null);
|
||||
|
||||
let addInfoTransition;
|
||||
let finished;
|
||||
onMount(() => {
|
||||
addInfoTransition = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
transitionName = 'hero';
|
||||
equirectangularTransitionName = 'hero';
|
||||
};
|
||||
eventManager.on('TransitionToAssetViewer', addInfoTransition);
|
||||
eventManager.on('TransitionToTimeline', addInfoTransition);
|
||||
finished = () => {
|
||||
detailPanelTransitionName = null;
|
||||
transitionName = null;
|
||||
};
|
||||
eventManager.on('Finished', finished);
|
||||
|
||||
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
@@ -171,10 +196,6 @@
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -191,20 +212,11 @@
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
eventManager.off('TransitionToAssetViewer', addInfoTransition!);
|
||||
eventManager.off('TransitionToTimeline', addInfoTransition!);
|
||||
eventManager.off('Finished', finished!);
|
||||
});
|
||||
|
||||
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 handleOpenActivity = () => {
|
||||
if ($isShowDetail) {
|
||||
$isShowDetail = false;
|
||||
@@ -218,6 +230,7 @@
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
transitionName = 'hero';
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
@@ -227,45 +240,98 @@
|
||||
});
|
||||
};
|
||||
|
||||
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 = null;
|
||||
}
|
||||
await tick();
|
||||
navigationResolve(await navigateFn());
|
||||
});
|
||||
eventManager.once('AssetViewerFree', () => tick().then(resolve));
|
||||
}),
|
||||
types,
|
||||
);
|
||||
});
|
||||
return navigationResult;
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
|
||||
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
|
||||
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();
|
||||
preloadManager.cancel(asset);
|
||||
if (tracker.isActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let skipped = false;
|
||||
if (viewTransitionManager.skipTransitions()) {
|
||||
skipped = true;
|
||||
}
|
||||
|
||||
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;
|
||||
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 if (onNavigateToAsset) {
|
||||
hasNext =
|
||||
order === 'previous'
|
||||
? await onNavigateToAsset(cursor.previousAsset)
|
||||
: await onNavigateToAsset(cursor.nextAsset);
|
||||
// only transition if the target is already preloaded, and is in a secure context
|
||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||
const navigate = async () =>
|
||||
order === 'previous' ? await onNavigateToAsset(previousAsset) : await onNavigateToAsset(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();
|
||||
}
|
||||
resetZoomState();
|
||||
} else {
|
||||
hasNext = false;
|
||||
}
|
||||
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
if (slideShowPlaying) {
|
||||
if (hasNext) {
|
||||
$restartSlideshowProgress = true;
|
||||
} else {
|
||||
@@ -333,7 +399,7 @@
|
||||
const handleAction = async (action: Action) => {
|
||||
switch (action.type) {
|
||||
case AssetAction.ADD_TO_ALBUM: {
|
||||
await handleGetAllAlbums();
|
||||
refreshAlbumsSignal++;
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
@@ -391,7 +457,6 @@
|
||||
|
||||
const refresh = async () => {
|
||||
await refreshStack();
|
||||
await handleGetAllAlbums();
|
||||
ocrManager.clear();
|
||||
if (!sharedLink) {
|
||||
if (previewStackedAsset) {
|
||||
@@ -416,6 +481,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
@@ -423,10 +495,7 @@
|
||||
if (asset.type === AssetTypeEnum.Image) {
|
||||
if (shouldPlayMotionPhoto && asset.livePhotoVideoId) {
|
||||
return 'LiveVideoViewer';
|
||||
} else if (
|
||||
asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
|
||||
(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
|
||||
) {
|
||||
} else if (isEquirectangular(asset)) {
|
||||
return 'ImagePanaramaViewer';
|
||||
} else if (isShowEditor && selectedEditType === 'crop') {
|
||||
return 'CropArea';
|
||||
@@ -444,12 +513,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 && !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}
|
||||
@@ -492,24 +565,30 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !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">
|
||||
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full items-center flex">
|
||||
{#if viewerKind === 'StackPhotoViewer'}
|
||||
<PhotoViewer
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{transitionName}
|
||||
cursor={{ ...cursor, current: previewStackedAsset! }}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
{sharedLink}
|
||||
/>
|
||||
{:else if viewerKind === 'StackVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
assetId={previewStackedAsset!.id}
|
||||
cacheKey={previewStackedAsset!.thumbhash}
|
||||
nextAsset={cursor.nextAsset}
|
||||
@@ -525,6 +604,8 @@
|
||||
/>
|
||||
{:else if viewerKind === 'LiveVideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
assetId={asset.livePhotoVideoId!}
|
||||
nextAsset={cursor.nextAsset}
|
||||
previousAsset={cursor.previousAsset}
|
||||
@@ -538,21 +619,24 @@
|
||||
{playOriginalVideo}
|
||||
/>
|
||||
{:else if viewerKind === 'ImagePanaramaViewer'}
|
||||
<ImagePanoramaViewer bind:zoomToggle {asset} />
|
||||
<ImagePanoramaViewer bind:zoomToggle {asset} transitionName={equirectangularTransitionName} />
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
{transitionName}
|
||||
bind:zoomToggle
|
||||
bind:copyImage
|
||||
{cursor}
|
||||
onPreviousAsset={() => navigateAsset('previous')}
|
||||
onNextAsset={() => navigateAsset('next')}
|
||||
onPreviousAsset={() => navigateAsset('previous', true)}
|
||||
onNextAsset={() => navigateAsset('next', true)}
|
||||
{sharedLink}
|
||||
onFree={() => eventManager.emit('AssetViewerFree')}
|
||||
onReady={() => eventManager.emit('AssetViewerFree')}
|
||||
/>
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{transitionName}
|
||||
{asset}
|
||||
assetId={asset.id}
|
||||
nextAsset={cursor.nextAsset}
|
||||
previousAsset={cursor.previousAsset}
|
||||
@@ -590,19 +674,23 @@
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !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 enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="detail-panel"
|
||||
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} onClose={() => ($isShowDetail = false)} />
|
||||
<DetailPanel {asset} {refreshAlbumsSignal} currentAlbum={album} onClose={() => ($isShowDetail = false)} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -13,13 +13,19 @@
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
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 } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
@@ -43,12 +49,12 @@
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
refreshAlbumsSignal?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
|
||||
let { asset, refreshAlbumsSignal = 0, currentAlbum = null, onClose }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@@ -74,6 +80,17 @@
|
||||
);
|
||||
let previousId: string | undefined = $state();
|
||||
|
||||
let albums = $state<AlbumResponseDto[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
refreshAlbumsSignal;
|
||||
if (authManager.isSharedLink) {
|
||||
return;
|
||||
}
|
||||
handlePromiseError(getAllAlbums({ assetId: asset.id }).then((response) => (albums = response)));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!previousId) {
|
||||
previousId = asset.id;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
transitionName?: string | null;
|
||||
asset: AssetResponseDto;
|
||||
zoomToggle?: (() => void) | null;
|
||||
};
|
||||
|
||||
let { asset, zoomToggle = $bindable() }: Props = $props();
|
||||
let { transitionName, asset, zoomToggle = $bindable() }: Props = $props();
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||
@@ -20,11 +21,12 @@
|
||||
};
|
||||
</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
|
||||
{transitionName}
|
||||
bind:zoomToggle
|
||||
panorama={data}
|
||||
originalPanorama={isWebCompatibleImage(asset)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
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 { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
@@ -27,6 +28,7 @@
|
||||
};
|
||||
|
||||
type Props = {
|
||||
transitionName?: string | null;
|
||||
panorama: string | { source: string };
|
||||
originalPanorama?: string | { source: string };
|
||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||
@@ -36,6 +38,7 @@
|
||||
};
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
panorama,
|
||||
originalPanorama,
|
||||
adapter = EquirectangularAdapter,
|
||||
@@ -154,6 +157,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 range: [0, 100]
|
||||
@@ -190,4 +200,9 @@
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, 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,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { swipeFeedback } from '$lib/actions/swipe-feedback';
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
@@ -11,14 +12,20 @@
|
||||
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 { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { photoZoomState, resetZoomState } from '$lib/stores/zoom-image.store';
|
||||
import {
|
||||
getAssetThumbnailUrl,
|
||||
getAssetUrl,
|
||||
targetImageSize as getTargetImageSize,
|
||||
handlePromiseError,
|
||||
} from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
@@ -28,41 +35,67 @@
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null | undefined;
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onFree?: (() => void) | null;
|
||||
onBusy?: (() => void) | null;
|
||||
onError?: (() => void) | null;
|
||||
onLoad?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
onFree?: (() => void) | null;
|
||||
onReady?: (() => void) | null;
|
||||
copyImage?: () => Promise<void>;
|
||||
zoomToggle?: (() => void) | null;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
transitionName,
|
||||
element = $bindable(),
|
||||
sharedLink,
|
||||
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
onFree = null,
|
||||
onBusy = null,
|
||||
onError = null,
|
||||
onLoad = null,
|
||||
onReady,
|
||||
onFree,
|
||||
copyImage = $bindable(),
|
||||
zoomToggle = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
|
||||
let swipeTarget = $state<HTMLElement | undefined>();
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
$effect(() => {
|
||||
if (loader) {
|
||||
const _loader = loader;
|
||||
const _src = loader.src;
|
||||
const _imageLoaderUrl = imageLoaderUrl;
|
||||
_loader.addEventListener('load', () => {
|
||||
if (_loader.src === _src && imageLoaderUrl === _imageLoaderUrl) {
|
||||
onload();
|
||||
}
|
||||
});
|
||||
_loader.addEventListener('error', () => {
|
||||
if (_loader.src === _src && imageLoaderUrl === _imageLoaderUrl) {
|
||||
onerror();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
resetZoomState();
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
|
||||
const box = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
@@ -74,16 +107,61 @@
|
||||
};
|
||||
});
|
||||
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
const blurredSlideshow = $derived(
|
||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && asset.thumbhash,
|
||||
);
|
||||
const transitionLetterboxLeft = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-left');
|
||||
const transitionLetterboxRight = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-right');
|
||||
const transitionLetterboxTop = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-top');
|
||||
const transitionLetterboxBottom = $derived(transitionName === 'hero' || blurredSlideshow ? null : 'letterbox-bottom');
|
||||
|
||||
// Letterbox regions (the empty space around the main box)
|
||||
const letterboxLeft = $derived.by(() => {
|
||||
const { width } = scaledDimensions;
|
||||
const leftOffset = (containerWidth - width) / 2;
|
||||
return {
|
||||
width: leftOffset + 'px',
|
||||
height: containerHeight + 'px',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
const letterboxRight = $derived.by(() => {
|
||||
const { width } = scaledDimensions;
|
||||
const leftOffset = (containerWidth - width) / 2;
|
||||
const rightOffset = leftOffset;
|
||||
return {
|
||||
width: rightOffset + 'px',
|
||||
height: containerHeight + 'px',
|
||||
left: containerWidth - rightOffset + 'px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxTop = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const topOffset = (containerHeight - height) / 2;
|
||||
const leftOffset = (containerWidth - width) / 2;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: topOffset + 'px',
|
||||
left: leftOffset + 'px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxBottom = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const topOffset = (containerHeight - height) / 2;
|
||||
const bottomOffset = topOffset;
|
||||
const leftOffset = (containerWidth - width) / 2;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: bottomOffset + 'px',
|
||||
left: leftOffset + 'px',
|
||||
top: containerHeight - bottomOffset + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
let ocrBoxes = $derived(
|
||||
@@ -161,10 +239,21 @@
|
||||
}
|
||||
};
|
||||
|
||||
let lastFreedUrl: string | undefined | null;
|
||||
const notifyFree = () => {
|
||||
if (lastFreedUrl !== imageLoaderUrl) {
|
||||
onFree?.();
|
||||
lastFreedUrl = imageLoaderUrl;
|
||||
}
|
||||
};
|
||||
|
||||
const notifyReady = () => {
|
||||
onReady?.();
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
onLoad?.();
|
||||
onFree?.();
|
||||
imageLoaded = true;
|
||||
notifyFree();
|
||||
dimensions = {
|
||||
width: loader?.naturalWidth ?? 1,
|
||||
height: loader?.naturalHeight ?? 1,
|
||||
@@ -173,19 +262,19 @@
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
onError?.();
|
||||
onFree?.();
|
||||
notifyFree();
|
||||
dimensions = {
|
||||
width: loader?.naturalWidth ?? 1,
|
||||
height: loader?.naturalHeight ?? 1,
|
||||
};
|
||||
imageError = imageLoaded = true;
|
||||
imageError = true;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
notifyReady();
|
||||
return () => {
|
||||
if (!imageLoaded && !imageError) {
|
||||
onFree?.();
|
||||
notifyFree();
|
||||
}
|
||||
if (imageLoaderUrl) {
|
||||
preloadManager.cancelPreloadUrl(imageLoaderUrl);
|
||||
@@ -196,8 +285,23 @@
|
||||
const imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
|
||||
);
|
||||
const previousAssetUrl = $derived(getAssetUrl({ asset: cursor.previousAsset, sharedLink }));
|
||||
const nextAssetUrl = $derived(getAssetUrl({ asset: cursor.nextAsset, sharedLink }));
|
||||
const previousAssetUrl = $derived(getAssetUrl({ asset: previousAsset, sharedLink }));
|
||||
const nextAssetUrl = $derived(getAssetUrl({ asset: nextAsset, sharedLink }));
|
||||
const thumbnailUrl = $derived(
|
||||
getAssetThumbnailUrl({
|
||||
id: asset.id,
|
||||
size: AssetMediaSize.Thumbnail,
|
||||
cacheKey: asset.thumbhash,
|
||||
}),
|
||||
);
|
||||
let thumbnailPreloaded = $state(false);
|
||||
// $effect(() => {
|
||||
// // eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
// asset;
|
||||
// untrack(() => {
|
||||
// void preloadManager.isUrlPreloaded(thumbnailUrl).then((preloaded) => (thumbnailPreloaded = preloaded));
|
||||
// });
|
||||
// });
|
||||
|
||||
const exifDimensions = $derived(
|
||||
asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageHeight
|
||||
@@ -214,31 +318,23 @@
|
||||
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
|
||||
const scaledDimensions = $derived(scaleToFit(dimensions, container));
|
||||
|
||||
let lastUrl: string | undefined | null | null;
|
||||
let lastPreviousUrl: string | undefined | null;
|
||||
let lastNextUrl: string | undefined | null;
|
||||
let lastUrl: string | undefined | null;
|
||||
|
||||
$effect(() => {
|
||||
if (!lastUrl) {
|
||||
untrack(() => onBusy?.());
|
||||
}
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
if (lastUrl !== imageLoaderUrl && imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
const isPreviewedImage = imageLoaderUrl === lastPreviousUrl || imageLoaderUrl === lastNextUrl;
|
||||
|
||||
if (!isPreviewedImage) {
|
||||
// It is a previewed image - prevent flicker - skip spinner but still let loader go through lifecycle
|
||||
imageLoaded = false;
|
||||
}
|
||||
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
onBusy?.();
|
||||
cancelImageUrl(lastUrl);
|
||||
notifyReady();
|
||||
});
|
||||
}
|
||||
|
||||
lastUrl = imageLoaderUrl;
|
||||
lastPreviousUrl = previousAssetUrl;
|
||||
lastNextUrl = nextAssetUrl;
|
||||
});
|
||||
$effect(() => {
|
||||
$photoViewerImgElement = loader;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -251,12 +347,8 @@
|
||||
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, 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} />
|
||||
|
||||
<!-- <img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} /> -->
|
||||
<div
|
||||
bind:this={element}
|
||||
class="relative h-full w-full select-none"
|
||||
@@ -268,33 +360,97 @@
|
||||
leftPreviewUrl: previousAssetUrl,
|
||||
rightPreviewUrl: nextAssetUrl,
|
||||
currentAssetUrl: imageLoaderUrl,
|
||||
target: $photoViewerImgElement,
|
||||
target: swipeTarget,
|
||||
}}
|
||||
>
|
||||
<div class="absolute" style:width={box.width} style:height={box.height} style:left={box.left} style:top={box.top}>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
{#if blurredSlideshow}
|
||||
<canvas
|
||||
id="test"
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash! }}
|
||||
class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
|
||||
></canvas>
|
||||
{/if}
|
||||
<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>
|
||||
<div
|
||||
style:view-transition-name={transitionName}
|
||||
data-transition-name={transitionName}
|
||||
class="absolute"
|
||||
style:left={box.left}
|
||||
style:top={box.top}
|
||||
style:width={box.width}
|
||||
style:height={box.height}
|
||||
bind:this={swipeTarget}
|
||||
>
|
||||
{#if asset.thumbhash}
|
||||
<canvas data-blur use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"
|
||||
></canvas>
|
||||
{#if thumbnailPreloaded}
|
||||
<img src={thumbnailUrl} alt={$getAltText(toTimelineAsset(asset))} class="h-full w-full absolute -z-1" />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !imageLoaded && !asset.thumbhash && !imageError}
|
||||
<div id="spinner" class="absolute flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
{/if}
|
||||
{#if imageError}
|
||||
<div class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
{#key imageLoaderUrl}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
style:width={box.width}
|
||||
style:height={box.height}
|
||||
style:overflow="visible"
|
||||
class="absolute"
|
||||
>
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<div use:zoomImageAction={{ disabled: isOcrActive }} style:width={box.width} style:height={box.height}>
|
||||
<img
|
||||
bind:this={$photoViewerImgElement}
|
||||
decoding="async"
|
||||
bind:this={loader}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
class={[
|
||||
'w-full',
|
||||
'h-full',
|
||||
$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook],
|
||||
imageError && 'hidden',
|
||||
]}
|
||||
draggable="false"
|
||||
/>
|
||||
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
|
||||
<div
|
||||
@@ -307,10 +463,9 @@
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/key}
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,9 +476,13 @@
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
[data-blur] {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.1s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
@@ -22,6 +23,8 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
asset: AssetResponseDto;
|
||||
assetId: string;
|
||||
previousAsset?: AssetResponseDto | null | undefined;
|
||||
nextAsset?: AssetResponseDto | undefined | null | undefined;
|
||||
@@ -37,6 +40,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
assetId,
|
||||
previousAsset,
|
||||
nextAsset,
|
||||
@@ -51,8 +56,6 @@
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let asset = $state<AssetResponseDto | null>(null);
|
||||
|
||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||
let isLoading = $state(true);
|
||||
let assetFileUrl = $derived(
|
||||
@@ -83,7 +86,7 @@
|
||||
|
||||
$effect(
|
||||
() =>
|
||||
void assetCacheManager.getAsset({ key: cacheKey ?? assetId, id: assetId }).then((assetDto) => (asset = assetDto)),
|
||||
void assetCacheManager.getAsset({ ...authManager.params, id: assetId }).then((assetDto) => (asset = assetDto)),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -184,7 +187,7 @@
|
||||
}}
|
||||
>
|
||||
{#if castManager.isCasting}
|
||||
<div class="place-content-center h-full place-items-center">
|
||||
<div class="place-content-center h-full place-items-center" data-swipe-subject>
|
||||
<VideoRemoteViewer
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
{onVideoStarted}
|
||||
@@ -193,8 +196,9 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<div class="relative">
|
||||
<video
|
||||
style:view-transition-name={transitionName}
|
||||
style:height={box.height}
|
||||
style:width={box.width}
|
||||
bind:this={videoPlayer}
|
||||
@@ -217,15 +221,14 @@
|
||||
bind:volume={$videoViewerVolume}
|
||||
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
|
||||
src={assetFileUrl}
|
||||
data-swipe-subject
|
||||
>
|
||||
</video>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="absolute flex place-content-center place-items-center">
|
||||
<div class="absolute inset-0 flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
@@ -233,3 +236,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
video:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
const { assetId }: Props = $props();
|
||||
const { assetId, transitionName }: Props = $props();
|
||||
|
||||
const modules = Promise.all([
|
||||
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
|
||||
@@ -23,6 +24,7 @@
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
<PhotoSphereViewer
|
||||
{transitionName}
|
||||
panorama={{ source: getAssetPlaybackUrl(assetId) }}
|
||||
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
|
||||
plugins={[videoPlugin]}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null;
|
||||
asset: AssetResponseDto;
|
||||
assetId: string;
|
||||
previousAsset?: AssetResponseDto | null | undefined;
|
||||
nextAsset?: AssetResponseDto | null | undefined;
|
||||
@@ -21,6 +23,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
transitionName,
|
||||
asset,
|
||||
assetId,
|
||||
previousAsset,
|
||||
nextAsset,
|
||||
@@ -38,11 +42,13 @@
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<VideoPanoramaViewer {assetId} />
|
||||
<VideoPanoramaViewer {assetId} {transitionName} />
|
||||
{:else}
|
||||
<VideoNativeViewer
|
||||
{transitionName}
|
||||
{loopVideo}
|
||||
{cacheKey}
|
||||
{asset}
|
||||
{assetId}
|
||||
{nextAsset}
|
||||
{sharedLink}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
|
||||
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { timeToSeconds } from '$lib/utils/date-time';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiArchiveArrowDownOutline,
|
||||
mdiCameraBurst,
|
||||
@@ -15,21 +23,11 @@
|
||||
mdiMotionPlayOutline,
|
||||
mdiRotate360,
|
||||
} from '@mdi/js';
|
||||
|
||||
import { thumbhash } from '$lib/actions/thumbhash';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { moveFocus } from '$lib/utils/focus-util';
|
||||
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ImageThumbnail from './image-thumbnail.svelte';
|
||||
import VideoThumbnail from './video-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: TimelineAsset;
|
||||
groupIndex?: number;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
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 { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext, type Snippet } from 'svelte';
|
||||
import type { AppState } from '../../../routes/+layout.svelte';
|
||||
|
||||
interface Props {
|
||||
hideNavbar?: boolean;
|
||||
@@ -37,13 +38,17 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
const appState = getContext('AppState') as AppState;
|
||||
let isAssetViewer = $derived(appState.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
<NavigationBar {showUploadButton} onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
{@render header?.()}
|
||||
</header>
|
||||
<div
|
||||
@@ -53,13 +58,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,15 @@
|
||||
<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 { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
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 +21,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 +33,21 @@
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName =
|
||||
animationTargetAssetId === asset.id && !mobileDevice.prefersReducedMotion ? '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,36 @@
|
||||
<script lang="ts">
|
||||
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 { onDestroy, 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 +53,34 @@
|
||||
});
|
||||
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', ({ id }) => {
|
||||
animationTargetAssetId = id;
|
||||
void tick().then(resolve);
|
||||
});
|
||||
}),
|
||||
[],
|
||||
() => {
|
||||
animationTargetAssetId = null;
|
||||
},
|
||||
);
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
eventManager.on('TransitionToTimeline', transitionToTimelineCallback);
|
||||
onDestroy(() => {
|
||||
eventManager.off('TransitionToTimeline', transitionToTimelineCallback);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -95,7 +125,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
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
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 { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
@@ -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;
|
||||
@@ -112,6 +113,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(mobileDevice.maxMd);
|
||||
@@ -219,7 +221,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -231,7 +233,7 @@
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -249,10 +251,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
|
||||
@@ -261,8 +266,13 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +282,7 @@
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -613,6 +623,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -692,11 +703,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
manager={timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
@@ -710,12 +721,39 @@
|
||||
{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 callClickHandler = () => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
};
|
||||
|
||||
if (!viewTransitionManager.isSupported()) {
|
||||
callClickHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
// tag target on the 'old' snapshot
|
||||
toAssetViewerTransitionId = asset.id;
|
||||
await tick();
|
||||
|
||||
eventManager.once('StartViewTransition', () => {
|
||||
toAssetViewerTransitionId = null;
|
||||
callClickHandler();
|
||||
});
|
||||
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('AssetViewerFree', () => {
|
||||
void tick().then(() => {
|
||||
eventManager.emit('TransitionToAssetViewer');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
|
||||
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';
|
||||
@@ -97,11 +96,12 @@
|
||||
if (!targetAsset) {
|
||||
return false;
|
||||
}
|
||||
let waitForAssetViewerFree = new Promise<void>((resolve) => {
|
||||
eventManager.once('AssetViewerFree', () => resolve());
|
||||
});
|
||||
// let waitForAssetViewerFree = new Promise<void>((resolve) => {
|
||||
// eventManager.once('AssetViewerFree', () => resolve());
|
||||
// });
|
||||
await navigate({ targetRoute: 'current', assetId: targetAsset.id });
|
||||
await waitForAssetViewerFree;
|
||||
|
||||
// await waitForAssetViewerFree;
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -114,6 +114,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 };
|
||||
|
||||
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 null;
|
||||
};
|
||||
|
||||
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();
|
||||
@@ -45,6 +45,18 @@ export type Events = {
|
||||
|
||||
AssetViewerFree: [];
|
||||
|
||||
TransitionToTimeline: [{ id: string }];
|
||||
TimelineLoaded: [{ id: string | null }];
|
||||
|
||||
TransitionToAssetViewer: [];
|
||||
AssetViewerLoaded: [];
|
||||
|
||||
BeforeStartViewTransition: [];
|
||||
Finished: [];
|
||||
Ready: [];
|
||||
UpdateCallbackDone: [];
|
||||
StartViewTransition: [];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
@@ -67,11 +79,11 @@ class EventManager<EventMap extends Record<string, unknown[]>> {
|
||||
}[];
|
||||
} = {};
|
||||
|
||||
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
||||
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
|
||||
return this.addListener(key, listener, false);
|
||||
}
|
||||
|
||||
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
||||
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => unknown) {
|
||||
return this.addListener(key, listener, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoViewerImgElement = writable<HTMLImageElement | null>(null);
|
||||
export const photoViewerImgElement = writable<HTMLImageElement | null | undefined>(null);
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
|
||||
const pointerCoarse = new MediaQuery('pointer:coarse');
|
||||
const reducedMotion = new MediaQuery('prefers-reduced-motion');
|
||||
const maxMd = new MediaQuery('max-width: 767px');
|
||||
const sidebar = new MediaQuery(`min-width: 850px`);
|
||||
|
||||
@@ -14,4 +15,7 @@ export const mobileDevice = {
|
||||
get isFullSidebar() {
|
||||
return sidebar.current;
|
||||
},
|
||||
get prefersReducedMotion() {
|
||||
return reducedMotion.current;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -30,6 +31,7 @@ describe('OcrManager', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the singleton state before each test
|
||||
ocrManager.clear();
|
||||
assetCacheManager.clearOcrCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
|
||||
export type OcrBoundingBox = {
|
||||
id: string;
|
||||
@@ -38,7 +38,7 @@ class OcrManager {
|
||||
this.#cleared = false;
|
||||
}
|
||||
await this.#ocrLoader.execute(async () => {
|
||||
this.#data = await getAssetOcr({ id });
|
||||
this.#data = await assetCacheManager.getAssetOcr(id);
|
||||
}, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoZoomState = writable<ZoomImageWheelState>();
|
||||
export const photoZoomState = writable<ZoomImageWheelState>({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
|
||||
export const resetZoomState = () => {
|
||||
photoZoomState.set({
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -24,11 +24,11 @@ export interface boundingBox {
|
||||
export const getBoundingBox = (
|
||||
faces: Faces[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
photoViewer: HTMLImageElement | null | undefined,
|
||||
): boundingBox[] => {
|
||||
const boxes: boundingBox[] = [];
|
||||
|
||||
if (photoViewer === null) {
|
||||
if (!photoViewer) {
|
||||
return boxes;
|
||||
}
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
@@ -76,9 +76,9 @@ export const zoomImageToBase64 = async (
|
||||
face: AssetFaceResponseDto,
|
||||
assetId: string,
|
||||
assetType: AssetTypeEnum,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
photoViewer: HTMLImageElement | null | undefined,
|
||||
): Promise<string | null> => {
|
||||
let image: HTMLImageElement | null = null;
|
||||
let image: HTMLImageElement | null | undefined = null;
|
||||
if (assetType === AssetTypeEnum.Image) {
|
||||
image = photoViewer;
|
||||
} else if (assetType === AssetTypeEnum.Video) {
|
||||
@@ -93,7 +93,7 @@ export const zoomImageToBase64 = async (
|
||||
|
||||
image = img;
|
||||
}
|
||||
if (image === null) {
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, imageWidth, imageHeight } = face;
|
||||
|
||||
@@ -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,17 @@
|
||||
<script lang="ts" module>
|
||||
export class AppState {
|
||||
#isAssetViewer = $state<boolean>(false);
|
||||
set isAssetViewer(value) {
|
||||
this.#isAssetViewer = value;
|
||||
}
|
||||
get isAssetViewer() {
|
||||
return this.#isAssetViewer;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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';
|
||||
@@ -23,7 +35,7 @@
|
||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||
import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import { onMount, setContext, type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import '../app.css';
|
||||
|
||||
@@ -49,6 +61,10 @@
|
||||
|
||||
let showNavigationLoadingBar = $state(false);
|
||||
|
||||
const appState = new AppState();
|
||||
appState.isAssetViewer = isAssetViewerRoute(page);
|
||||
setContext('AppState', appState);
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
@@ -74,8 +90,14 @@
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
onNavigate(({ to }) => {
|
||||
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
});
|
||||
afterNavigate(({ to, complete }) => {
|
||||
appState.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
void complete.finally(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const put = async (key: string, response: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.put(key, response.clone());
|
||||
await cache.put(key, response.clone());
|
||||
};
|
||||
|
||||
export const prune = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get, put } from './cache';
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
const pendingRequests = new Map<string, { abort: AbortController; callbacks: ((canceled: boolean) => void)[] }>();
|
||||
|
||||
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;
|
||||
@@ -31,6 +31,11 @@ export const handlePreload = async (request: URL | Request) => {
|
||||
}
|
||||
};
|
||||
|
||||
const canceledResponse = () => {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
};
|
||||
|
||||
export const handleRequest = async (request: URL | Request) => {
|
||||
const cacheKey = getCacheKey(request);
|
||||
const cachedResponse = await get(cacheKey);
|
||||
@@ -38,36 +43,53 @@ export const handleRequest = async (request: URL | Request) => {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
try {
|
||||
const requestSignals = pendingRequests.get(cacheKey);
|
||||
if (requestSignals) {
|
||||
const canceled = await new Promise<boolean>((resolve) => requestSignals.callbacks.push(resolve));
|
||||
if (canceled) {
|
||||
return canceledResponse();
|
||||
}
|
||||
const cachedResponse = await get(cacheKey);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
const cancelToken = new AbortController();
|
||||
pendingRequests.set(cacheKey, cancelToken);
|
||||
pendingRequests.set(cacheKey, { abort: cancelToken, callbacks: [] });
|
||||
const response = await fetch(request, { signal: cancelToken.signal });
|
||||
|
||||
assertResponse(response);
|
||||
put(cacheKey, response);
|
||||
await 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 });
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
canceled = true;
|
||||
return canceledResponse();
|
||||
}
|
||||
|
||||
console.log('Not an abort error', error);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
const requestSignals = pendingRequests.get(cacheKey);
|
||||
pendingRequests.delete(cacheKey);
|
||||
if (requestSignals) {
|
||||
for (const callback of requestSignals.callbacks) {
|
||||
callback(canceled);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
const requestSignals = pendingRequests.get(cacheKey);
|
||||
if (!requestSignals) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
requestSignals.abort.abort();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user