mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 748e5dfcd5 | |||
| 148f7a7fdb |
@@ -2160,6 +2160,7 @@
|
|||||||
"skip_to_folders": "Skip to folders",
|
"skip_to_folders": "Skip to folders",
|
||||||
"skip_to_tags": "Skip to tags",
|
"skip_to_tags": "Skip to tags",
|
||||||
"slideshow": "Slideshow",
|
"slideshow": "Slideshow",
|
||||||
|
"slideshow_ken_burns_effect": "Ken Burns effect",
|
||||||
"slideshow_repeat": "Repeat slideshow",
|
"slideshow_repeat": "Repeat slideshow",
|
||||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||||
"slideshow_settings": "Slideshow settings",
|
"slideshow_settings": "Slideshow settings",
|
||||||
|
|||||||
Generated
+8
@@ -824,6 +824,9 @@ importers:
|
|||||||
simple-icons:
|
simple-icons:
|
||||||
specifier: ^15.15.0
|
specifier: ^15.15.0
|
||||||
version: 15.22.0
|
version: 15.22.0
|
||||||
|
smartcrop:
|
||||||
|
specifier: ^2.0.5
|
||||||
|
version: 2.0.5
|
||||||
socket.io-client:
|
socket.io-client:
|
||||||
specifier: ~4.8.0
|
specifier: ~4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
@@ -10904,6 +10907,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||||
|
|
||||||
|
smartcrop@2.0.5:
|
||||||
|
resolution: {integrity: sha512-aXoHTM8XlC51g96kgZkYxZ2mx09/ibOrIVLiUNOFozV/MHmFSgEr1/5CKVBoFD5vd+re2wSy0xra21CyjRITzA==}
|
||||||
|
|
||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||||
|
|
||||||
@@ -24106,6 +24112,8 @@ snapshots:
|
|||||||
|
|
||||||
smart-buffer@4.2.0: {}
|
smart-buffer@4.2.0: {}
|
||||||
|
|
||||||
|
smartcrop@2.0.5: {}
|
||||||
|
|
||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"pmtiles": "^4.3.0",
|
"pmtiles": "^4.3.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"simple-icons": "^15.15.0",
|
"simple-icons": "^15.15.0",
|
||||||
|
"smartcrop": "^2.0.5",
|
||||||
"socket.io-client": "~4.8.0",
|
"socket.io-client": "~4.8.0",
|
||||||
"svelte-gestures": "^5.2.2",
|
"svelte-gestures": "^5.2.2",
|
||||||
"svelte-i18n": "^4.0.1",
|
"svelte-i18n": "^4.0.1",
|
||||||
|
|||||||
+242
-29
@@ -328,8 +328,189 @@
|
|||||||
}
|
}
|
||||||
::view-transition-new(hero) {
|
::view-transition-new(hero) {
|
||||||
animation: none;
|
animation: none;
|
||||||
align-content: center;
|
|
||||||
}
|
}
|
||||||
|
::view-transition-old(memory-overlay),
|
||||||
|
::view-transition-old(memory-controls),
|
||||||
|
::view-transition-new(memory-overlay),
|
||||||
|
::view-transition-new(memory-controls) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: none;
|
||||||
|
object-position: left top;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory) {
|
||||||
|
&::view-transition-group(hero),
|
||||||
|
&::view-transition-group(hero-out) {
|
||||||
|
animation-duration: var(--vt-duration-memory);
|
||||||
|
animation-timing-function: var(--vt-memory-easing);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay),
|
||||||
|
&::view-transition-group(memory-controls) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay-prev),
|
||||||
|
&::view-transition-group(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(memory-overlay),
|
||||||
|
&::view-transition-image-pair(memory-controls) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls) {
|
||||||
|
animation: 120ms linear fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls) {
|
||||||
|
animation: 200ms linear calc(var(--vt-duration-memory) - 200ms) fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay-prev),
|
||||||
|
&::view-transition-old(memory-overlay-next) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay-prev),
|
||||||
|
&::view-transition-new(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: none;
|
||||||
|
object-position: left top;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(hero) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(hero-out) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero-out) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(hero-out) {
|
||||||
|
animation: var(--vt-duration-memory) var(--vt-memory-easing) dimDown forwards;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-departing) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-departing) {
|
||||||
|
animation: calc(var(--vt-duration-memory) * 0.4) linear fadeFromDim forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-departing) {
|
||||||
|
animation: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-enter) {
|
||||||
|
&::view-transition-group(hero) {
|
||||||
|
animation-duration: var(--vt-duration-hero);
|
||||||
|
animation-timing-function: var(--vt-memory-easing);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
&::view-transition-old(hero),
|
||||||
|
&::view-transition-new(hero) {
|
||||||
|
animation: none;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay),
|
||||||
|
&::view-transition-group(memory-controls),
|
||||||
|
&::view-transition-group(memory-nav-buttons) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls),
|
||||||
|
&::view-transition-old(memory-nav-buttons) {
|
||||||
|
animation: none;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls),
|
||||||
|
&::view-transition-new(memory-nav-buttons) {
|
||||||
|
animation: 200ms linear var(--vt-duration-hero) fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(memory-fade-out) {
|
||||||
|
animation: 500ms linear crossfadeOut forwards;
|
||||||
|
}
|
||||||
|
::view-transition-new(memory-fade-in) {
|
||||||
|
animation: 500ms linear crossfadeIn forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-nav-fast) {
|
||||||
|
&::view-transition-old(memory-fade-out) {
|
||||||
|
animation-duration: 250ms;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-fade-in) {
|
||||||
|
animation-duration: 250ms;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls) {
|
||||||
|
animation-duration: 100ms;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls) {
|
||||||
|
animation: 100ms linear 150ms fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html:active-view-transition-type(memory-nav) {
|
||||||
|
&::view-transition-group(memory-overlay),
|
||||||
|
&::view-transition-group(memory-controls) {
|
||||||
|
animation: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
&::view-transition-image-pair(memory-overlay),
|
||||||
|
&::view-transition-image-pair(memory-controls) {
|
||||||
|
isolation: auto;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay),
|
||||||
|
&::view-transition-old(memory-controls) {
|
||||||
|
animation: 150ms linear fadeOut forwards;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay),
|
||||||
|
&::view-transition-new(memory-controls) {
|
||||||
|
animation: 200ms linear 300ms fadeIn forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
&::view-transition-group(memory-overlay-prev),
|
||||||
|
&::view-transition-group(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
&::view-transition-old(memory-overlay-prev),
|
||||||
|
&::view-transition-old(memory-overlay-next) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::view-transition-new(memory-overlay-prev),
|
||||||
|
&::view-transition-new(memory-overlay-next) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
::view-transition-old(next),
|
::view-transition-old(next),
|
||||||
::view-transition-old(next-old),
|
::view-transition-old(next-old),
|
||||||
::view-transition-new(next),
|
::view-transition-new(next),
|
||||||
@@ -381,6 +562,24 @@
|
|||||||
z-index: -1;
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeFromDim {
|
||||||
|
from {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dimDown {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes flyInLeft {
|
@keyframes flyInLeft {
|
||||||
from {
|
from {
|
||||||
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
|
||||||
@@ -539,36 +738,50 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-group(previous),
|
html:active-view-transition-type(viewer-nav) {
|
||||||
::view-transition-group(previous-old),
|
&::view-transition-group(previous),
|
||||||
::view-transition-group(next),
|
&::view-transition-group(previous-old),
|
||||||
::view-transition-group(next-old) {
|
&::view-transition-group(next),
|
||||||
width: 100% !important;
|
&::view-transition-group(next-old) {
|
||||||
height: 100% !important;
|
width: 100% !important;
|
||||||
transform: none !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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-old(previous),
|
html:active-view-transition-type(memory-enter) {
|
||||||
::view-transition-old(previous-old),
|
&::view-transition-group(hero) {
|
||||||
::view-transition-old(next),
|
animation-duration: 0s;
|
||||||
::view-transition-old(next-old) {
|
}
|
||||||
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
|
&::view-transition-old(hero) {
|
||||||
transform-origin: center;
|
animation: var(--vt-duration-default) fadeOut forwards;
|
||||||
height: 100%;
|
}
|
||||||
width: 100%;
|
&::view-transition-new(hero) {
|
||||||
object-fit: contain;
|
animation: var(--vt-duration-default) fadeIn forwards;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
|
||||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
||||||
@@ -18,19 +17,15 @@
|
|||||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
|
||||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
|
||||||
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
|
||||||
import { getGlobalActions } from '$lib/services/app.service';
|
import { getGlobalActions } from '$lib/services/app.service';
|
||||||
import { getAssetActions } from '$lib/services/asset.service';
|
import { getAssetActions } from '$lib/services/asset.service';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSharedLink, withoutIcons } from '$lib/utils';
|
import { getSharedLink, withoutIcons } from '$lib/utils';
|
||||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||||
import { isPhotosRoute } from '$lib/utils/navigation';
|
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
@@ -189,21 +184,7 @@
|
|||||||
{#if !asset.isArchived && !asset.isTrashed}
|
{#if !asset.isArchived && !asset.isTrashed}
|
||||||
<MenuOption
|
<MenuOption
|
||||||
icon={mdiImageSearch}
|
icon={mdiImageSearch}
|
||||||
onClick={async () => {
|
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
|
||||||
const assetId = stack?.primaryAssetId ?? asset.id;
|
|
||||||
if (isPhotosRoute(page.route.id) && viewTransitionManager.isSupported()) {
|
|
||||||
const transitionReady = eventManager.untilNext('ViewerCloseTransitionReady');
|
|
||||||
eventManager.emit('ViewerCloseTransition', { id: assetId });
|
|
||||||
await transitionReady;
|
|
||||||
await goto(Route.photos({ at: assetId }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToTimeline(assetId, {
|
|
||||||
types: ['timeline'],
|
|
||||||
prepareOldSnapshot: () => eventManager.emit('ViewerOpenTransition'),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
text={$t('view_in_timeline')}
|
text={$t('view_in_timeline')}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -297,8 +297,19 @@
|
|||||||
|
|
||||||
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
|
||||||
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
const slideshowAllowsTransition = !slideShowPlaying || $slideshowTransition;
|
||||||
const useTransition = canTransition && slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
const useTransition = slideshowAllowsTransition && (slideShowShuffle || !!targetAsset);
|
||||||
const hasNext = useTransition ? await startTransition(types, targetTransition, navigate) : await navigate();
|
|
||||||
|
let hasNext: boolean;
|
||||||
|
if (slideShowPlaying && useTransition) {
|
||||||
|
hasNext = false;
|
||||||
|
await crossfadeViewerContent(async () => {
|
||||||
|
hasNext = await navigate();
|
||||||
|
}, 1000);
|
||||||
|
} else if (canTransition && useTransition) {
|
||||||
|
hasNext = await startTransition(types, targetTransition, navigate);
|
||||||
|
} else {
|
||||||
|
hasNext = await navigate();
|
||||||
|
}
|
||||||
|
|
||||||
if (!slideShowPlaying) {
|
if (!slideShowPlaying) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isEditFacesPanelOpen, isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||||
@@ -17,11 +18,12 @@
|
|||||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||||
import type { Size } from '$lib/utils/container-utils';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { KenBurnsAnimation } from '$lib/utils/ken-burns';
|
||||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
import { toastManager } from '@immich/ui';
|
import { toastManager } from '@immich/ui';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { AssetCursor } from './asset-viewer.svelte';
|
import type { AssetCursor } from './asset-viewer.svelte';
|
||||||
@@ -46,13 +48,17 @@
|
|||||||
onSwipe,
|
onSwipe,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const { slideshowState, slideshowLook } = slideshowStore;
|
const { slideshowState, slideshowLook, kenBurnsEffect, slideshowDelay } = slideshowStore;
|
||||||
const objectFit = $derived(
|
|
||||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover ? 'cover' : 'contain',
|
|
||||||
);
|
|
||||||
const asset = $derived(cursor.current);
|
const asset = $derived(cursor.current);
|
||||||
|
|
||||||
let visibleImageReady: boolean = $state(false);
|
let visibleImageReady: boolean = $state(false);
|
||||||
|
const kenBurns = new KenBurnsAnimation();
|
||||||
|
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
||||||
|
onMount(() =>
|
||||||
|
eventManager.on({
|
||||||
|
ViewTransitionOldSnapshotPending: () => kenBurns.freeze(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let previousAssetId: string | undefined;
|
let previousAssetId: string | undefined;
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
@@ -62,13 +68,16 @@
|
|||||||
}
|
}
|
||||||
previousAssetId = id;
|
previousAssetId = id;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
|
kenBurns.cancel();
|
||||||
assetViewerManager.resetZoomState();
|
assetViewerManager.resetZoomState();
|
||||||
visibleImageReady = false;
|
visibleImageReady = false;
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
|
adaptiveImage?.style.removeProperty('transform');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
kenBurns.cancel();
|
||||||
$boundingBoxesArray = [];
|
$boundingBoxesArray = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,6 +89,8 @@
|
|||||||
height: containerHeight,
|
height: containerHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isCoverMode = $derived($slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.Cover);
|
||||||
|
|
||||||
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
let imageDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
let scaledDimensions = $state<Size>({ width: 0, height: 0 });
|
||||||
|
|
||||||
@@ -147,8 +158,6 @@
|
|||||||
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
$slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
|
||||||
);
|
);
|
||||||
|
|
||||||
let adaptiveImage = $state<HTMLDivElement | undefined>();
|
|
||||||
|
|
||||||
const faceToNameMap = $derived.by(() => {
|
const faceToNameMap = $derived.by(() => {
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
const map = new Map<Faces, string | undefined>();
|
const map = new Map<Faces, string | undefined>();
|
||||||
@@ -167,6 +176,47 @@
|
|||||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||||
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
const boundingBoxes = $derived(getBoundingBox(faces, overlaySize));
|
||||||
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
const activeBoundingBoxes = $derived(boundingBoxes.filter((box) => $boundingBoxesArray.some((f) => f.id === box.id)));
|
||||||
|
|
||||||
|
const unassignedFaces = $derived((asset.unassignedFaces ?? []) as Faces[]);
|
||||||
|
|
||||||
|
const kenBurnsActive = $derived($slideshowState === SlideshowState.PlaySlideshow && $kenBurnsEffect);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!kenBurnsActive || !visibleImageReady || !adaptiveImage || !assetViewerManager.imgRef) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assetViewerManager.zoomState = { ...untrack(() => assetViewerManager.zoomState), enable: false };
|
||||||
|
|
||||||
|
void kenBurns.startWithSmartCrop(adaptiveImage, {
|
||||||
|
imgRef: assetViewerManager.imgRef,
|
||||||
|
faces,
|
||||||
|
fallbackFaces: unassignedFaces,
|
||||||
|
contentWidth: overlaySize.width,
|
||||||
|
contentHeight: overlaySize.height,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
slideshowLook: $slideshowLook,
|
||||||
|
isCoverMode,
|
||||||
|
slideshowDelay: $slideshowDelay,
|
||||||
|
assetId: asset.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
kenBurns.cancel();
|
||||||
|
if (viewTransitionManager.activeViewTransition === null) {
|
||||||
|
assetViewerManager.resetZoomState();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (viewTransitionManager.activeViewTransition === null) {
|
||||||
|
kenBurns.resume();
|
||||||
|
} else {
|
||||||
|
kenBurns.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AssetViewerEvents {onCopy} {onZoom} />
|
<AssetViewerEvents {onCopy} {onZoom} />
|
||||||
@@ -194,7 +244,7 @@
|
|||||||
{asset}
|
{asset}
|
||||||
{sharedLink}
|
{sharedLink}
|
||||||
{container}
|
{container}
|
||||||
{objectFit}
|
objectFit={isCoverMode ? 'cover' : 'contain'}
|
||||||
{onUrlChange}
|
{onUrlChange}
|
||||||
onImageReady={() => {
|
onImageReady={() => {
|
||||||
visibleImageReady = true;
|
visibleImageReady = true;
|
||||||
|
|||||||
@@ -1,59 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { assetViewerFadeDuration } from '$lib/constants';
|
import AdaptiveImage from '$lib/components/AdaptiveImage.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { Size } from '$lib/utils/container-utils';
|
||||||
import { getAssetMediaUrl } from '$lib/utils';
|
import type { AssetResponseDto } from '@immich/sdk';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
|
||||||
import { AssetMediaSize } from '@immich/sdk';
|
|
||||||
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
import DelayedLoadingSpinner from '$lib/components/DelayedLoadingSpinner.svelte';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
asset: TimelineAsset;
|
asset: AssetResponseDto;
|
||||||
onImageLoad: () => void;
|
|
||||||
transitionName?: string;
|
transitionName?: string;
|
||||||
|
onImageLoad: () => void;
|
||||||
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { asset, onImageLoad, transitionName }: Props = $props();
|
const { asset, transitionName, onImageLoad, onError }: Props = $props();
|
||||||
|
|
||||||
let assetFileUrl: string = $state('');
|
let containerWidth = $state(0);
|
||||||
let imageLoaded: boolean = $state(false);
|
let containerHeight = $state(0);
|
||||||
let loader = $state<HTMLImageElement>();
|
|
||||||
|
|
||||||
const onLoadCallback = () => {
|
const container: Size = $derived({ width: containerWidth, height: containerHeight });
|
||||||
imageLoaded = true;
|
|
||||||
assetFileUrl = imageLoaderUrl;
|
|
||||||
onImageLoad();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (loader?.complete) {
|
|
||||||
onLoadCallback();
|
|
||||||
}
|
|
||||||
loader?.addEventListener('load', onLoadCallback);
|
|
||||||
return () => {
|
|
||||||
loader?.removeEventListener('load', onLoadCallback);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !imageLoaded}
|
<div
|
||||||
<!-- svelte-ignore a11y_missing_attribute -->
|
class="relative h-full w-full overflow-hidden"
|
||||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} aria-hidden="true" />
|
bind:clientWidth={containerWidth}
|
||||||
{/if}
|
bind:clientHeight={containerHeight}
|
||||||
|
>
|
||||||
{#if !imageLoaded}
|
{#if containerWidth > 0 && containerHeight > 0}
|
||||||
<DelayedLoadingSpinner />
|
<AdaptiveImage {asset} {container} {transitionName} showLetterboxes={false} onImageReady={onImageLoad} {onError} />
|
||||||
{:else if imageLoaded}
|
{:else}
|
||||||
<div transition:fade={{ duration: assetViewerFadeDuration }} class="h-full w-full">
|
<DelayedLoadingSpinner />
|
||||||
<img
|
{/if}
|
||||||
class="h-full w-full rounded-2xl object-contain transition-all"
|
</div>
|
||||||
src={assetFileUrl}
|
|
||||||
alt={$getAltText(asset)}
|
|
||||||
draggable="false"
|
|
||||||
style:view-transition-name={transitionName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -20,17 +20,19 @@
|
|||||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import { QueryParameter } from '$lib/constants';
|
import { QueryParameter } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { Route } from '$lib/route';
|
import { Route } from '$lib/route';
|
||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||||
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
|
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
|
||||||
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { navigateToTimeline } from '$lib/utils/transition-utils';
|
|
||||||
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
|
||||||
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
import { ActionButton, IconButton, toastManager } from '@immich/ui';
|
||||||
@@ -53,6 +55,7 @@
|
|||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
import type { NavigationTarget, Page } from '@sveltejs/kit';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { Attachment } from 'svelte/attachments';
|
import type { Attachment } from 'svelte/attachments';
|
||||||
import { Tween } from 'svelte/motion';
|
import { Tween } from 'svelte/motion';
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
let current = $state<MemoryAsset | undefined>(undefined);
|
let current = $state<MemoryAsset | undefined>(undefined);
|
||||||
const currentAssetId = $derived(current?.asset.id);
|
const currentAssetId = $derived(current?.asset.id);
|
||||||
|
const currentAssetDto = $derived(current ? current.memory.assets[current.assetIndex] : undefined);
|
||||||
const currentMemoryAssetFull = $derived.by(async () =>
|
const currentMemoryAssetFull = $derived.by(async () =>
|
||||||
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
|
||||||
);
|
);
|
||||||
@@ -77,6 +81,14 @@
|
|||||||
|
|
||||||
let isSaved = $derived(current?.memory.isSaved);
|
let isSaved = $derived(current?.memory.isSaved);
|
||||||
let viewerHeight = $state(0);
|
let viewerHeight = $state(0);
|
||||||
|
let transition = $state({
|
||||||
|
name: undefined as string | undefined,
|
||||||
|
previousPanel: undefined as string | undefined,
|
||||||
|
nextPanel: undefined as string | undefined,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
const showTransitionOverlays = $derived(transition.active || transition.name === 'hero');
|
||||||
|
const showNavButtonOverlay = $derived(transition.name === 'hero');
|
||||||
|
|
||||||
const { isViewing } = assetViewingStore;
|
const { isViewing } = assetViewingStore;
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
@@ -87,18 +99,6 @@
|
|||||||
let videoPlayer: HTMLVideoElement | undefined = $state();
|
let videoPlayer: HTMLVideoElement | undefined = $state();
|
||||||
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
|
||||||
|
|
||||||
const handleNavigate = async (asset?: { id: string }) => {
|
|
||||||
if ($isViewing) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await goto(asHref(asset));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setProgressDuration = (asset: TimelineAsset) => {
|
const setProgressDuration = (asset: TimelineAsset) => {
|
||||||
if (asset.isVideo) {
|
if (asset.isVideo) {
|
||||||
const timeParts = asset.duration!.split(':').map(Number);
|
const timeParts = asset.duration!.split(':').map(Number);
|
||||||
@@ -113,11 +113,177 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextAsset = () => handleNavigate(current?.next?.asset);
|
const scrollToTop = () => {
|
||||||
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
|
if (window.scrollY === 0) {
|
||||||
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
|
return Promise.resolve();
|
||||||
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
|
}
|
||||||
const handleEscape = async () => goto(Route.photos());
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(resolve, 500);
|
||||||
|
window.addEventListener(
|
||||||
|
'scrollend',
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const withMemoryTransition = async (
|
||||||
|
asset: { id: string } | undefined,
|
||||||
|
config: Omit<Parameters<typeof viewTransitionManager.startTransition>[0], 'onFinished'> & {
|
||||||
|
onFinished?: () => void;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if ($isViewing || !asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await scrollToTop();
|
||||||
|
|
||||||
|
transition.active = true;
|
||||||
|
viewTransitionManager
|
||||||
|
.startTransition({
|
||||||
|
...config,
|
||||||
|
onFinished: () => {
|
||||||
|
transition.previousPanel = undefined;
|
||||||
|
transition.nextPanel = undefined;
|
||||||
|
transition.name = undefined;
|
||||||
|
transition.active = false;
|
||||||
|
config.onFinished?.();
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => console.error('[Memory] transition failed:', error));
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateWithTransition = (asset?: { id: string }) =>
|
||||||
|
withMemoryTransition(asset, {
|
||||||
|
types: ['memory-nav'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-out';
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
await goto(asHref(asset!));
|
||||||
|
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
},
|
||||||
|
prepareNewSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-in';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNextAsset = () => {
|
||||||
|
const next = current?.next;
|
||||||
|
if (next && next.memory.id !== current?.memory.id) {
|
||||||
|
void navigateToMemory('next', next.asset);
|
||||||
|
} else {
|
||||||
|
void navigateWithTransition(next?.asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handlePreviousAsset = () => {
|
||||||
|
const previous = current?.previous;
|
||||||
|
if (previous && previous.memory.id !== current?.memory.id) {
|
||||||
|
void navigateToMemory('previous', previous.asset);
|
||||||
|
} else {
|
||||||
|
void navigateWithTransition(previous?.asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const navigateToMemory = (direction: 'next' | 'previous', asset?: { id: string }) => {
|
||||||
|
const isNext = direction === 'next';
|
||||||
|
const useHeroMorph = !mediaQueryManager.reducedMotion;
|
||||||
|
|
||||||
|
return withMemoryTransition(asset, {
|
||||||
|
types: ['memory'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
if (useHeroMorph) {
|
||||||
|
if (isNext) {
|
||||||
|
transition.nextPanel = 'hero';
|
||||||
|
transition.previousPanel = 'memory-departing';
|
||||||
|
} else {
|
||||||
|
transition.previousPanel = 'hero';
|
||||||
|
transition.nextPanel = 'memory-departing';
|
||||||
|
}
|
||||||
|
transition.name = 'hero-out';
|
||||||
|
} else {
|
||||||
|
transition.name = 'memory-fade-out';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
transition.nextPanel = undefined;
|
||||||
|
transition.previousPanel = undefined;
|
||||||
|
if (useHeroMorph) {
|
||||||
|
if (isNext) {
|
||||||
|
transition.previousPanel = 'hero-out';
|
||||||
|
} else {
|
||||||
|
transition.nextPanel = 'hero-out';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition.name = useHeroMorph ? 'hero' : 'memory-fade-in';
|
||||||
|
await goto(asHref(asset!));
|
||||||
|
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNextMemory = () => void navigateToMemory('next', current?.nextMemory?.assets[0]);
|
||||||
|
const handlePreviousMemory = () => void navigateToMemory('previous', current?.previousMemory?.assets[0]);
|
||||||
|
const closeMemoryViewer = () => {
|
||||||
|
if (current && current.assetIndex > 0 && !mediaQueryManager.reducedMotion) {
|
||||||
|
const firstAsset = current.memory.assets[0];
|
||||||
|
void withMemoryTransition(firstAsset, {
|
||||||
|
types: ['memory-nav', 'memory-nav-fast'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-out';
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
await goto(asHref(firstAsset));
|
||||||
|
await eventManager.untilNext('ViewerOpenTransitionReady');
|
||||||
|
},
|
||||||
|
prepareNewSnapshot: () => {
|
||||||
|
transition.name = 'memory-fade-in';
|
||||||
|
},
|
||||||
|
onFinished: () => closeToTimeline(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
closeToTimeline();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeToTimeline = () => {
|
||||||
|
const memoryId = current?.memory.id;
|
||||||
|
let cardImage: HTMLElement | null | undefined;
|
||||||
|
|
||||||
|
void viewTransitionManager.startTransition({
|
||||||
|
types: ['memory-enter'],
|
||||||
|
prepareOldSnapshot: () => {
|
||||||
|
transition.name = 'hero';
|
||||||
|
},
|
||||||
|
performUpdate: async () => {
|
||||||
|
transition.name = undefined;
|
||||||
|
await goto(Route.photos());
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
const memoryCard = memoryId
|
||||||
|
? document.querySelector<HTMLElement>(`[data-memory-id="${CSS.escape(memoryId)}"]`)
|
||||||
|
: null;
|
||||||
|
memoryCard?.scrollIntoView({ behavior: 'instant', inline: 'nearest', block: 'nearest' });
|
||||||
|
cardImage = memoryCard?.querySelector<HTMLElement>('img');
|
||||||
|
if (cardImage) {
|
||||||
|
cardImage.style.viewTransitionName = 'hero';
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
if (cardImage) {
|
||||||
|
cardImage.style.viewTransitionName = '';
|
||||||
|
cardImage = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = closeMemoryViewer;
|
||||||
const handleSelectAll = () =>
|
const handleSelectAll = () =>
|
||||||
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
assetInteraction.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
|
||||||
|
|
||||||
@@ -161,13 +327,17 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProgress = async (progress: number) => {
|
const handleProgress = (progress: number) => {
|
||||||
if (!progressBarController) {
|
if (!progressBarController) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress === 1 && !paused) {
|
if (progress === 1 && !paused && !transition.active) {
|
||||||
await (current?.next ? handleNextAsset() : handlePromiseError(handleAction('handleProgressLast', 'pause')));
|
if (current?.next) {
|
||||||
|
handleNextAsset();
|
||||||
|
} else {
|
||||||
|
handlePromiseError(handleAction('handleProgressLast', 'pause'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -227,20 +397,6 @@
|
|||||||
handlePromiseError(handleAction('galleryInView', 'pause'));
|
handlePromiseError(handleAction('galleryInView', 'pause'));
|
||||||
};
|
};
|
||||||
|
|
||||||
let memoryTransitionName = $state<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const viewInTimeline = (assetId: string) => {
|
|
||||||
navigateToTimeline(assetId, {
|
|
||||||
types: ['memory-enter'],
|
|
||||||
prepareOldSnapshot: () => {
|
|
||||||
memoryTransitionName = 'hero';
|
|
||||||
},
|
|
||||||
onFinished: () => {
|
|
||||||
memoryTransitionName = undefined;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGalleryScrollsOutOfView = () => {
|
const handleGalleryScrollsOutOfView = () => {
|
||||||
galleryInView = false;
|
galleryInView = false;
|
||||||
// only call play after the first page load. When page first loads the gallery will not be visible
|
// only call play after the first page load. When page first loads the gallery will not be visible
|
||||||
@@ -285,7 +441,18 @@
|
|||||||
playerInitialized = false;
|
playerInitialized = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetAndPlay = () => {
|
const resolveTransitionIfPending = () => {
|
||||||
|
if (viewTransitionManager.activeViewTransition) {
|
||||||
|
transition.name = 'hero';
|
||||||
|
eventManager.emit('ViewerOpenTransitionReady');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
transition.name = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemoryImageReady = () => {
|
||||||
|
resolveTransitionIfPending();
|
||||||
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
handlePromiseError(handleAction('resetAndPlay', 'reset'));
|
||||||
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
handlePromiseError(handleAction('resetAndPlay', 'play'));
|
||||||
};
|
};
|
||||||
@@ -300,7 +467,7 @@
|
|||||||
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
|
||||||
} else if (isVideo) {
|
} else if (isVideo) {
|
||||||
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
// Image assets will start playing when the image is loaded. Only autostart video assets.
|
||||||
resetAndPlay();
|
handleMemoryImageReady();
|
||||||
}
|
}
|
||||||
playerInitialized = true;
|
playerInitialized = true;
|
||||||
};
|
};
|
||||||
@@ -328,7 +495,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (progressBarController) {
|
if (progressBarController) {
|
||||||
handlePromiseError(handleProgress(progressBarController.current));
|
handleProgress(progressBarController.current);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -397,7 +564,7 @@
|
|||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
>
|
>
|
||||||
{#if current}
|
{#if current}
|
||||||
<ControlAppBar onClose={() => goto(Route.photos())} forceDark multiRow>
|
<ControlAppBar onClose={closeMemoryViewer} forceDark multiRow>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
{#if current}
|
{#if current}
|
||||||
<p class="text-lg">
|
<p class="text-lg">
|
||||||
@@ -473,7 +640,11 @@
|
|||||||
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
class="ms-[-100%] box-border flex h-[calc(100vh-224px)] md:h-[calc(100vh-180px)] w-[300%] items-center justify-center gap-10 overflow-hidden"
|
||||||
>
|
>
|
||||||
<!-- PREVIOUS MEMORY -->
|
<!-- PREVIOUS MEMORY -->
|
||||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.previousMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
<div
|
||||||
|
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.previousMemory
|
||||||
|
? ''
|
||||||
|
: 'opacity-0!'}"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-full w-full rounded-2xl"
|
class="relative h-full w-full rounded-2xl"
|
||||||
@@ -486,6 +657,7 @@
|
|||||||
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||||
alt={$t('previous_memory')}
|
alt={$t('previous_memory')}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
style:view-transition-name={transition.previousPanel}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
@@ -498,7 +670,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if current.previousMemory}
|
{#if current.previousMemory}
|
||||||
<div class="absolute bottom-4 end-4 text-start text-white">
|
<div
|
||||||
|
class="absolute bottom-4 end-4 text-start text-white"
|
||||||
|
style:view-transition-name={transition.active ? 'memory-overlay-prev' : undefined}
|
||||||
|
>
|
||||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
<p class="uppercase text-xs font-semibold text-gray-200">{$t('previous')}</p>
|
||||||
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,43 +682,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CURRENT MEMORY -->
|
<!-- CURRENT MEMORY -->
|
||||||
<div
|
<div class="main-view relative isolate h-full w-[70vw] rounded-2xl bg-black">
|
||||||
class="main-view relative flex h-full w-[70vw] place-content-center place-items-center rounded-2xl bg-black"
|
{#key current.asset.id}
|
||||||
>
|
{#if current.asset.isVideo}
|
||||||
<div class="relative h-full w-full rounded-2xl bg-black">
|
<MemoryVideoViewer
|
||||||
{#key current.asset.id}
|
asset={current.asset}
|
||||||
{#if current.asset.isVideo}
|
bind:videoPlayer
|
||||||
<MemoryVideoViewer
|
videoViewerMuted={$videoViewerMuted}
|
||||||
asset={current.asset}
|
videoViewerVolume={$videoViewerVolume}
|
||||||
bind:videoPlayer
|
/>
|
||||||
videoViewerMuted={$videoViewerMuted}
|
{:else if currentAssetDto}
|
||||||
videoViewerVolume={$videoViewerVolume}
|
<MemoryPhotoViewer
|
||||||
/>
|
asset={currentAssetDto}
|
||||||
{:else}
|
transitionName={transition.name}
|
||||||
<MemoryPhotoViewer
|
onImageLoad={handleMemoryImageReady}
|
||||||
asset={current.asset}
|
onError={resolveTransitionIfPending}
|
||||||
onImageLoad={resetAndPlay}
|
/>
|
||||||
transitionName={memoryTransitionName}
|
{/if}
|
||||||
/>
|
{/key}
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
class="absolute bottom-0 end-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2 dark"
|
||||||
class:opacity-0={galleryInView}
|
class:opacity-0={galleryInView}
|
||||||
class:opacity-100={!galleryInView}
|
class:opacity-100={!galleryInView}
|
||||||
>
|
style:view-transition-name={showTransitionOverlays ? 'memory-controls' : undefined}
|
||||||
<div class="flex items-center">
|
>
|
||||||
<IconButton
|
<div class="flex items-center">
|
||||||
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
<IconButton
|
||||||
shape="round"
|
icon={isSaved ? mdiHeart : mdiHeartOutline}
|
||||||
variant="ghost"
|
shape="round"
|
||||||
color="secondary"
|
variant="ghost"
|
||||||
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
color="secondary"
|
||||||
onclick={() => handleSaveMemory()}
|
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
|
||||||
class="w-12 h-12"
|
onclick={() => handleSaveMemory()}
|
||||||
/>
|
class="w-12 h-12"
|
||||||
<!-- <IconButton
|
/>
|
||||||
|
<!-- <IconButton
|
||||||
icon={mdiShareVariantOutline}
|
icon={mdiShareVariantOutline}
|
||||||
shape="round"
|
shape="round"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -551,46 +725,46 @@
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
aria-label={$t('share')}
|
aria-label={$t('share')}
|
||||||
/> -->
|
/> -->
|
||||||
<ButtonContextMenu
|
<ButtonContextMenu
|
||||||
icon={mdiDotsVertical}
|
icon={mdiDotsVertical}
|
||||||
title={$t('menu')}
|
title={$t('menu')}
|
||||||
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
onclick={() => handlePromiseError(handleAction('ContextMenuClick', 'pause'))}
|
||||||
direction="left"
|
direction="left"
|
||||||
size="medium"
|
size="medium"
|
||||||
align="bottom-right"
|
align="bottom-right"
|
||||||
>
|
>
|
||||||
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
<MenuOption onClick={() => handleDeleteMemory()} text={$t('remove_memory')} icon={mdiCardsOutline} />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
onClick={() => handleDeleteMemoryAsset()}
|
onClick={() => handleDeleteMemoryAsset()}
|
||||||
text={$t('remove_photo_from_memory')}
|
text={$t('remove_photo_from_memory')}
|
||||||
icon={mdiImageMinusOutline}
|
icon={mdiImageMinusOutline}
|
||||||
/>
|
/>
|
||||||
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
<!-- shortcut={{ key: 'l', shift: shared }} -->
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{#await currentMemoryAssetFull then asset}
|
|
||||||
{#if asset}
|
|
||||||
<IconButton
|
|
||||||
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
|
||||||
icon={mdiImageSearch}
|
|
||||||
aria-label={$t('view_in_timeline')}
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
onclick={(event: MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
viewInTimeline(asset.stack?.primaryAssetId ?? asset.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- CONTROL BUTTONS -->
|
|
||||||
|
<div>
|
||||||
|
{#await currentMemoryAssetFull then asset}
|
||||||
|
{#if asset}
|
||||||
|
<IconButton
|
||||||
|
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
|
||||||
|
icon={mdiImageSearch}
|
||||||
|
aria-label={$t('view_in_timeline')}
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- CONTROL BUTTONS -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 pointer-events-none"
|
||||||
|
style:view-transition-name={showNavButtonOverlay ? 'memory-nav-buttons' : undefined}
|
||||||
|
>
|
||||||
{#if current.previous}
|
{#if current.previous}
|
||||||
<div class="absolute top-1/2 start-0 ms-4 dark">
|
<div class="absolute top-1/2 inset-s-0 ms-4 dark pointer-events-auto">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
aria-label={$t('previous_memory')}
|
aria-label={$t('previous_memory')}
|
||||||
@@ -604,7 +778,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if current.next}
|
{#if current.next}
|
||||||
<div class="absolute top-1/2 end-0 me-4 dark">
|
<div class="absolute top-1/2 inset-e-0 me-4 dark pointer-events-auto">
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
aria-label={$t('next_memory')}
|
aria-label={$t('next_memory')}
|
||||||
@@ -616,25 +790,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="absolute start-8 top-4 text-sm font-medium text-white">
|
<div
|
||||||
<p>
|
class="absolute start-8 top-4 text-sm font-medium text-white"
|
||||||
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
style:view-transition-name={showTransitionOverlays ? 'memory-overlay' : undefined}
|
||||||
locale: $locale,
|
>
|
||||||
})}
|
<p>
|
||||||
</p>
|
{fromISODateTimeUTC(current.memory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL, {
|
||||||
<p>
|
locale: $locale,
|
||||||
{#await currentMemoryAssetFull then asset}
|
})}
|
||||||
{asset?.exifInfo?.city || ''}
|
</p>
|
||||||
{asset?.exifInfo?.country || ''}
|
<p>
|
||||||
{/await}
|
{#await currentMemoryAssetFull then asset}
|
||||||
</p>
|
{asset?.exifInfo?.city || ''}
|
||||||
</div>
|
{asset?.exifInfo?.country || ''}
|
||||||
|
{/await}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- NEXT MEMORY -->
|
<!-- NEXT MEMORY -->
|
||||||
<div class="h-1/2 w-[20vw] rounded-2xl {current.nextMemory ? 'opacity-25 hover:opacity-70' : 'opacity-0'}">
|
<div
|
||||||
|
class="h-1/2 w-[20vw] rounded-2xl opacity-25 transition-opacity duration-150 hover:opacity-70 {current.nextMemory
|
||||||
|
? ''
|
||||||
|
: 'opacity-0!'}"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative h-full w-full rounded-2xl"
|
class="relative h-full w-full rounded-2xl"
|
||||||
@@ -647,6 +828,7 @@
|
|||||||
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
|
||||||
alt={$t('next_memory')}
|
alt={$t('next_memory')}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
style:view-transition-name={transition.nextPanel}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<enhanced:img
|
<enhanced:img
|
||||||
@@ -659,7 +841,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if current.nextMemory}
|
{#if current.nextMemory}
|
||||||
<div class="absolute bottom-4 start-4 text-start text-white">
|
<div
|
||||||
|
class="absolute bottom-4 start-4 text-start text-white"
|
||||||
|
style:view-transition-name={transition.active ? 'memory-overlay-next' : undefined}
|
||||||
|
>
|
||||||
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
<p class="uppercase text-xs font-semibold text-gray-200">{$t('up_next')}</p>
|
||||||
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -703,8 +888,6 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.main-view {
|
.main-view {
|
||||||
box-shadow:
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.15));
|
||||||
0 4px 4px 0 rgba(0, 0, 0, 0.3),
|
|
||||||
0 8px 12px 6px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -218,14 +218,11 @@
|
|||||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||||
}
|
}
|
||||||
if (!scrolled) {
|
if (!scrolled) {
|
||||||
|
// if the asset is not found, scroll to the top
|
||||||
timelineManager.scrollTo(0);
|
timelineManager.scrollTo(0);
|
||||||
if (scrollTarget) {
|
|
||||||
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
|
||||||
}
|
|
||||||
} else if (scrollTarget) {
|
} else if (scrollTarget) {
|
||||||
await tick();
|
await tick();
|
||||||
focusAsset(scrollTarget);
|
focusAsset(scrollTarget);
|
||||||
eventManager.emit('TimelineScrolledToAsset', { id: scrollTarget });
|
|
||||||
}
|
}
|
||||||
invisible = isAssetViewerRoute(page) ? true : false;
|
invisible = isAssetViewerRoute(page) ? true : false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,21 +48,34 @@ describe('ViewTransitionManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when a transition is already active', () => {
|
describe('when a transition is already active', () => {
|
||||||
it('should skip the second transition', async () => {
|
it('should skip the first transition and run the second', async () => {
|
||||||
let resolveFinished!: () => void;
|
let resolveFirstUpdate!: () => void;
|
||||||
const finished = new Promise<void>((resolve) => {
|
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
|
||||||
resolveFinished = resolve;
|
resolveFirstUpdate = resolve;
|
||||||
});
|
|
||||||
let resolveUpdate!: () => void;
|
|
||||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
|
||||||
resolveUpdate = resolve;
|
|
||||||
});
|
});
|
||||||
|
const firstFinished = new Promise<void>(() => {});
|
||||||
|
const firstSkipTransition = vi.fn();
|
||||||
|
|
||||||
|
let callCount = 0;
|
||||||
// eslint-disable-next-line tscompat/tscompat
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||||
|
callCount++;
|
||||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||||
void updateFn();
|
void updateFn();
|
||||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
if (callCount === 1) {
|
||||||
|
return {
|
||||||
|
updateCallbackDone: firstUpdateCallbackDone,
|
||||||
|
finished: firstFinished,
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: firstSkipTransition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
updateCallbackDone: Promise.resolve(),
|
||||||
|
finished: Promise.resolve(),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
skipTransition: vi.fn(),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
@@ -75,13 +88,13 @@ describe('ViewTransitionManager', () => {
|
|||||||
// Flush microtasks so the first transition reaches the startViewTransition call
|
// Flush microtasks so the first transition reaches the startViewTransition call
|
||||||
await new Promise<void>((r) => queueMicrotask(r));
|
await new Promise<void>((r) => queueMicrotask(r));
|
||||||
|
|
||||||
// While first is active, try a second — should be skipped
|
// While first is active, start a second — should skip the first and proceed
|
||||||
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
||||||
expect(secondPerformUpdate).not.toHaveBeenCalled();
|
expect(firstSkipTransition).toHaveBeenCalledOnce();
|
||||||
|
expect(secondPerformUpdate).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
// Clean up
|
// Clean up first promise
|
||||||
resolveUpdate();
|
resolveFirstUpdate();
|
||||||
resolveFinished();
|
|
||||||
await firstPromise;
|
await firstPromise;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface TransitionRequest {
|
|||||||
|
|
||||||
export class ViewTransitionManager {
|
export class ViewTransitionManager {
|
||||||
#activeViewTransition = $state<ViewTransition | null>(null);
|
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||||
|
#activeOnFinished: (() => void) | undefined;
|
||||||
|
|
||||||
get activeViewTransition() {
|
get activeViewTransition() {
|
||||||
return this.#activeViewTransition;
|
return this.#activeViewTransition;
|
||||||
@@ -23,6 +24,9 @@ export class ViewTransitionManager {
|
|||||||
const skipped = !!this.#activeViewTransition;
|
const skipped = !!this.#activeViewTransition;
|
||||||
this.#activeViewTransition?.skipTransition();
|
this.#activeViewTransition?.skipTransition();
|
||||||
this.#activeViewTransition = null;
|
this.#activeViewTransition = null;
|
||||||
|
const onFinished = this.#activeOnFinished;
|
||||||
|
this.#activeOnFinished = undefined;
|
||||||
|
onFinished?.();
|
||||||
return skipped;
|
return skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +38,7 @@ export class ViewTransitionManager {
|
|||||||
onFinished,
|
onFinished,
|
||||||
}: TransitionRequest) {
|
}: TransitionRequest) {
|
||||||
if (this.#activeViewTransition) {
|
if (this.#activeViewTransition) {
|
||||||
return;
|
this.skipTransitions();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isSupported()) {
|
if (!this.isSupported()) {
|
||||||
@@ -68,6 +72,7 @@ export class ViewTransitionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.#activeViewTransition = transition;
|
this.#activeViewTransition = transition;
|
||||||
|
this.#activeOnFinished = onFinished;
|
||||||
|
|
||||||
// eslint-disable-next-line tscompat/tscompat
|
// eslint-disable-next-line tscompat/tscompat
|
||||||
void transition.ready.catch((error: unknown) => {
|
void transition.ready.catch((error: unknown) => {
|
||||||
@@ -80,8 +85,11 @@ export class ViewTransitionManager {
|
|||||||
void transition.finished
|
void transition.finished
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.#activeViewTransition = null;
|
if (this.#activeViewTransition === transition) {
|
||||||
onFinished?.();
|
this.#activeViewTransition = null;
|
||||||
|
this.#activeOnFinished = undefined;
|
||||||
|
onFinished?.();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait only until the DOM update completes (both snapshots captured),
|
// Wait only until the DOM update completes (both snapshots captured),
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export type Events = {
|
|||||||
ViewerCloseTransitionReady: [];
|
ViewerCloseTransitionReady: [];
|
||||||
ViewerOpenTransition: [];
|
ViewerOpenTransition: [];
|
||||||
ViewerOpenTransitionReady: [];
|
ViewerOpenTransitionReady: [];
|
||||||
|
ViewTransitionOldSnapshotPending: [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const eventManager = new BaseEventManager<Events>();
|
export const eventManager = new BaseEventManager<Events>();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
slideshowTransition,
|
slideshowTransition,
|
||||||
slideshowAutoplay,
|
slideshowAutoplay,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
kenBurnsEffect,
|
||||||
slideshowState,
|
slideshowState,
|
||||||
} = slideshowStore;
|
} = slideshowStore;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
let tempSlideshowTransition = $state($slideshowTransition);
|
let tempSlideshowTransition = $state($slideshowTransition);
|
||||||
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
let tempSlideshowAutoplay = $state($slideshowAutoplay);
|
||||||
let tempSlideshowRepeat = $state($slideshowRepeat);
|
let tempSlideshowRepeat = $state($slideshowRepeat);
|
||||||
|
let tempKenBurnsEffect = $state($kenBurnsEffect);
|
||||||
|
|
||||||
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
||||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
$slideshowTransition = tempSlideshowTransition;
|
$slideshowTransition = tempSlideshowTransition;
|
||||||
$slideshowAutoplay = tempSlideshowAutoplay;
|
$slideshowAutoplay = tempSlideshowAutoplay;
|
||||||
$slideshowRepeat = tempSlideshowRepeat;
|
$slideshowRepeat = tempSlideshowRepeat;
|
||||||
|
$kenBurnsEffect = tempKenBurnsEffect;
|
||||||
$slideshowState = SlideshowState.PlaySlideshow;
|
$slideshowState = SlideshowState.PlaySlideshow;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -107,6 +110,10 @@
|
|||||||
<Switch bind:checked={tempSlideshowTransition} />
|
<Switch bind:checked={tempSlideshowTransition} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field label={$t('slideshow_ken_burns_effect')}>
|
||||||
|
<Switch bind:checked={tempKenBurnsEffect} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
|
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
|
||||||
<Switch bind:checked={tempSlideshowRepeat} />
|
<Switch bind:checked={tempSlideshowRepeat} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ function createSlideshowStore() {
|
|||||||
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
const slideshowTransition = persisted<boolean>('slideshow-transition', true);
|
||||||
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
const slideshowAutoplay = persisted<boolean>('slideshow-autoplay', true, {});
|
||||||
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
const slideshowRepeat = persisted<boolean>('slideshow-repeat', false);
|
||||||
|
const kenBurnsEffect = persisted<boolean>('slideshow-ken-burns-effect', false);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
restartProgress: {
|
restartProgress: {
|
||||||
@@ -73,6 +74,7 @@ function createSlideshowStore() {
|
|||||||
slideshowTransition,
|
slideshowTransition,
|
||||||
slideshowAutoplay,
|
slideshowAutoplay,
|
||||||
slideshowRepeat,
|
slideshowRepeat,
|
||||||
|
kenBurnsEffect,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Faces } from '$lib/stores/people.store';
|
||||||
|
import { SlideshowLook } from '$lib/stores/slideshow.store';
|
||||||
|
import type { Point } from '$lib/utils/container-utils';
|
||||||
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
|
import { clamp } from 'lodash-es';
|
||||||
|
import smartcrop from 'smartcrop';
|
||||||
|
|
||||||
|
const KEN_BURNS_MAX_ZOOM_SPEED = 0.08;
|
||||||
|
const KEN_BURNS_MAX_PAN_SPEED = 8;
|
||||||
|
|
||||||
|
export interface KenBurnsKeyframes {
|
||||||
|
startTransform: string;
|
||||||
|
endTransform: string;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KenBurnsInput {
|
||||||
|
faces: Faces[];
|
||||||
|
fallbackFaces: Faces[];
|
||||||
|
smartCropCenter: Point | undefined;
|
||||||
|
contentWidth: number;
|
||||||
|
contentHeight: number;
|
||||||
|
containerWidth: number;
|
||||||
|
containerHeight: number;
|
||||||
|
slideshowLook: SlideshowLook;
|
||||||
|
isCoverMode: boolean;
|
||||||
|
slideshowDelay: number;
|
||||||
|
assetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeSmartCropCenter(
|
||||||
|
imgRef: HTMLImageElement,
|
||||||
|
faces: Faces[],
|
||||||
|
containerWidth: number,
|
||||||
|
containerHeight: number,
|
||||||
|
): Promise<Point | undefined> {
|
||||||
|
if (!TUNABLES.KEN_BURNS.SMARTCROP) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boosts =
|
||||||
|
TUNABLES.KEN_BURNS.FACE_BOOST && faces.length > 0
|
||||||
|
? faces.map((face) => ({
|
||||||
|
x: (face.boundingBoxX1 / face.imageWidth) * imgRef.naturalWidth,
|
||||||
|
y: (face.boundingBoxY1 / face.imageHeight) * imgRef.naturalHeight,
|
||||||
|
width: ((face.boundingBoxX2 - face.boundingBoxX1) / face.imageWidth) * imgRef.naturalWidth,
|
||||||
|
height: ((face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight) * imgRef.naturalHeight,
|
||||||
|
weight: 1,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const result = await smartcrop.crop(imgRef, {
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
...(boosts && { boost: boosts }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { x, y, width, height } = result.topCrop;
|
||||||
|
return {
|
||||||
|
x: (x + width / 2) / imgRef.naturalWidth,
|
||||||
|
y: (y + height / 2) / imgRef.naturalHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectKenBurnsFace = (faces: Faces[]): Faces | null => {
|
||||||
|
let best: Faces | null = null;
|
||||||
|
let bestArea = 0;
|
||||||
|
for (const face of faces) {
|
||||||
|
const area = (face.boundingBoxX2 - face.boundingBoxX1) * (face.boundingBoxY2 - face.boundingBoxY1);
|
||||||
|
if (area > bestArea) {
|
||||||
|
best = face;
|
||||||
|
bestArea = area;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeKenBurnsKeyframes({
|
||||||
|
faces,
|
||||||
|
fallbackFaces,
|
||||||
|
smartCropCenter,
|
||||||
|
contentWidth,
|
||||||
|
contentHeight,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
slideshowLook,
|
||||||
|
isCoverMode,
|
||||||
|
slideshowDelay,
|
||||||
|
assetId,
|
||||||
|
}: KenBurnsInput): KenBurnsKeyframes {
|
||||||
|
const blurredBackground = slideshowLook === SlideshowLook.BlurredBackground;
|
||||||
|
|
||||||
|
const minZoom =
|
||||||
|
!blurredBackground && contentWidth > 0 && contentHeight > 0
|
||||||
|
? Math.min(Math.max(containerWidth / contentWidth, containerHeight / contentHeight), 2)
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
const slideDurationMs = slideshowDelay * 1000;
|
||||||
|
const maxZoomChange = KEN_BURNS_MAX_ZOOM_SPEED * (slideDurationMs / 1000);
|
||||||
|
|
||||||
|
const face = selectKenBurnsFace(faces) ?? selectKenBurnsFace(fallbackFaces);
|
||||||
|
|
||||||
|
let targetScale: number;
|
||||||
|
let endX = 0;
|
||||||
|
let endY = 0;
|
||||||
|
|
||||||
|
if (face && contentWidth > 0) {
|
||||||
|
const faceHeightFraction = (face.boundingBoxY2 - face.boundingBoxY1) / face.imageHeight;
|
||||||
|
const faceTargetZoom = (0.4 * containerHeight) / (faceHeightFraction * contentHeight);
|
||||||
|
targetScale = clamp(faceTargetZoom, Math.max(minZoom, 1.2), 2);
|
||||||
|
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
|
||||||
|
|
||||||
|
const targetNormalizedX =
|
||||||
|
TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter
|
||||||
|
? smartCropCenter.x
|
||||||
|
: (face.boundingBoxX1 + face.boundingBoxX2) / 2 / face.imageWidth;
|
||||||
|
const targetNormalizedY =
|
||||||
|
TUNABLES.KEN_BURNS.FACE_BOOST && smartCropCenter
|
||||||
|
? smartCropCenter.y
|
||||||
|
: (face.boundingBoxY1 + face.boundingBoxY2) / 2 / face.imageHeight;
|
||||||
|
|
||||||
|
endX = (((0.5 - targetNormalizedX) * contentWidth) / containerWidth) * 100;
|
||||||
|
endY = (((0.5 - targetNormalizedY) * contentHeight) / containerHeight) * 100;
|
||||||
|
} else {
|
||||||
|
targetScale = clamp(minZoom, 1.2, 2);
|
||||||
|
targetScale = clamp(targetScale, Math.max(minZoom, 1.2), minZoom + maxZoomChange);
|
||||||
|
|
||||||
|
if (smartCropCenter && contentWidth > 0) {
|
||||||
|
endX = (((0.5 - smartCropCenter.x) * contentWidth) / containerWidth) * 100;
|
||||||
|
endY = (((0.5 - smartCropCenter.y) * contentHeight) / containerHeight) * 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampWidth = blurredBackground || isCoverMode ? containerWidth : contentWidth;
|
||||||
|
const clampHeight = blurredBackground || isCoverMode ? containerHeight : contentHeight;
|
||||||
|
const maxTranslateX = Math.max(0, (clampWidth / (2 * containerWidth) - 1 / (2 * targetScale)) * 100);
|
||||||
|
const maxTranslateY = Math.max(0, (clampHeight / (2 * containerHeight) - 1 / (2 * targetScale)) * 100);
|
||||||
|
endX = clamp(endX, -maxTranslateX, maxTranslateX);
|
||||||
|
endY = clamp(endY, -maxTranslateY, maxTranslateY);
|
||||||
|
|
||||||
|
const panDist = Math.hypot(endX, endY);
|
||||||
|
const maxPan = KEN_BURNS_MAX_PAN_SPEED * (slideDurationMs / 1000);
|
||||||
|
if (panDist > maxPan && panDist > 0) {
|
||||||
|
const ratio = maxPan / panDist;
|
||||||
|
endX *= ratio;
|
||||||
|
endY *= ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomIn = Number.parseInt(assetId.at(-1) ?? '0', 16) < 8;
|
||||||
|
|
||||||
|
const startTransform = zoomIn
|
||||||
|
? `scale(${minZoom}) translate(0%, 0%)`
|
||||||
|
: `scale(${targetScale}) translate(${endX}%, ${endY}%)`;
|
||||||
|
const endTransform = zoomIn
|
||||||
|
? `scale(${targetScale}) translate(${endX}%, ${endY}%)`
|
||||||
|
: `scale(${minZoom}) translate(0%, 0%)`;
|
||||||
|
|
||||||
|
return { startTransform, endTransform, duration: slideDurationMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KenBurnsAnimation {
|
||||||
|
#animation: Animation | undefined;
|
||||||
|
#element: HTMLElement | undefined;
|
||||||
|
#cancelToken: { value: boolean } | undefined;
|
||||||
|
|
||||||
|
async startWithSmartCrop(
|
||||||
|
element: HTMLElement,
|
||||||
|
input: Omit<KenBurnsInput, 'smartCropCenter'> & { imgRef: HTMLImageElement },
|
||||||
|
) {
|
||||||
|
this.cancel();
|
||||||
|
const token = { value: false };
|
||||||
|
this.#cancelToken = token;
|
||||||
|
|
||||||
|
const { imgRef, faces, fallbackFaces, containerWidth, containerHeight, ...rest } = input;
|
||||||
|
const allFaces = faces.length > 0 ? faces : fallbackFaces;
|
||||||
|
const smartCropCenter = await computeSmartCropCenter(imgRef, allFaces, containerWidth, containerHeight);
|
||||||
|
|
||||||
|
if (token.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframes = computeKenBurnsKeyframes({
|
||||||
|
faces,
|
||||||
|
fallbackFaces,
|
||||||
|
smartCropCenter,
|
||||||
|
containerWidth,
|
||||||
|
containerHeight,
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
this.start(element, keyframes);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(element: HTMLElement, { startTransform, endTransform, duration }: KenBurnsKeyframes) {
|
||||||
|
this.cancel();
|
||||||
|
this.#element = element;
|
||||||
|
|
||||||
|
element.style.transformOrigin = '50% 50%';
|
||||||
|
element.style.transform = startTransform;
|
||||||
|
|
||||||
|
const keyframes: Keyframe[] = [{ transform: startTransform, easing: 'ease-in-out' }, { transform: endTransform }];
|
||||||
|
this.#animation = element.animate(keyframes, { duration, fill: 'forwards' });
|
||||||
|
}
|
||||||
|
|
||||||
|
freeze() {
|
||||||
|
if (!this.#animation || !this.#element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frozen = getComputedStyle(this.#element).transform;
|
||||||
|
this.#animation.cancel();
|
||||||
|
this.#animation = undefined;
|
||||||
|
if (frozen && frozen !== 'none') {
|
||||||
|
this.#element.style.transform = frozen;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.#animation?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
this.#animation?.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
if (this.#cancelToken) {
|
||||||
|
this.#cancelToken.value = true;
|
||||||
|
this.#cancelToken = undefined;
|
||||||
|
}
|
||||||
|
this.#animation?.cancel();
|
||||||
|
this.#animation = undefined;
|
||||||
|
this.#element?.style.removeProperty('transform-origin');
|
||||||
|
this.#element = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import { goto } from '$app/navigation';
|
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||||
import { Route } from '$lib/route';
|
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
export function startViewerTransition(
|
function startHeroTransition(
|
||||||
assetId: string,
|
type: string,
|
||||||
|
id: string,
|
||||||
navigate: () => void,
|
navigate: () => void,
|
||||||
setTransitionId: (id: string | null) => void,
|
setTransitionId: (id: string | null) => void,
|
||||||
) {
|
) {
|
||||||
void viewTransitionManager.startTransition({
|
void viewTransitionManager.startTransition({
|
||||||
types: ['viewer'],
|
types: [type],
|
||||||
prepareOldSnapshot: () => {
|
prepareOldSnapshot: () => {
|
||||||
setTransitionId(assetId);
|
setTransitionId(id);
|
||||||
},
|
},
|
||||||
performUpdate: async (signal) => {
|
performUpdate: async (signal) => {
|
||||||
setTransitionId(null);
|
setTransitionId(null);
|
||||||
@@ -25,6 +24,22 @@ export function startViewerTransition(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startViewerTransition(
|
||||||
|
assetId: string,
|
||||||
|
navigate: () => void,
|
||||||
|
setTransitionId: (id: string | null) => void,
|
||||||
|
) {
|
||||||
|
startHeroTransition('viewer', assetId, navigate, setTransitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startMemoryTransition(
|
||||||
|
memoryId: string,
|
||||||
|
navigate: () => void,
|
||||||
|
setTransitionId: (id: string | null) => void,
|
||||||
|
) {
|
||||||
|
startHeroTransition('memory-enter', memoryId, navigate, setTransitionId);
|
||||||
|
}
|
||||||
|
|
||||||
let activeOverlay: HTMLElement | undefined;
|
let activeOverlay: HTMLElement | undefined;
|
||||||
|
|
||||||
export function removeCrossfadeOverlay() {
|
export function removeCrossfadeOverlay() {
|
||||||
@@ -34,64 +49,6 @@ export function removeCrossfadeOverlay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToTimeline(
|
|
||||||
assetId: string,
|
|
||||||
options: { types: string[]; prepareOldSnapshot?: () => void; onFinished?: () => void },
|
|
||||||
) {
|
|
||||||
let heroOverlay: HTMLElement | null = null;
|
|
||||||
let hiddenElement: HTMLElement | null = null;
|
|
||||||
|
|
||||||
void viewTransitionManager.startTransition({
|
|
||||||
types: options.types,
|
|
||||||
prepareOldSnapshot: options.prepareOldSnapshot,
|
|
||||||
performUpdate: async () => {
|
|
||||||
const scrolled = eventManager.untilNext('TimelineScrolledToAsset');
|
|
||||||
await goto(Route.photos({ at: assetId }));
|
|
||||||
await scrolled;
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
const element = document.querySelector<HTMLElement>(`[data-asset-id="${CSS.escape(assetId)}"]`);
|
|
||||||
if (!element) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const img = element.querySelector('img');
|
|
||||||
|
|
||||||
hiddenElement = element;
|
|
||||||
element.style.visibility = 'hidden';
|
|
||||||
|
|
||||||
heroOverlay = document.createElement('div');
|
|
||||||
heroOverlay.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: ${rect.top}px;
|
|
||||||
left: ${rect.left}px;
|
|
||||||
width: ${rect.width}px;
|
|
||||||
height: ${rect.height}px;
|
|
||||||
view-transition-name: hero;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
overflow: hidden;
|
|
||||||
`;
|
|
||||||
if (img?.src) {
|
|
||||||
heroOverlay.style.backgroundImage = `url("${CSS.escape(img.src)}")`;
|
|
||||||
heroOverlay.style.backgroundSize = 'cover';
|
|
||||||
heroOverlay.style.backgroundPosition = 'center';
|
|
||||||
}
|
|
||||||
document.body.append(heroOverlay);
|
|
||||||
},
|
|
||||||
onFinished: () => {
|
|
||||||
heroOverlay?.remove();
|
|
||||||
heroOverlay = null;
|
|
||||||
if (hiddenElement) {
|
|
||||||
hiddenElement.style.visibility = '';
|
|
||||||
hiddenElement = null;
|
|
||||||
}
|
|
||||||
options.onFinished?.();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
|
export async function crossfadeViewerContent(updateFn: () => void | Promise<void>, duration = 200) {
|
||||||
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
const viewerContent = document.querySelector<HTMLElement>('[data-viewer-content]');
|
||||||
if (!viewerContent) {
|
if (!viewerContent) {
|
||||||
@@ -101,12 +58,15 @@ export async function crossfadeViewerContent(updateFn: () => void | Promise<void
|
|||||||
|
|
||||||
removeCrossfadeOverlay();
|
removeCrossfadeOverlay();
|
||||||
|
|
||||||
|
eventManager.emit('ViewTransitionOldSnapshotPending');
|
||||||
|
|
||||||
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
const clone = viewerContent.cloneNode(true) as HTMLElement;
|
||||||
Object.assign(clone.style, {
|
Object.assign(clone.style, {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: '0',
|
inset: '0',
|
||||||
zIndex: '1',
|
zIndex: '1',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
backgroundColor: 'black',
|
||||||
});
|
});
|
||||||
delete clone.dataset.viewerContent;
|
delete clone.dataset.viewerContent;
|
||||||
if (!viewerContent.parentElement) {
|
if (!viewerContent.parentElement) {
|
||||||
|
|||||||
@@ -31,4 +31,8 @@ export const TUNABLES = {
|
|||||||
IMAGE_THUMBNAIL: {
|
IMAGE_THUMBNAIL: {
|
||||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
|
||||||
},
|
},
|
||||||
|
KEN_BURNS: {
|
||||||
|
SMARTCROP: getBoolean(storage.getItem('KEN_BURNS.SMARTCROP'), true),
|
||||||
|
FACE_BOOST: getBoolean(storage.getItem('KEN_BURNS.FACE_BOOST'), true),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
@@ -38,8 +38,9 @@
|
|||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import { startMemoryTransition } from '$lib/utils/transition-utils';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
|
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel, type CarouselImageItem } from '@immich/ui';
|
||||||
import { mdiDotsVertical } from '@mdi/js';
|
import { mdiDotsVertical } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -99,6 +100,16 @@
|
|||||||
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
src: getAssetMediaUrl({ id: memory.assets[0].id }),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let memoryTransitionId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const handleMemoryCardClick = (item: CarouselImageItem) => {
|
||||||
|
startMemoryTransition(
|
||||||
|
item.id ?? item.href,
|
||||||
|
() => void goto(item.href),
|
||||||
|
(id) => (memoryTransitionId = id),
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
|
<UserPageLayout hideNavbar={assetInteraction.selectionActive} scrollbar={false}>
|
||||||
@@ -112,7 +123,33 @@
|
|||||||
withStacked
|
withStacked
|
||||||
>
|
>
|
||||||
{#if $preferences.memories.enabled}
|
{#if $preferences.memories.enabled}
|
||||||
<ImageCarousel {items} />
|
{#snippet memoryCard(item: CarouselImageItem)}
|
||||||
|
<a
|
||||||
|
class="relative me-2 inline-block aspect-3/4 h-54 rounded-xl last:me-0 max-md:h-37.5 md:me-4 md:aspect-4/3 xl:aspect-video"
|
||||||
|
href={item.href}
|
||||||
|
data-memory-id={item.id}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMemoryCardClick(item);
|
||||||
|
}}
|
||||||
|
style:box-shadow="rgba(60, 64, 67, 0.3) 0px 1px 2px 0px, rgba(60, 64, 67, 0.15) 0px 1px 3px 1px"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="h-full w-full rounded-xl object-cover"
|
||||||
|
src={item.src}
|
||||||
|
alt={item.alt ?? item.title}
|
||||||
|
draggable="false"
|
||||||
|
style:view-transition-name={memoryTransitionId === (item.id ?? item.href) ? 'hero' : undefined}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="absolute inset-s-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||||
|
></div>
|
||||||
|
<p class="absolute inset-s-4 bottom-2 text-lg text-white max-md:text-sm">
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
<ImageCarousel {items} child={memoryCard} />
|
||||||
{/if}
|
{/if}
|
||||||
{#snippet empty()}
|
{#snippet empty()}
|
||||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
||||||
|
|||||||
Reference in New Issue
Block a user