Compare commits

..

19 Commits

Author SHA1 Message Date
midzelis
197bc5d387 tweak animation - no crossfade by default 2026-02-04 02:31:14 +00:00
midzelis
6a16f3697f feat: web - view transitions from timeline to viewer, next/prev
feat: web - view transitions from timeline to viewer, next/prev

feat: web - swipe feedback - show image while swiping/dragging left/right

feat: web - swipe feedback - show image while swiping/dragging left/right
2026-02-04 02:31:14 +00:00
midzelis
d4901c8818 feat: swipe feedback
refactor: replace onPreviousAsset/onNextAsset with onSwipe
2026-02-04 02:27:50 +00:00
midzelis
f58dcb2f76 bugfixes in thumbnail 2026-02-04 02:27:50 +00:00
midzelis
5c046df37d feat: rework photo-viewer/asset-viewer - introduce adaptive-image.svelte
preload thumbs and previews
2026-02-04 02:27:50 +00:00
midzelis
a99a085583 review comments 2026-02-04 02:24:27 +00:00
midzelis
205e87007a remove sw response handling 2026-02-04 02:24:27 +00:00
midzelis
9a90e4f942 feat: remove Cache API, rework preload(), cancel() and fetch()
perf - replace broadcast channel with direct postMessage
2026-02-04 02:24:27 +00:00
Jason Rasmussen
3ea65f8d27 fix: album dto docs (#25873) 2026-02-03 21:05:18 +00:00
github-actions
38c1f0b1fd chore: version v2.5.3 2026-02-03 18:14:21 +00:00
Michel Heusschen
5212bca3d0 fix: reset zoom when navigating between assets (#25863) 2026-02-03 11:07:06 -06:00
Daniel Dietzler
2990bde0bb fix: metadata extraction race condition (#25866) 2026-02-03 11:03:27 -06:00
Michel Heusschen
af1ecaf5cc fix: prevent backspace from accidentally triggering delete modals (#25858)
* fix: prevent backspace from accidentally triggering delete modals

* ignore input fields instead of removing shortcut
2026-02-03 16:42:46 +00:00
Alex
3870ebc3c6 fix: prevent album page get rebuilt when resuming app (#25862) 2026-02-03 16:35:53 +00:00
Michel Heusschen
0a9d969b47 fix: prevent stale values in edit user form after save (#25859) 2026-02-03 17:29:01 +01:00
Daniel Dietzler
94965f6d66 chore: rework tags sidebar (#25855) 2026-02-03 16:06:26 +00:00
Alex
8872d2c7ae chore: remove swift logs (#25857) 2026-02-03 16:00:17 +00:00
Alex
23445fdcc1 fix: upload progress bar flickering (#25829)
* fix: upload progress bar flickering

* pr feedback and more logs
2026-02-03 09:28:29 -06:00
renovate[bot]
25f2273e24 chore(deps): update redis:6.2-alpine docker digest to 46884be (#25839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 12:56:56 +01:00
89 changed files with 3083 additions and 877 deletions

View File

@@ -23,21 +23,9 @@ We generally discourage PRs entirely generated by an LLM. For any part generated
From time to time, we put a feature freeze on parts of the codebase. For us, this means we won't accept most PRs that make changes in that area. Exempted from this are simple bug fixes that require only minor changes. We will close feature PRs that target a feature-frozen area, even if that feature is highly requested and you put a lot of work into it. Please keep that in mind, and if you're ever uncertain if a PR would be accepted, reach out to us first (e.g., in the aforementioned `#contributing` channel). We hate to throw away work. Currently, we have feature freezes on:
- Sharing/Asset ownership
- (External) libraries
* Sharing/Asset ownership
* (External) libraries
## Non-code contributions
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team.
### Translations
All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated!
### Datasets
Help us improve our [Immich Datasets](https://datasets.immich.app) by submitting photos and videos taken from a variety of devices, including smartphones, DSLRs, and action cameras, as well as photos with unique features, such as panoramas, burst photos, and photo spheres. These datasets will be publically available for anyone to use, do not submit private/sensitive photos.
### Community support
If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.
If you want to contribute to Immich but you don't feel comfortable programming in our tech stack, there are other ways you can help the team. All our translations are done through [Weblate](https://hosted.weblate.org/projects/immich). These rely entirely on the community; if you speak a language that isn't fully translated yet, submitting translations there is greatly appreciated! If you like helping others, answering Q&A discussions here on GitHub and replying to people on our Discord is also always appreciated.

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.5.2",
"version": "2.5.3",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -1,7 +1,7 @@
[
{
"label": "v2.5.2",
"url": "https://docs.v2.5.2.archive.immich.app"
"label": "v2.5.3",
"url": "https://docs.v2.5.3.archive.immich.app"
},
{
"label": "v2.4.1",

View File

@@ -70,7 +70,7 @@ services:
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

View File

@@ -42,7 +42,7 @@ services:
- 2285:2285
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
image: redis:6.2-alpine@sha256:46884be93652d02a96a176ccf173d1040bef365c5706aa7b6a1931caec8bfeef
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.5.2",
"version": "2.5.3",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -1,10 +1,7 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { Page, expect, test } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
function imageLocator(page: Page) {
return page.getByAltText('Image taken').locator('visible=true');
}
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
@@ -26,31 +23,44 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const box = await thumbnail.boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
await expect(original).toBeInViewport();
await expect(original).toHaveAttribute('src', /original/);
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const original = page.getByTestId('original').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const box = await thumbnail.boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
const initialSrc = await thumbnail.getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
await expect(preview).not.toHaveAttribute('src', initialSrc!);
});
});

View File

@@ -65,7 +65,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
return page.locator('[data-thumbnail-focus-container][data-selected]');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,11 +103,8 @@ export const thumbnailUtils = {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.5.2",
"version": "2.5.3",
"private": true,
"scripts": {
"format": "prettier --check .",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.5.2"
version = "2.5.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"

View File

@@ -882,7 +882,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.5.2"
version = "2.5.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3033,
"android.injected.version.name" => "2.5.2",
"android.injected.version.code" => 3034,
"android.injected.version.name" => "2.5.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -133,7 +133,6 @@ class LocalImageApiImpl: LocalImageApi {
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
]))
print("Successful response for \(requestId)")
Self.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.2</string>
<string>2.5.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -33,7 +33,7 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
@override
Widget build(BuildContext context) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 10;
final showScrollbar = albumCount > 20;
final scrollView = CustomScrollView(
controller: _scrollController,

View File

@@ -87,7 +87,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}
void onSearch(String searchTerm, QuickFilterMode filterMode) {
final userId = ref.watch(currentUserProvider)?.id;
final userId = ref.read(currentUserProvider)?.id;
filter = filter.copyWith(query: searchTerm, userId: userId, mode: filterMode);
filterAlbums();
@@ -186,7 +186,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
@override
Widget build(BuildContext context) {
final userId = ref.watch(currentUserProvider)?.id;
final userId = ref.watch(currentUserProvider.select((user) => user?.id));
// refilter and sort when albums change
ref.listen(remoteAlbumProvider.select((state) => state.albums), (_, _) async {

View File

@@ -259,6 +259,11 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> startForegroundBackup(String userId) async {
// Cancel any existing backup before starting a new one
if (state.cancelToken != null) {
await stopForegroundBackup();
}
state = state.copyWith(error: BackupError.none);
final cancelToken = CancellationToken();
@@ -375,21 +380,21 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
_logger.warning("Skip handleBackupResume (pre-call): notifier disposed");
return;
}
_logger.info("Resuming backup tasks...");
_logger.info("Start background backup sequence");
state = state.copyWith(error: BackupError.none);
final tasks = await _backgroundUploadService.getActiveTasks(kBackupGroup);
if (!mounted) {
_logger.warning("Skip handleBackupResume (post-call): notifier disposed");
return;
}
_logger.info("Found ${tasks.length} tasks");
_logger.info("Found ${tasks.length} pending tasks");
if (tasks.isEmpty) {
_logger.info("Start backup with URLSession");
_logger.info("No pending tasks, starting new upload");
return _backgroundUploadService.uploadBackupCandidates(userId);
}
_logger.info("Tasks to resume: ${tasks.length}");
_logger.info("Resuming upload ${tasks.length} assets");
return _backgroundUploadService.resume();
}
}

View File

@@ -164,9 +164,12 @@ class BackgroundUploadService {
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
_logger.info("No new backup candidates found, finishing background upload");
return;
}
_logger.info("Found ${candidates.length} backup candidates for background tasks");
const batchSize = 100;
final batch = candidates.take(batchSize).toList();
List<UploadTask> tasks = [];
@@ -179,6 +182,7 @@ class BackgroundUploadService {
}
if (tasks.isNotEmpty && !shouldAbortQueuingTasks) {
_logger.info("Enqueuing ${tasks.length} background upload tasks");
await enqueueTasks(tasks);
}
}

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.5.2
- API version: 2.5.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -473,7 +473,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = only own, undefined = all
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<Response> getAllAlbumsWithHttpInfo({ String? assetId, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums';
@@ -516,7 +516,7 @@ class AlbumsApi {
/// Filter albums containing this asset ID (ignores shared parameter)
///
/// * [bool] shared:
/// Filter by shared status: true = only shared, false = only own, undefined = all
/// Filter by shared status: true = only shared, false = not shared, undefined = all owned albums
Future<List<AlbumResponseDto>?> getAllAlbums({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsWithHttpInfo( assetId: assetId, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {

View File

@@ -1249,10 +1249,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1942,10 +1942,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.5.2+3033
version: 2.5.3+3034
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -1618,7 +1618,7 @@
"name": "shared",
"required": false,
"in": "query",
"description": "Filter by shared status: true = only shared, false = only own, undefined = all",
"description": "Filter by shared status: true = only shared, false = not shared, undefined = all owned albums",
"schema": {
"type": "boolean"
}
@@ -15057,7 +15057,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.5.2",
"version": "2.5.3",
"contact": {}
},
"tags": [

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.5.2",
"version": "2.5.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.5.2
* 2.5.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.5.2",
"version": "2.5.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",

23
pnpm-lock.yaml generated
View File

@@ -771,11 +771,8 @@ importers:
specifier: ^7946.0.16
version: 7946.0.16
'@zoom-image/core':
specifier: ^0.41.0
version: 0.41.4
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.48.0)
specifier: ^0.42.0
version: 0.42.0
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -5681,13 +5678,8 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
'@zoom-image/core@0.41.4':
resolution: {integrity: sha512-zUJNHWQzx8rmfNOlp2Rr0+n8I7QK9hLNThnusdtvz20/HN+J//RcDJmCuRDj6jUW/qJGh9FWR5sROMFBuPLPfQ==}
'@zoom-image/svelte@0.3.8':
resolution: {integrity: sha512-rkXS+JS4qkBccmRK9+I5j+Pe4rp78GWK/7y0EduBJNtt38q+AwmKhhQs8oTMKTU6lOzLgxjXy1TI802mtvcAmw==}
peerDependencies:
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0
'@zoom-image/core@0.42.0':
resolution: {integrity: sha512-aF7siQqxqmOVlBd65deaCM7L/6V80Rp7HazZJpxtErh8zAn5itXXKBv1KA1NufSPfRZsXl1QtysxkjB3gVIzxw==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
@@ -18675,15 +18667,10 @@ snapshots:
'@xtuc/long@4.2.2': {}
'@zoom-image/core@0.41.4':
'@zoom-image/core@0.42.0':
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.8(svelte@5.48.0)':
dependencies:
'@zoom-image/core': 0.41.4
svelte: 5.48.0
abab@2.0.6:
optional: true

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.5.2",
"version": "2.5.3",
"description": "",
"author": "",
"private": true,

View File

@@ -102,7 +102,7 @@ export class UpdateAlbumDto {
export class GetAlbumsDto {
@ValidateBoolean({
optional: true,
description: 'Filter by shared status: true = only shared, false = only own, undefined = all',
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
})
shared?: boolean;

View File

@@ -307,7 +307,6 @@ export class MetadataService extends BaseService {
const assetHeight = isSidewards ? validate(width) : validate(height);
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
this.assetRepository.update({
id: asset.id,
duration: this.getDuration(exifTags),
@@ -322,6 +321,7 @@ export class MetadataService extends BaseService {
}),
];
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
await this.applyTagList(asset);
if (this.isMotionPhoto(asset, exifTags)) {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.5.2",
"version": "2.5.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -37,8 +37,7 @@
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0",
"@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0",
"@zoom-image/core": "^0.42.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"geo-coordinates-parser": "^1.7.4",

View File

@@ -74,6 +74,40 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
/* Base animation duration for standard transitions (page fades, info panel) */
--vt-duration-default: 250ms;
/* Duration for hero transitions (thumbnail to full viewer) */
--vt-duration-hero: 280ms;
/* Duration for next/previous photo navigation */
--vt-duration-viewer-navigation: 270ms;
/* Duration for slideshow mode transitions */
--vt-duration-slideshow: 1s;
/* Easing function for slide animations (ease-out) */
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
/* How far images slide in/out during navigation (% of viewport) */
--vt-viewer-slide-distance: 15%;
/* Starting opacity for fly transitions (slide+fade effect) */
--vt-viewer-opacity-start: 0.1;
/* Maximum blur during fly transitions (currently disabled) */
--vt-viewer-blur-max: 0px;
--vt-viewer-next-in: slideInRight;
--vt-viewer-next-out: slideOutLeft;
--vt-viewer-prev-in: slideInLeft;
--vt-viewer-prev-out: slideOutRight;
--vt-viewer-old-opacity: 4;
/* For fly (slide+fade): uncomment these and comment above */
--vt-viewer-next-in: flyInRight;
--vt-viewer-next-out: flyOutLeft;
--vt-viewer-prev-in: flyInLeft;
--vt-viewer-prev-out: flyOutRight;
--vt-viewer-old-opacity: 1;
}
button:not(:disabled),
@@ -171,3 +205,360 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(root) {
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s flyOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 0s flyInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
z-index: 4;
}
::view-transition-image-pair(letterbox-left),
::view-transition-image-pair(letterbox-right),
::view-transition-image-pair(letterbox-top),
::view-transition-image-pair(letterbox-bottom) {
isolation: auto;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: none;
width: 100%;
height: 100%;
object-fit: fill;
background-color: var(--color-black);
}
::view-transition-group(exclude-leftbutton),
::view-transition-group(exclude-rightbutton),
::view-transition-group(exclude) {
animation: none;
z-index: 5;
}
::view-transition-old(exclude-leftbutton),
::view-transition-old(exclude-rightbutton),
::view-transition-old(exclude) {
visibility: hidden;
}
::view-transition-new(exclude-leftbutton),
::view-transition-new(exclude-rightbutton),
::view-transition-new(exclude) {
animation: none;
z-index: 5;
}
::view-transition-group(hero) {
animation-duration: var(--vt-duration-hero);
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition-old(hero) {
animation: none;
align-content: center;
}
::view-transition-new(hero) {
animation: none;
align-content: center;
}
::view-transition-old(next),
::view-transition-old(next-old) {
animation-name: var(--vt-viewer-next-out);
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
opacity: var(--vt-viewer-old-opacity);
overflow: hidden;
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation-name: var(--vt-viewer-next-in);
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
overflow: hidden;
}
::view-transition-old(previous) {
animation-name: var(--vt-viewer-prev-out);
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
opacity: var(--vt-viewer-old-opacity);
}
::view-transition-old(previous-old) {
animation-name: var(--vt-viewer-prev-out);
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
opacity: var(--vt-viewer-old-opacity);
overflow: hidden;
z-index: -1;
}
::view-transition-new(previous) {
animation-name: var(--vt-viewer-prev-in);
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
}
::view-transition-new(previous-new) {
animation-name: var(--vt-viewer-prev-in);
animation-duration: var(--vt-duration-viewer-navigation);
animation-timing-function: var(--vt-viewer-slide-easing);
animation-fill-mode: forwards;
overflow: hidden;
}
@keyframes slideInLeft {
from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
}
}
@keyframes slideInRight {
from {
transform: translateX(var(--vt-viewer-slide-distance));
}
}
@keyframes slideOutLeft {
/* No animation needed, instant hide via opacity */
}
@keyframes slideOutRight {
/* No animation needed, instant hide via opacity */
}
@keyframes flyInLeft {
from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
@keyframes fadeIn {
from {
opacity: 0;
}
50% {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
50% {
opacity: 0.85;
}
to {
opacity: 0;
}
}
/* Reduced motion: when system preference is set */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 100;
}
::view-transition-image-pair(letterbox-left),
::view-transition-image-pair(letterbox-right),
::view-transition-image-pair(letterbox-top),
::view-transition-image-pair(letterbox-bottom) {
isolation: auto;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: none;
width: 100%;
height: 100%;
object-fit: fill;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

@@ -0,0 +1,207 @@
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import type { ClassValue } from 'svelte/elements';
/**
* Converts a ClassValue to a string suitable for className assignment.
* Handles strings, arrays, and objects similar to how clsx works.
*/
function classValueToString(value: ClassValue | undefined): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value
.map((v) => classValueToString(v))
.filter(Boolean)
.join(' ');
}
// Object/dictionary case
return Object.entries(value)
.filter(([, v]) => v)
.map(([k]) => k)
.join(' ');
}
export interface ImageLoaderProperties {
imgClass?: ClassValue;
alt?: string;
draggable?: boolean;
role?: string;
style?: string;
title?: string | null;
loading?: 'lazy' | 'eager';
dataAttributes?: Record<string, string>;
}
export interface ImageSourceProperty {
src: string | undefined;
}
export interface ImageLoaderCallbacks {
onStart?: () => void;
onLoad?: () => void;
onError?: (error: Error) => void;
onElementCreated?: (element: HTMLImageElement) => void;
}
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
if (params.alt !== undefined) {
img.alt = params.alt;
}
if (params.draggable !== undefined) {
img.draggable = params.draggable;
}
if (params.imgClass) {
img.className = classValueToString(params.imgClass);
}
if (params.role) {
img.role = params.role;
}
if (params.style !== undefined) {
img.setAttribute('style', params.style);
}
if (params.title !== undefined && params.title !== null) {
img.title = params.title;
}
if (params.loading !== undefined) {
img.loading = params.loading;
}
if (params.dataAttributes) {
for (const [key, value] of Object.entries(params.dataAttributes)) {
img.setAttribute(key, value);
}
}
};
const destroyImageElement = (
imgElement: HTMLImageElement,
currentSrc: string | undefined,
handleLoad: () => void,
handleError: () => void,
) => {
imgElement.removeEventListener('load', handleLoad);
imgElement.removeEventListener('error', handleError);
cancelImageUrl(currentSrc);
imgElement.remove();
};
const createImageElement = (
src: string | undefined,
properties: ImageLoaderProperties,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
onElementCreated?: (imgElement: HTMLImageElement) => void,
) => {
if (!src) {
return undefined;
}
const img = document.createElement('img');
updateImageAttributes(img, properties);
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
onStart?.();
if (src) {
img.src = src;
onElementCreated?.(img);
}
return img;
};
export function loadImage(
src: string,
properties: ImageLoaderProperties,
onLoad: () => void,
onError: () => void,
onStart?: () => void,
) {
let destroyed = false;
const wrapper = (fn: (() => void) | undefined) => () => {
if (destroyed) {
return;
}
fn?.();
};
const wrappedOnLoad = wrapper(onLoad);
const wrappedOnError = wrapper(onError);
const wrappedOnStart = wrapper(onStart);
const img = createImageElement(src, properties, wrappedOnLoad, wrappedOnError, wrappedOnStart);
if (!img) {
return () => void 0;
}
return () => {
destroyed = true;
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
};
}
export type LoadImageFunction = typeof loadImage;
/**
* 1. Creates and appends an <img> element to the parent
* 2. Coordinates with service worker before src triggers fetch
* 3. Adds load/error listeners
* 4. Cancels SW request when element is removed from DOM
*/
export function imageLoader(
node: HTMLElement,
params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
) {
let currentSrc = params.src;
let currentCallbacks = params;
let imgElement: HTMLImageElement | undefined = undefined;
const handleLoad = () => {
currentCallbacks.onLoad?.();
};
const handleError = () => {
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
};
const handleElementCreated = (img: HTMLImageElement) => {
if (img) {
node.append(img);
currentCallbacks.onElementCreated?.(img);
}
};
const createImage = () => {
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
};
createImage();
return {
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
// If src changed, recreate the image element
if (newParams.src !== currentSrc) {
if (imgElement) {
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
}
currentSrc = newParams.src;
currentCallbacks = newParams;
createImage();
return;
}
currentCallbacks = newParams;
if (!imgElement) {
return;
}
updateImageAttributes(imgElement, newParams);
},
destroy() {
if (imgElement) {
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
}
},
};
}

View File

@@ -1,8 +1,12 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean; zoomTarget?: HTMLElement }) => {
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: assetViewerManager.zoomState,
zoomTarget: options?.zoomTarget ?? null,
});
const unsubscribes = [
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
@@ -20,8 +24,9 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
node.style.overflow = 'visible';
return {
update(newOptions?: { disabled?: boolean }) {
update(newOptions?: { disabled?: boolean; zoomTarget?: HTMLElement }) {
options = newOptions;
zoomInstance.setState({ zoomTarget: newOptions?.zoomTarget ?? null });
},
destroy() {
for (const unsubscribe of unsubscribes) {

View File

@@ -14,7 +14,7 @@ type ActionMap = {
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.KEEP_THIS_DELETE_OTHERS]: { asset: TimelineAsset };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | undefined; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };

View File

@@ -0,0 +1,244 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import { thumbhash } from '$lib/actions/thumbhash';
import { zoomImageAction } from '$lib/actions/zoom-image';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, untrack, type Snippet } from 'svelte';
interface Props {
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
zoomDisabled?: boolean;
imageClass?: string;
container: {
width: number;
height: number;
};
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
transitionName?: string | null | undefined;
onImageReady?: () => void;
onError?: () => void;
imgElement?: HTMLImageElement;
overlays?: Snippet;
}
let {
imgElement = $bindable<HTMLImageElement | undefined>(),
asset,
sharedLink,
zoomDisabled = false,
imageClass = '',
container,
slideshowState,
slideshowLook,
transitionName,
onImageReady,
onError,
overlays,
}: Props = $props();
let previousLoader = $state<AdaptiveImageLoader>();
let previousAssetId: string | undefined;
let previousSharedLinkId: string | undefined;
const adaptiveImageLoader = $derived.by(() => {
if (previousAssetId === asset.id && previousSharedLinkId === sharedLink?.id) {
return previousLoader!;
}
return untrack(() => {
previousAssetId = asset.id;
previousSharedLinkId = sharedLink?.id;
previousLoader?.destroy();
assetViewerManager.resetZoomState();
const loader = new AdaptiveImageLoader(asset, sharedLink, {
currentZoomFn: () => assetViewerManager.zoom,
onImageReady,
onError,
});
previousLoader = loader;
return loader;
});
});
onDestroy(() => adaptiveImageLoader.destroy());
const imageDimensions = $derived.by(() => {
if ((asset.width ?? 0) > 0 && (asset.height ?? 0) > 0) {
return { width: asset.width!, height: asset.height! };
}
if (asset.exifInfo?.exifImageHeight && asset.exifInfo.exifImageWidth) {
return getDimensions(asset.exifInfo) as { width: number; height: number };
}
return { width: 1, height: 1 };
});
const scaledDimensions = $derived(scaleToFit(imageDimensions, container));
const renderDimensions = $derived.by(() => {
const { width, height } = scaledDimensions;
return {
width: width + 'px',
height: height + 'px',
left: (container.width - width) / 2 + 'px',
top: (container.height - height) / 2 + 'px',
};
});
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && !!asset.thumbhash,
);
const loadState = $derived(adaptiveImageLoader.adaptiveLoaderState);
const imageAltText = $derived(loadState.previewUrl ? $getAltText(toTimelineAsset(asset)) : '');
const thumbnailUrl = $derived(loadState.thumbnailUrl);
const previewUrl = $derived(loadState.previewUrl);
const originalUrl = $derived(loadState.originalUrl);
const showSpinner = $derived(!asset.thumbhash && loadState.quality === 'basic');
const showBrokenAsset = $derived(loadState.hasError);
// Effect: Upgrade to original when user zooms in
$effect(() => {
if (assetViewerManager.zoom > 1 && loadState.quality === 'preview') {
void adaptiveImageLoader.triggerOriginal();
}
});
let thumbnailElement = $state<HTMLImageElement>();
let previewElement = $state<HTMLImageElement>();
let originalElement = $state<HTMLImageElement>();
let mainImageBox = $state<HTMLElement>();
// Effect: Synchronize highest quality element as main imgElement
$effect(() => {
imgElement = originalElement ?? previewElement ?? thumbnailElement;
});
</script>
<div class="relative h-full w-full" use:zoomImageAction={{ disabled: zoomDisabled, zoomTarget: mainImageBox }}>
<!-- Blurred slideshow background (full viewport) -->
{#if blurredSlideshow}
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash! }} class="-z-1 absolute top-0 left-0 start-0 h-dvh w-dvw"
></canvas>
{/if}
<!-- Letterbox regions (empty space around image) -->
<Letterboxes
{transitionName}
{slideshowState}
{slideshowLook}
hasThumbhash={!!asset.thumbhash}
{scaledDimensions}
{container}
/>
<!-- Main image box with transition -->
<div
bind:this={mainImageBox}
style:view-transition-name={transitionName}
data-transition-name={transitionName}
class="absolute"
style:left={renderDimensions.left}
style:top={renderDimensions.top}
style:width={renderDimensions.width}
style:height={renderDimensions.height}
>
{#if asset.thumbhash}
<!-- Thumbhash / spinner layer -->
<canvas use:thumbhash={{ base64ThumbHash: asset.thumbhash }} class="h-full w-full absolute -z-2"></canvas>
{:else if showSpinner}
<div id="spinner" class="absolute flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{/if}
<div
class="absolute top-0"
style:width={renderDimensions.width}
style:height={renderDimensions.height}
use:imageLoader={{
src: thumbnailUrl,
onStart: () => adaptiveImageLoader.onThumbnailStart(),
onLoad: () => adaptiveImageLoader.onThumbnailLoad(),
onError: () => adaptiveImageLoader.onThumbnailError(),
onElementCreated: (element) => (thumbnailElement = element),
imgClass: ['absolute h-full', 'w-full'],
alt: '',
role: 'presentation',
dataAttributes: {
'data-testid': 'thumbnail',
},
}}
></div>
{#if showBrokenAsset}
<BrokenAsset class="text-xl h-full w-full absolute" />
{:else}
<div
class="absolute top-0"
style:width={renderDimensions.width}
style:height={renderDimensions.height}
use:imageLoader={{
src: previewUrl,
onStart: () => adaptiveImageLoader.onPreviewStart(),
onLoad: () => adaptiveImageLoader.onPreviewLoad(),
onError: () => adaptiveImageLoader.onPreviewError(),
onElementCreated: (element) => (previewElement = element),
imgClass: ['h-full', 'w-full', imageClass],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'preview',
},
}}
>
{@render overlays?.()}
</div>
<div
class="absolute top-0"
style:width={renderDimensions.width}
style:height={renderDimensions.height}
use:imageLoader={{
src: originalUrl,
onStart: () => adaptiveImageLoader.onOriginalStart(),
onLoad: () => adaptiveImageLoader.onOriginalLoad(),
onError: () => adaptiveImageLoader.onOriginalError(),
onElementCreated: (element) => (originalElement = element),
imgClass: ['h-full', 'w-full', imageClass],
alt: imageAltText,
draggable: false,
dataAttributes: {
'data-testid': 'original',
},
}}
>
{@render overlays?.()}
</div>
{/if}
</div>
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import { loadImage } from '$lib/actions/image-loader.svelte';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
@@ -12,8 +13,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -21,14 +23,15 @@
import { user } from '$lib/stores/user.store';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
type AlbumResponseDto,
@@ -36,9 +39,10 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { onDestroy, onMount, untrack } from 'svelte';
import { CommandPaletteDefaultProvider } from '@immich/ui';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
import { fly, slide } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -87,27 +91,31 @@
onRandom,
}: Props = $props();
const { setAssetId } = assetViewingStore;
const { setAssetId, invisible } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
slideshowRepeat,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
const asset = $derived(cursor.current);
let stack: StackResponseDto | undefined = $state();
let selectedStackAsset = $derived(stack?.assets.find(({ id }) => id === stack?.primaryAssetId));
let previewStackedAsset: AssetResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
const previousAsset = $derived(cursor.previousAsset);
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
@@ -117,35 +125,50 @@
};
const refreshStack = async () => {
if (authManager.isSharedLink) {
if (authManager.isSharedLink || !withStacked) {
return;
}
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
if (!cursor.current.stack) {
stack = undefined;
return;
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
untrack(() => {
imageManager.preload(stack?.assets[1]);
});
stack = await getStack({ id: cursor.current.stack.id });
};
const handleFavorite = async () => {
if (album && album.isActivityEnabled) {
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
if (!album || !album.isActivityEnabled) {
return;
}
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
};
onMount(async () => {
let transitionName = $state<string | undefined>('hero');
let detailPanelTransitionName = $state<string | undefined>(undefined);
let unsubscribes: (() => void)[] = [];
onMount(() => {
const addInfoTransition = () => {
detailPanelTransitionName = 'info';
transitionName = 'hero';
};
const finished = () => {
detailPanelTransitionName = undefined;
transitionName = undefined;
};
unsubscribes.push(
eventManager.onMany({
TransitionToAssetViewer: addInfoTransition,
TransitionToTimeline: addInfoTransition,
Finished: finished,
}),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@@ -162,33 +185,21 @@
}
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
activityManager.reset();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
activityManager.reset();
destroyNextPreloader();
destroyPreviousPreloader();
});
const handleGetAllAlbums = async () => {
if (authManager.isSharedLink) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset);
};
@@ -201,52 +212,203 @@
assetViewerManager.closeEditor();
};
const tracker = new InvocationTracker();
const startTransition = async (
types: string[],
targetTransition: string | null,
targetAsset: AssetResponseDto | null,
navigateFn: () => Promise<boolean>,
) => {
const oldTransitionName = viewTransitionManager.getTransitionName('old', targetTransition);
const newTransitionName = viewTransitionManager.getTransitionName('new', targetTransition);
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
transitionName = oldTransitionName;
detailPanelTransitionName = 'detail-panel';
await tick();
const navigationResult = new Promise<boolean>((navigationResolve) => {
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('StartViewTransition', async () => {
transitionName = newTransitionName;
await tick();
const result = await navigateFn();
navigationResolve(result);
});
eventManager.once('AssetViewerFree', () => void tick().then(resolve));
}),
types,
);
});
return navigationResult;
};
let nextPreloader: AdaptiveImageLoader | undefined;
let previousPreloader: AdaptiveImageLoader | undefined;
let nextPreviewUrl = $state<string | undefined>();
let previousPreviewUrl = $state<string | undefined>();
const setPreviewUrl = (direction: 'next' | 'previous', url: string | undefined) => {
if (direction === 'next') {
nextPreviewUrl = url;
} else {
previousPreviewUrl = url;
}
};
const startPreloader = (asset: AssetResponseDto | undefined, direction: 'next' | 'previous') => {
if (!asset) {
return;
}
const loader = new AdaptiveImageLoader(
asset,
undefined,
{
currentZoomFn: () => 1,
onQualityUpgrade: (url) => setPreviewUrl(direction, url),
},
loadImage,
);
loader.start();
return loader;
};
const destroyPreviousPreloader = () => {
previousPreloader?.destroy();
previousPreloader = undefined;
previousPreviewUrl = undefined;
};
const destroyNextPreloader = () => {
nextPreloader?.destroy();
nextPreloader = undefined;
nextPreviewUrl = undefined;
};
const cancelPreloadsBeforeNavigation = (direction: 'previous' | 'next') => {
setPreviewUrl(direction, undefined);
if (direction === 'next') {
destroyPreviousPreloader();
return;
}
destroyNextPreloader();
};
const updatePreloadsAfterNavigation = (oldCursor: AssetCursor, newCursor: AssetCursor) => {
const movedForward = newCursor.current.id === oldCursor.nextAsset?.id;
const movedBackward = newCursor.current.id === oldCursor.previousAsset?.id;
const shouldDestroyPrevious = movedForward || !movedBackward;
const shouldDestroyNext = movedBackward || !movedForward;
if (movedForward) {
// When moving forward: old next becomes current, shift preview URLs
const oldNextUrl = nextPreviewUrl;
destroyPreviousPreloader();
previousPreviewUrl = oldNextUrl;
destroyNextPreloader();
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
} else if (movedBackward) {
// When moving backward: old previous becomes current, shift preview URLs
const oldPreviousUrl = previousPreviewUrl;
destroyNextPreloader();
nextPreviewUrl = oldPreviousUrl;
destroyPreviousPreloader();
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
} else {
// Non-adjacent navigation (e.g., slideshow random) - clear everything
if (shouldDestroyPrevious) {
destroyPreviousPreloader();
}
if (shouldDestroyNext) {
destroyNextPreloader();
}
previousPreloader = startPreloader(newCursor.previousAsset, 'previous');
nextPreloader = startPreloader(newCursor.nextAsset, 'next');
}
};
const getNavigationTarget = (): 'previous' | 'next' | undefined => {
if (slideShowPlaying) {
return slideShowAscending ? 'previous' : 'next';
}
return undefined;
};
const completeNavigation = async (order: 'previous' | 'next', skipTransition: boolean) => {
cancelPreloadsBeforeNavigation(order);
let skipped = false;
if (viewTransitionManager.skipTransitions()) {
skipped = true;
}
let hasNext = false;
if (slideShowPlaying && slideShowShuffle) {
const navigate = async () => {
let next = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!next) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
next = true;
}
}
return next;
};
// eslint-disable-next-line unicorn/prefer-ternary
if (viewTransitionManager.isSupported() && !skipped && !skipTransition) {
hasNext = await startTransition(['slideshow'], null, null, navigate);
} else {
hasNext = await navigate();
}
} else {
const targetAsset = order === 'previous' ? previousAsset : nextAsset;
const navigate = async () =>
order === 'previous' ? await navigateToAsset(previousAsset) : await navigateToAsset(nextAsset);
if (viewTransitionManager.isSupported() && !skipped && !skipTransition && !!targetAsset) {
const targetTransition = slideShowPlaying ? null : order;
hasNext = await startTransition(
slideShowPlaying ? ['slideshow'] : ['viewer-nav'],
targetTransition,
targetAsset,
navigate,
);
} else {
hasNext = await navigate();
}
}
if (!slideShowPlaying) {
return;
}
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
};
const tracker = new InvocationTracker();
const navigateAsset = (order?: 'previous' | 'next', skipTransition: boolean = false) => {
if (!order) {
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
} else {
return;
}
}
e?.stopPropagation();
imageManager.cancel(asset);
if (tracker.isActive()) {
return;
}
void tracker.invoke(async () => {
let hasNext = false;
if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle) {
hasNext = order === 'previous' ? slideshowHistory.previous() : slideshowHistory.next();
if (!hasNext) {
const asset = await onRandom?.();
if (asset) {
slideshowHistory.queue(asset);
hasNext = true;
}
}
} else {
hasNext =
order === 'previous' ? await navigateToAsset(cursor.previousAsset) : await navigateToAsset(cursor.nextAsset);
}
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
// Loop back to starting asset
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
}
}, $t('error_while_navigating'));
void tracker.invoke(
() => completeNavigation(order, skipTransition),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => eventManager.emit('AssetViewerAfterNavigate'),
);
};
/**
@@ -277,10 +439,11 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
if (!document.fullscreenElement) {
return;
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -289,16 +452,24 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
if (isMouseOver) {
previewStackedAsset = stackedAsset;
}
};
const handleStackedAssetsMouseLeave = () => {
previewStackedAsset = undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
await handleGetAllAlbums();
eventManager.emit('AlbumAddAssets');
break;
}
case AssetAction.DELETE:
@@ -308,9 +479,10 @@
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
if (!stack) {
break;
}
cursor.current = stack.assets[0];
break;
}
case AssetAction.STACK:
@@ -358,21 +530,46 @@
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
if (sharedLink) {
return;
}
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
cursor.current;
untrack(() => handlePromiseError(refresh()));
imageManager.preload(cursor.nextAsset);
imageManager.preload(cursor.previousAsset);
});
let lastCursor = $state<AssetCursor>();
$effect(() => {
if (cursor.current.id === lastCursor?.current.id) {
return;
}
if (lastCursor) {
selectedStackAsset = undefined;
previewStackedAsset = undefined;
// After navigation completes, reconcile preloads with full state information
updatePreloadsAfterNavigation(lastCursor, cursor);
lastCursor = cursor;
return;
}
// "first time" load, start preloads
if (cursor.nextAsset) {
nextPreloader = startPreloader(cursor.nextAsset, 'next');
}
if (cursor.previousAsset) {
previousPreloader = startPreloader(cursor.previousAsset, 'previous');
}
lastCursor = cursor;
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
@@ -390,9 +587,11 @@
}
};
const handleAssetViewerFree = () => eventManager.emit('AssetViewerFree');
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer';
@@ -426,8 +625,11 @@
!assetViewerManager.isShowEditor &&
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />
@@ -435,12 +637,16 @@
<section
id="immich-asset-viewer"
class="fixed start-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
class:invisible={$invisible}
use:focusTrap
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<div
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
style:view-transition-name="exclude"
>
<AssetViewerNavBar
{asset}
{album}
@@ -472,69 +678,70 @@
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<div
class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"
style:view-transition-name="exclude-leftbutton"
>
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
{/if}
<!-- Asset Viewer -->
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
{#if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
{transitionName}
cursor={{ ...cursor, current: previewStackedAsset! }}
assetId={previewStackedAsset!.id}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoStarted={handleVideoStarted}
onReady={handleAssetViewerFree}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{asset}
{transitionName}
{cursor}
assetId={asset.livePhotoVideoId!}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
onReady={handleAssetViewerFree}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} />
<ImagePanoramaViewer {asset} {transitionName} onReady={handleAssetViewerFree} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
<CropArea {asset} onReady={handleAssetViewerFree} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{transitionName}
cursor={{ ...cursor, current: asset }}
{sharedLink}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
onReady={handleAssetViewerFree}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{asset}
{transitionName}
{cursor}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoStarted={handleVideoStarted}
onReady={handleAssetViewerFree}
{playOriginalVideo}
/>
{/if}
@@ -559,19 +766,23 @@
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<div
class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"
style:view-transition-name="exclude-rightbutton"
>
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
transition:slide={{ axis: 'x', duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
style:view-transition-name={detailPanelTransitionName}
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
<DetailPanel {asset} currentAlbum={album} />
</div>
{/if}
@@ -589,10 +800,14 @@
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
<div
role="presentation"
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={handleStackedAssetsMouseLeave}
>
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
class={['inline-block px-1 relative transition-all pb-2']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
@@ -601,7 +816,7 @@
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
cursor.current = stackedAsset;
selectedStackAsset = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { Badge, IconButton, Link, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -18,22 +19,23 @@
let tags = $derived(asset.tags || []);
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}
};
const handleRemove = async (tagId: string) => {
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
if (ids) {
asset = await getAssetInfo({ id: asset.id });
}
};
const onAssetsTag = async (ids: string[]) => {
if (ids.includes(asset.id)) {
asset = await getAssetInfo({ id: asset.id });
}
};
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
<OnEvents {onAssetsTag} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
@@ -42,36 +44,24 @@
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
<Badge size="small" class="items-center px-0" shape="round">
<Link
href={Route.tags({ path: tag.value })}
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
>
<p class="text-sm">
{tag.value}
</p>
</a>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
{tag.value}
</Link>
<IconButton
aria-label={$t('remove_tag')}
icon={mdiClose}
onclick={() => handleRemove(tag.id)}
>
<Icon icon={mdiClose} />
</button>
</div>
size="tiny"
class="hover:bg-primary-400"
shape="round"
/>
</Badge>
{/each}
<button
type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title={$t('add_tag')}
onclick={handleAddTag}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"
><Icon icon={mdiPlus} />{$t('add')}</span
>
</button>
<HeaderActionButton action={Tag} />
</section>
</section>
{/if}

View File

@@ -7,6 +7,7 @@
import { timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
import { Route } from '$lib/route';
@@ -17,9 +18,16 @@
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { getParentPath } from '$lib/utils/tree-utils';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import {
mdiCalendar,
@@ -34,6 +42,7 @@
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { slide } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
@@ -43,11 +52,10 @@
interface Props {
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
}
let { asset, albums = [], currentAlbum = null }: Props = $props();
let { asset, currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -74,14 +82,40 @@
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
let albums = $state<AlbumResponseDto[]>([]);
const refreshAlbums = async () => {
if (authManager.isSharedLink) {
return;
}
try {
albums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
handleError(error, 'Error getting asset album membership');
}
};
onMount(() => eventManager.on('AlbumAddAssets', () => void refreshAlbums()));
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => void refreshAlbums());
});
$effect(() => {
if (!previousId) {
previousId = asset.id;
return;
}
if (asset.id !== previousId) {
showEditFaces = false;
previousId = asset.id;
if (asset.id === previousId) {
return;
}
showEditFaces = false;
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {

View File

@@ -7,9 +7,10 @@
interface Props {
asset: AssetResponseDto;
onReady?: () => void;
}
let { asset }: Props = $props();
let { asset, onReady }: Props = $props();
let canvasContainer = $state<HTMLElement | null>(null);
@@ -62,6 +63,8 @@
src={imageSrc}
alt={$getAltText(toTimelineAsset(asset))}
style={imageTransform ? `transform: ${imageTransform}` : ''}
onload={() => onReady?.()}
onerror={() => onReady?.()}
/>
<div
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}

View File

@@ -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,

View File

@@ -4,13 +4,14 @@
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
type Props = {
transitionName?: string;
asset: AssetResponseDto;
onReady?: () => void;
};
let { asset }: Props = $props();
let { transitionName, asset, onReady }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -18,11 +19,16 @@
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div class="flex h-dvh w-dvw select-none place-content-center place-items-center">
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
<PhotoSphereViewer
{transitionName}
panorama={data}
originalPanorama={getAssetUrl({ asset, forceOriginal: true })}
{onReady}
/>
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
interface Props {
transitionName?: string | null | undefined;
slideshowState: SlideshowState;
slideshowLook: SlideshowLook;
hasThumbhash: boolean;
scaledDimensions: {
width: number;
height: number;
};
container: {
width: number;
height: number;
};
}
let { transitionName, slideshowState, slideshowLook, hasThumbhash, scaledDimensions, container }: Props = $props();
const blurredSlideshow = $derived(
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && hasThumbhash,
);
const shouldShowLetterboxes = $derived(!!transitionName && transitionName !== 'hero' && !blurredSlideshow);
const transitionLetterboxLeft = $derived(shouldShowLetterboxes ? 'letterbox-left' : null);
const transitionLetterboxRight = $derived(shouldShowLetterboxes ? 'letterbox-right' : null);
const transitionLetterboxTop = $derived(shouldShowLetterboxes ? 'letterbox-top' : null);
const transitionLetterboxBottom = $derived(shouldShowLetterboxes ? 'letterbox-bottom' : null);
// Letterbox regions (the empty space around the main box)
const letterboxLeft = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (container.width - width) / 2;
return {
width: leftOffset + 'px',
height: container.height + 'px',
left: '0px',
top: '0px',
};
});
const letterboxRight = $derived.by(() => {
const { width } = scaledDimensions;
const leftOffset = (container.width - width) / 2;
const rightOffset = leftOffset;
return {
width: rightOffset + 'px',
height: container.height + 'px',
left: container.width - rightOffset + 'px',
top: '0px',
};
});
const letterboxTop = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (container.height - height) / 2;
const leftOffset = (container.width - width) / 2;
return {
width: width + 'px',
height: topOffset + 'px',
left: leftOffset + 'px',
top: '0px',
};
});
const letterboxBottom = $derived.by(() => {
const { width, height } = scaledDimensions;
const topOffset = (container.height - height) / 2;
const bottomOffset = topOffset;
const leftOffset = (container.width - width) / 2;
return {
width: width + 'px',
height: bottomOffset + 'px',
left: leftOffset + 'px',
top: container.height - bottomOffset + 'px',
};
});
</script>
<!-- Letterbox regions (empty space around image) -->
<div
class="absolute"
style:view-transition-name={transitionLetterboxLeft}
style:left={letterboxLeft.left}
style:top={letterboxLeft.top}
style:width={letterboxLeft.width}
style:height={letterboxLeft.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxRight}
style:left={letterboxRight.left}
style:top={letterboxRight.top}
style:width={letterboxRight.width}
style:height={letterboxRight.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxTop}
style:left={letterboxTop.left}
style:top={letterboxTop.top}
style:width={letterboxTop.width}
style:height={letterboxTop.height}
></div>
<div
class="absolute"
style:view-transition-name={transitionLetterboxBottom}
style:left={letterboxBottom.left}
style:top={letterboxBottom.top}
style:width={letterboxBottom.width}
style:height={letterboxBottom.height}
></div>

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import Letterboxes from '$lib/components/asset-viewer/letterboxes.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import {
@@ -28,18 +30,30 @@
};
type Props = {
transitionName?: string;
panorama: string | { source: string };
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
onReady?: () => void;
};
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let {
transitionName,
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
onReady,
}: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
const fullscreenDimensions = { width: globalThis.innerWidth || 0, height: globalThis.innerHeight || 0 };
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
@@ -144,6 +158,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
viewer.addEventListener('ready', () => onReady?.(), { once: true });
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel is 0-100
@@ -175,4 +190,19 @@
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
<div
id="sphere"
class="h-full w-full h-dvh w-dvw mb-0"
bind:this={container}
style:view-transition-name={transitionName}
></div>
<!-- Zero-sized letterboxes for view transitions from/to regular photos -->
<Letterboxes
{transitionName}
slideshowState={SlideshowState.None}
slideshowLook={SlideshowLook.Contain}
hasThumbhash={false}
scaledDimensions={fullscreenDimensions}
container={fullscreenDimensions}
/>

View File

@@ -1,68 +1,42 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, untrack } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import type { AssetCursor } from './asset-viewer.svelte';
interface Props {
cursor: AssetCursor;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
element?: HTMLDivElement;
sharedLink?: SharedLinkResponseDto;
transitionName?: string;
onReady?: () => void;
onError?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
}
let {
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
}: Props = $props();
let { cursor, element = $bindable(), sharedLink, transitionName, onReady, onError, onSwipe }: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
const asset = $derived(cursor.current);
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let loader = $state<HTMLImageElement>();
assetViewerManager.zoomState = {
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
};
onDestroy(() => {
$boundingBoxesArray = [];
});
@@ -110,29 +84,11 @@
handlePromiseError(onCopy());
};
const onSwipe = (event: SwipeCustomEvent) => {
if (assetViewerManager.zoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
};
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
let currentPreviewUrl = $state<string>();
$effect(() => {
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
if (currentPreviewUrl) {
void cast(currentPreviewUrl);
}
});
@@ -150,35 +106,18 @@
}
};
const onload = () => {
imageLoaded = true;
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
};
const onerror = () => {
imageError = imageLoaded = true;
};
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
);
let containerWidth = $state(0);
let containerHeight = $state(0);
let lastUrl: string | undefined;
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let swipeFeedbackReset = $state<(() => void) | undefined>();
$effect(() => {
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
});
}
lastUrl = imageLoaderUrl;
// Reset swipe feedback when asset changes
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
untrack(() => swipeFeedbackReset?.());
});
</script>
@@ -192,46 +131,35 @@
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]}
/>
{#if imageError}
<div id="broken-asset" class="h-full w-full">
<BrokenAsset class="text-xl h-full w-full" />
</div>
{/if}
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
<div
bind:this={element}
<SwipeFeedback
bind:element
class="relative h-full w-full select-none"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
disabled={isOcrActive || assetViewerManager.zoom > 1}
disableSwipeLeft={!cursor.nextAsset}
disableSwipeRight={!cursor.previousAsset}
bind:reset={swipeFeedbackReset}
{onSwipe}
>
{#if !imageLoaded}
<div id="spinner" class="flex h-full items-center justify-center">
<LoadingSpinner />
</div>
{:else if !imageError}
<div
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
<img
src={imageLoaderUrl}
alt=""
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
draggable="false"
/>
{/if}
<img
bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}"
draggable="false"
/>
<AdaptiveImage
{asset}
{sharedLink}
{container}
zoomDisabled={isOcrActive}
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
onImageReady={() => onReady?.()}
onError={() => {
onError?.();
onReady?.();
}}
bind:imgElement={assetViewerManager.imgRef}
{transitionName}
>
{#snippet overlays()}
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
<div
@@ -243,23 +171,38 @@
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{/snippet}
</AdaptiveImage>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
</div>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>
{#snippet leftPreview()}
{#if cursor.previousAsset}
<AdaptiveImage
asset={cursor.previousAsset}
{sharedLink}
{container}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if cursor.nextAsset}
<AdaptiveImage
asset={cursor.nextAsset}
{sharedLink}
{container}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>

View File

@@ -0,0 +1,395 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { Snippet } from 'svelte';
import { onDestroy, onMount } from 'svelte';
interface Props {
disabled?: boolean;
disableSwipeLeft?: boolean;
disableSwipeRight?: boolean;
onSwipeEnd?: (offsetX: number) => void;
onSwipeMove?: (offsetX: number) => void;
onSwipe?: (direction: 'left' | 'right') => void;
swipeThreshold?: number;
class?: string;
transitionName?: string;
element?: HTMLDivElement;
clientWidth?: number;
clientHeight?: number;
reset?: () => void;
children: Snippet;
leftPreview?: Snippet;
rightPreview?: Snippet;
}
let {
disabled = false,
disableSwipeLeft = false,
disableSwipeRight = false,
onSwipeEnd,
onSwipeMove,
onSwipe,
swipeThreshold = 45,
class: className = '',
transitionName,
element = $bindable(),
clientWidth = $bindable(),
clientHeight = $bindable(),
reset = $bindable(),
children,
leftPreview,
rightPreview,
}: Props = $props();
interface SwipeAnimations {
currentImageAnimation: Animation;
previewAnimation: Animation | null;
abortController: AbortController;
}
const ANIMATION_DURATION_MS = 300;
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
const MIN_PROGRESS_THRESHOLD = 0.25;
const ENABLE_SCALE_ANIMATION = false;
let contentElement: HTMLElement | undefined = $state();
let leftPreviewContainer: HTMLDivElement | undefined = $state();
let rightPreviewContainer: HTMLDivElement | undefined = $state();
let isDragging = $state(false);
let startX = $state(0);
let currentOffsetX = $state(0);
let dragStartTime: number | null = $state(null);
let leftAnimations: SwipeAnimations | null = $state(null);
let rightAnimations: SwipeAnimations | null = $state(null);
let isSwipeInProgress = $state(false);
const cursorStyle = $derived(disabled ? '' : isSwipeInProgress ? 'wait' : isDragging ? 'grabbing' : 'grab');
const isValidPointerEvent = (event: PointerEvent) =>
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
if (!contentElement) {
return null;
}
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
const sign = direction === 'left' ? -1 : 1;
if (isPreview) {
return [
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
];
}
return [
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
];
};
contentElement.style.transformOrigin = 'center';
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
// Preview slides in from opposite side of swipe direction
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
let previewAnimation: Animation | null = null;
if (previewContainer) {
previewContainer.style.transformOrigin = 'center';
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
duration: ANIMATION_DURATION_MS,
easing: 'linear',
fill: 'both',
});
}
currentImageAnimation.pause();
previewAnimation?.pause();
const abortController = new AbortController();
return { currentImageAnimation, previewAnimation, abortController };
};
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
animations.currentImageAnimation.currentTime = time;
if (animations.previewAnimation) {
animations.previewAnimation.currentTime = time;
}
};
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
animations.currentImageAnimation.playbackRate = playbackRate;
if (animations.previewAnimation) {
animations.previewAnimation.playbackRate = playbackRate;
}
animations.currentImageAnimation.play();
animations.previewAnimation?.play();
};
const cancelAnimations = (animations: SwipeAnimations | null) => {
if (!animations) {
return;
}
animations.abortController.abort();
animations.currentImageAnimation.cancel();
animations.previewAnimation?.cancel();
};
const handlePointerDown = (event: PointerEvent) => {
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
return;
}
startDrag(event);
event.preventDefault();
};
const startDrag = (event: PointerEvent) => {
if (!element) {
return;
}
isDragging = true;
startX = event.clientX;
currentOffsetX = 0;
element.setPointerCapture(event.pointerId);
dragStartTime = Date.now();
};
const handlePointerMove = (event: PointerEvent) => {
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
return;
}
const rawOffsetX = event.clientX - startX;
const direction = rawOffsetX < 0 ? 'left' : 'right';
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
currentOffsetX = 0;
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
return;
}
currentOffsetX = rawOffsetX;
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
if (direction === 'left') {
if (!leftAnimations) {
leftAnimations = createSwipeAnimations('left');
}
if (leftAnimations) {
setAnimationTime(leftAnimations, animationTime);
}
if (rightAnimations) {
cancelAnimations(rightAnimations);
rightAnimations = null;
}
} else {
if (!rightAnimations) {
rightAnimations = createSwipeAnimations('right');
}
if (rightAnimations) {
setAnimationTime(rightAnimations, animationTime);
}
if (leftAnimations) {
cancelAnimations(leftAnimations);
leftAnimations = null;
}
}
onSwipeMove?.(currentOffsetX);
event.preventDefault(); // Prevent scrolling during drag
};
const handlePointerUp = (event: PointerEvent) => {
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
return;
}
isDragging = false;
if (element.hasPointerCapture(event.pointerId)) {
element.releasePointerCapture(event.pointerId);
}
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
if (
Math.abs(currentOffsetX) < swipeThreshold ||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
) {
resetPosition();
return;
}
isSwipeInProgress = true;
onSwipeEnd?.(currentOffsetX);
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
};
const resetPosition = () => {
if (!contentElement) {
return;
}
const direction = currentOffsetX < 0 ? 'left' : 'right';
const animations = direction === 'left' ? leftAnimations : rightAnimations;
if (!animations) {
currentOffsetX = 0;
return;
}
playAnimation(animations, -1);
const handleFinish = () => {
cancelAnimations(animations);
if (direction === 'left') {
leftAnimations = null;
} else {
rightAnimations = null;
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
signal: animations.abortController.signal,
});
currentOffsetX = 0;
};
const completeTransition = (direction: 'left' | 'right') => {
if (!contentElement) {
return;
}
const animations = direction === 'left' ? leftAnimations : rightAnimations;
if (!animations) {
return;
}
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
onSwipe?.(direction);
return;
}
playAnimation(animations, 1);
const handleFinish = () => {
if (contentElement) {
onSwipe?.(direction);
}
};
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
signal: animations.abortController.signal,
});
};
const resetPreviewContainers = () => {
cancelAnimations(leftAnimations);
cancelAnimations(rightAnimations);
leftAnimations = null;
rightAnimations = null;
if (contentElement) {
contentElement.style.transform = '';
contentElement.style.transition = '';
contentElement.style.opacity = '';
}
currentOffsetX = 0;
};
const finishSwipeInProgress = () => {
isSwipeInProgress = false;
};
const resetSwipeFeedback = () => {
resetPreviewContainers();
finishSwipeInProgress();
};
reset = resetSwipeFeedback;
onMount(() =>
eventManager.onMany({
ViewerFinishNavigate: finishSwipeInProgress,
ResetSwipeFeedback: resetSwipeFeedback,
}),
);
onDestroy(() => {
resetSwipeFeedback();
if (element) {
element.style.cursor = '';
}
if (contentElement) {
contentElement.style.cursor = '';
}
});
</script>
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
bind:this={element}
bind:clientWidth
bind:clientHeight
class={className}
style:cursor={cursorStyle}
style:view-transition-name={transitionName}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="presentation"
>
{#if leftPreview}
<!-- Swiping right reveals left preview -->
<div
bind:this={leftPreviewContainer}
class="absolute inset-0"
style:pointer-events="none"
style:display={rightAnimations ? 'block' : 'none'}
>
{@render leftPreview()}
</div>
{/if}
{#if rightPreview}
<!-- Swiping left reveals right preview -->
<div
bind:this={rightPreviewContainer}
class="absolute inset-0"
style:pointer-events="none"
style:display={leftAnimations ? 'block' : 'none'}
>
{@render rightPreview()}
</div>
{/if}
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
{@render children()}
</div>
</div>

View File

@@ -1,5 +1,8 @@
<script lang="ts">
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import VideoRemoteViewer from '$lib/components/asset-viewer/video-remote-viewer.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
@@ -10,56 +13,90 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { onDestroy, onMount, untrack } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
transitionName?: string;
cursor: AssetCursor;
assetId?: string;
sharedLink?: SharedLinkResponseDto;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onClose?: () => void;
onReady?: () => void;
}
let {
transitionName,
cursor,
assetId,
sharedLink,
loopVideo,
cacheKey,
playOriginalVideo,
onPreviousAsset = () => {},
onNextAsset = () => {},
onSwipe,
onVideoEnded = () => {},
onVideoStarted = () => {},
onClose = () => {},
onReady,
}: Props = $props();
const asset = $derived(cursor.current);
const previousAsset = $derived(cursor.previousAsset);
const nextAsset = $derived(cursor.nextAsset);
const effectiveAssetId = $derived(assetId ?? asset.id);
const { slideshowState, slideshowLook } = slideshowStore;
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
? getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: effectiveAssetId, cacheKey }),
);
let previousAssetFileUrl = $state<string | undefined>();
let isScrubbing = $state(false);
let showVideo = $state(false);
let containerWidth = $state(document.documentElement.clientWidth);
let containerHeight = $state(document.documentElement.clientHeight);
const exifDimensions = $derived(
asset?.exifInfo?.exifImageHeight && asset?.exifInfo.exifImageHeight
? (getDimensions(asset.exifInfo) as { width: number; height: number })
: null,
);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let dimensions = $derived(exifDimensions ?? { width: 1, height: 1 });
const scaledDimensions = $derived(scaleToFit(dimensions, container));
onMount(() => {
// Show video after mount to ensure fading in.
showVideo = true;
});
$effect(() => {
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
videoPlayer?.load();
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
previousAssetFileUrl = assetFileUrl;
untrack(() => {
isLoading = true;
videoPlayer?.load();
});
}
});
@@ -69,6 +106,14 @@
}
});
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
onReady?.();
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@@ -100,76 +145,119 @@
}
};
const onSwipe = (event: SwipeCustomEvent) => {
if (event.detail.direction === 'left') {
onNextAsset();
}
if (event.detail.direction === 'right') {
onPreviousAsset();
}
};
let containerWidth = $state(0);
let containerHeight = $state(0);
$effect(() => {
if (isFaceEditMode.value) {
videoPlayer?.pause();
}
});
const calculateSize = () => {
const { width, height } = scaledDimensions;
const size = {
width: width + 'px',
height: height + 'px',
};
return size;
};
const box = $derived(calculateSize());
</script>
{#if showVideo}
<div
transition:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full select-none place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<video
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
class="h-full object-contain"
{...useSwipe(onSwipe)}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
<SwipeFeedback
class="flex select-none h-full w-full place-content-center place-items-center"
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
{onSwipe}
>
{#if showVideo}
<div
in:fade={{ duration: assetViewerFadeDuration }}
class="flex h-full w-full place-content-center place-items-center"
>
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
/>
</div>
{:else}
<div class="relative">
<video
style:view-transition-name={transitionName}
style:height={box.height}
style:width={box.width}
bind:this={videoPlayer}
loop={$loopVideoPreference && loopVideo}
autoplay={$autoPlayVideo}
playsinline
controls
disablePictureInPicture
onloadedmetadata={() => handleLoadedMetadata()}
oncanplay={(e) => handleCanPlay(e.currentTarget)}
onended={onVideoEnded}
onvolumechange={(e) => ($videoViewerMuted = e.currentTarget.muted)}
onseeking={() => (isScrubbing = true)}
onseeked={() => (isScrubbing = false)}
onplaying={(e) => {
e.currentTarget.focus();
}}
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetMediaUrl({ id: effectiveAssetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
{#if isLoading}
<div class="absolute inset-0 flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} assetId={effectiveAssetId} />
{/if}
</div>
{/if}
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
</div>
{/if}
{#snippet leftPreview()}
{#if previousAsset}
<AdaptiveImage
asset={previousAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
</div>
{/if}
{/snippet}
{#snippet rightPreview()}
{#if nextAsset}
<AdaptiveImage
asset={nextAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{/if}
{/snippet}
</SwipeFeedback>
<style>
video:focus {
outline: none;
}
</style>

View File

@@ -3,13 +3,14 @@
import type { AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string;
asset: AssetResponseDto;
onReady?: () => void;
}
const { asset }: Props = $props();
const { asset, transitionName, onReady }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@@ -19,16 +20,18 @@
]);
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
<div class="flex h-full select-none place-content-center place-items-center">
{#await modules}
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
{transitionName}
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
plugins={[videoPlugin]}
{adapter}
navbar
{onReady}
/>
{:catch}
{$t('errors.failed_to_load_asset')}

View File

@@ -1,52 +1,58 @@
<script lang="ts">
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto } from '@immich/sdk';
import type { SharedLinkResponseDto } from '@immich/sdk';
interface Props {
asset: AssetResponseDto;
transitionName?: string;
cursor: AssetCursor;
assetId?: string;
sharedLink?: SharedLinkResponseDto;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
playOriginalVideo: boolean;
onClose?: () => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onReady?: () => void;
}
let {
asset,
transitionName,
cursor,
assetId,
sharedLink,
projectionType,
cacheKey,
loopVideo,
playOriginalVideo,
onPreviousAsset,
onSwipe,
onClose,
onNextAsset,
onVideoEnded,
onVideoStarted,
onReady,
}: Props = $props();
const effectiveAssetId = $derived(assetId ?? asset.id);
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {asset} />
<VideoPanoramaViewer {transitionName} asset={cursor.current} {onReady} />
{:else}
<VideoNativeViewer
{transitionName}
{loopVideo}
{cacheKey}
assetId={effectiveAssetId}
{cursor}
{assetId}
{sharedLink}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}
{onSwipe}
{onVideoEnded}
{onVideoStarted}
{onClose}
{onReady}
/>
{/if}

View File

@@ -2,24 +2,34 @@
import { Icon } from '@immich/ui';
import { mdiImageBrokenVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: string;
class?: ClassValue;
hideMessage?: boolean;
width?: string | undefined;
height?: string | undefined;
}
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
let clientWidth = $state(0);
let textClass = $derived(clientWidth < 100 ? 'text-xs' : clientWidth < 150 ? 'text-sm' : 'text-base');
</script>
<div
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
bind:clientWidth
class={[
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
className,
]}
style:width
style:height
>
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
{#if clientWidth >= 75}
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
{/if}
{#if !hideMessage}
<span class="text-center">{$t('error_loading_image')}</span>
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
{/if}
</div>

View File

@@ -1,9 +1,8 @@
<script lang="ts">
import { imageLoader } from '$lib/actions/image-loader.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
import type { ClassValue } from 'svelte/elements';
interface Props {
@@ -49,53 +48,46 @@
loaded = true;
onComplete?.(false);
};
const setErrored = () => {
errored = true;
onComplete?.(true);
};
function mount(elem: HTMLImageElement): ActionReturn {
if (elem.complete) {
loaded = true;
onComplete?.(false);
}
return {
destroy: () => imageManager.cancelPreloadUrl(url),
};
}
let optionalClasses = $derived(
let optionalClasses = $derived([
[
curve && 'rounded-xl',
circle && 'rounded-full',
shadow && 'shadow-lg',
(circle || !heightStyle) && 'aspect-square',
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
brokenAssetClass,
]
.filter(Boolean)
.join(' '),
brokenAssetClass,
]);
let style = $derived(
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
);
</script>
{#if errored}
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
{:else}
<img
use:mount
onload={setLoaded}
onerror={setErrored}
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={loaded || errored ? altText : ''}
{title}
class={['object-cover', optionalClasses, imageClass]}
draggable="false"
loading={preload ? 'eager' : 'lazy'}
/>
<div
use:imageLoader={{
src: url,
onLoad: setLoaded,
onError: setErrored,
imgClass: ['object-cover', imageClass],
style,
alt: loaded || errored ? altText : '',
draggable: false,
title,
loading: preload ? 'eager' : 'lazy',
}}
></div>
{/if}
{#if hidden}

View File

@@ -200,8 +200,9 @@
<div
class={[
'focus-visible:outline-none flex overflow-hidden',
'group focus-visible:outline-none focus-visible:rounded-lg flex overflow-hidden',
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
{ 'rounded-xl': selected },
]}
style:width="{width}px"
style:height="{height}px"
@@ -223,18 +224,10 @@
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
data-selected={selected ? true : undefined}
tabindex={0}
role="link"
>
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
{ 'rounded-xl': selected },
]}
data-outline
></div>
<div
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit"
@@ -247,13 +240,81 @@
{ 'rounded-xl': selected },
]}
>
<ImageThumbnail
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
<VideoThumbnail
class="group-focus-visible:rounded-lg"
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
curve={selected}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
/>
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
</span>
</div>
</div>
{/if}
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
<!-- icon overlay -->
<div>
<!-- Gradient overlay on hover -->
{#if !usingMobileDevice && !disabled}
<div
class={[
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
]}
></div>
@@ -261,7 +322,10 @@
<!-- Dimmed support -->
{#if dimmed && !mouseOver}
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
<div
id="a"
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
></div>
{/if}
<!-- Favorite asset star -->
@@ -329,72 +393,6 @@
>
</a>
{/if}
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
/>
{#if asset.isVideo}
<div class="absolute top-0 h-full w-full pointer-events-none">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
curve={selected}
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.livePhotoVideoId}
<div class="absolute top-0 h-full w-full pointer-events-none">
<VideoThumbnail
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
pauseIcon={mdiMotionPauseOutline}
playIcon={mdiMotionPlayOutline}
showTime={false}
curve={selected}
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
curve={selected}
/>
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
</span>
</div>
</div>
{/if}
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
data-testid="thumbhash"
class="absolute top-0 object-cover"
style:width="{width}px"
style:height="{height}px"
class:rounded-xl={selected}
draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
></canvas>
{/if}
</div>
{#if selectionCandidate}
@@ -427,11 +425,14 @@
{/if}
</button>
{/if}
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:rounded-lg group-focus-visible:outline-2 group-focus-visible:-outline-offset-2',
{ 'rounded-xl': selected },
]}
data-outline
></div>
</div>
</div>
<style>
[data-asset]:focus > [data-outline] {
outline-style: solid;
}
</style>

View File

@@ -2,6 +2,7 @@
import { Icon, LoadingSpinner } from '@immich/ui';
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
import { Duration } from 'luxon';
import type { ClassValue } from 'svelte/elements';
interface Props {
url: string;
@@ -12,6 +13,7 @@
curve?: boolean;
playIcon?: string;
pauseIcon?: string;
class?: ClassValue;
}
let {
@@ -23,6 +25,7 @@
curve = false,
playIcon = mdiPlayCircleOutline,
pauseIcon = mdiPauseCircleOutline,
class: className,
}: Props = $props();
let remainingSeconds = $state(durationInSeconds);
@@ -57,7 +60,7 @@
{#if enablePlayback}
<video
bind:this={player}
class="h-full w-full object-cover"
class={['h-full w-full object-cover', className]}
class:rounded-xl={curve}
muted
autoplay

View File

@@ -6,10 +6,11 @@
import { useActions, type ActionArray } from '$lib/actions/use-actions';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
import { appManager } from '$lib/managers/app-manager.svelte';
import type { HeaderButtonActionItem } from '$lib/types';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
import type { Snippet } from 'svelte';
import { type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -44,12 +45,17 @@
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
let isAssetViewer = $derived(appManager.isAssetViewer);
</script>
<header>
{#if !hideNavbar}
{#if !hideNavbar && !isAssetViewer}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header>
<div
tabindex="-1"
@@ -58,13 +64,15 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>
{#if sidebar}
{#if isAssetViewer}
<div></div>
{:else if sidebar}
{@render sidebar()}
{:else}
<UserSidebar />
{/if}
<main class="relative">
<main class="relative w-full">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>

View File

@@ -1,20 +1,14 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
type Props = {
animationTargetAssetId?: string | null;
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
@@ -26,10 +20,7 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const { animationTargetAssetId, viewerAssets, width, height, thumbnail, customThumbnailLayout }: Props = $props();
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
@@ -41,18 +32,20 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
data-transition-name={transitionName}
style:view-transition-name={transitionName}
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<!-- animate:flip={{ duration: transitionDuration }} -->
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>

View File

@@ -1,34 +1,37 @@
<script lang="ts">
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
import { onMount, tick, type Snippet } from 'svelte';
type Props = {
toAssetViewerTransitionId?: string | null;
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
toAssetViewerTransitionId,
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
@@ -51,6 +54,32 @@
});
return getDateLocaleString(date);
};
let toTimelineTransitionAssetId = $state<string | null>(null);
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
const transitionToTimelineCallback = ({ id }: { id: string }) => {
const asset = monthGroup.findAssetById({ id });
if (!asset) {
return;
}
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('TimelineLoaded', (event: { id: string | null }) => {
toTimelineTransitionAssetId = event.id;
void tick().then(resolve);
});
}),
['timeline'],
() => {
toTimelineTransitionAssetId = null;
focusAsset(asset.id);
},
);
};
if (viewTransitionManager.isSupported()) {
onMount(() => eventManager.on('TransitionToTimeline', transitionToTimelineCallback));
}
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
@@ -95,7 +124,7 @@
</div>
<AssetLayout
{manager}
{animationTargetAssetId}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}

View File

@@ -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

View File

@@ -12,6 +12,8 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -28,7 +30,6 @@
import { DateTime } from 'luxon';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
isSelectionMode?: boolean;
singleSelect?: boolean;
@@ -104,6 +105,7 @@
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0);
let toAssetViewerTransitionId = $state<string | null>(null);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mediaQueryManager.maxMd);
@@ -211,7 +213,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = getScrollTarget();
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -223,7 +225,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = false;
invisible = isAssetViewerRoute(page) ? true : false;
};
// note: only modified once in afterNavigate()
@@ -241,10 +243,13 @@
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
});
const getScrollTarget = () => {
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
};
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
// after successful navigation.
afterNavigate(({ complete }) => {
void complete.finally(() => {
void complete.finally(async () => {
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
@@ -253,8 +258,13 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
});
});
@@ -264,7 +274,7 @@
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
if (!enableRouting && !isAssetViewerRoute(page)) {
invisible = false;
}
});
@@ -561,19 +571,6 @@
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
};
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -604,6 +601,7 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -683,11 +681,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -701,12 +699,56 @@
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
onClick={async (asset) => {
const onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const dispatchClick = () => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, onClick);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
};
const hasThumbnailClick = typeof onThumbnailClick === 'function';
const selectingAssets = isSelectionMode || assetInteraction.selectionActive;
if (!viewTransitionManager.isSupported() || hasThumbnailClick || selectingAssets) {
dispatchClick();
return;
}
// tag target on the 'old' snapshot
toAssetViewerTransitionId = asset.id;
await tick();
eventManager.once('StartViewTransition', () => {
toAssetViewerTransitionId = null;
dispatchClick();
});
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => {
void tick().then(() => {
eventManager.emit('TransitionToAssetViewer');
resolve();
});
});
}),
['viewer'],
);
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {

View File

@@ -4,6 +4,7 @@
import { AssetAction } from '$lib/constants';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
@@ -97,6 +98,10 @@
};
const handleClose = async (asset: { id: string }) => {
const awaitInit = new Promise<void>((resolve) => eventManager.once('StartViewTransition', resolve));
eventManager.emit('TransitionToTimeline', { id: asset.id });
await awaitInit;
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });

View File

@@ -20,11 +20,8 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
};
</script>

View File

@@ -49,6 +49,7 @@
$locale = newLocale;
}
};
let editedLocale = $derived(findLocale($locale).code);
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
let selectedOption = $derived({

View File

@@ -4,19 +4,46 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
type AllAssetMediaSize = AssetMediaSize | 'all';
type AssetLoadState = 'loading' | 'cancelled';
class ImageManager {
// track recently canceled assets, so know if an load "error" is due to
// cancelation
private assetStates = new Map<string, AssetLoadState>();
private readonly MAX_TRACKED_ASSETS = 10;
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
// Remove if exists to reset insertion order
this.assetStates.delete(asset.id);
this.assetStates.set(asset.id, action);
// Only keep recent assets (Map maintains insertion order)
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
const firstKey = this.assetStates.keys().next().value!;
this.assetStates.delete(firstKey);
}
}
isCanceled(asset: AssetResponseDto) {
return 'cancelled' === this.assetStates.get(asset.id);
}
trackLoad(asset: AssetResponseDto) {
this.trackAction(asset, 'loading');
}
trackCancelled(asset: AssetResponseDto) {
this.trackAction(asset, 'cancelled');
}
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (!url) {
return;
}
const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
this.trackLoad(asset);
const img = new Image();
img.src = url;
img.src = src;
}
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
@@ -24,6 +51,8 @@ class ImageManager {
return;
}
this.trackCancelled(asset);
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
for (const size of sizes) {
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
@@ -32,12 +61,6 @@ class ImageManager {
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();

View File

@@ -0,0 +1,127 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function traceTransitionEvents(msg: string, error?: unknown) {
// console.log(msg, error);
}
class ViewTransitionManager {
#activeViewTransition = $state<ViewTransition | null>(null);
#finishedCallbacks: (() => void)[] = [];
#splitViewerNavTransitionNames = true;
constructor() {
const root = document.documentElement;
const value = getComputedStyle(root).getPropertyValue('--immich-split-viewer-nav').trim();
this.#splitViewerNavTransitionNames = value === 'enabled';
}
getTransitionName = (kind: 'old' | 'new', name: string | null | undefined) => {
if (name === 'previous' || name === 'next') {
return this.#splitViewerNavTransitionNames ? name + '-' + kind : name;
} else if (name) {
return name;
}
return undefined;
};
get activeViewTransition() {
return this.#activeViewTransition;
}
isSupported() {
return 'startViewTransition' in document;
}
skipTransitions() {
const skippedTransitions = !!this.#activeViewTransition;
this.#activeViewTransition?.skipTransition();
this.#notifyFinished();
return skippedTransitions;
}
startTransition(domUpdateComplete: Promise<unknown>, types?: string[], finishedCallback?: () => unknown) {
if (!this.isSupported()) {
throw new Error('View transition API not available');
}
if (this.#activeViewTransition) {
traceTransitionEvents('Can not start transition - one already active');
return;
}
// good time to add view-transition-name styles (if needed)
traceTransitionEvents('emit BeforeStartViewTransition');
eventManager.emit('BeforeStartViewTransition');
// next call will create the 'old' view snapshot
let transition: ViewTransition;
try {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition({
update: async () => {
// Good time to remove any view-transition-name styles created during
// BeforeStartViewTransition, then trigger the actual view transition.
traceTransitionEvents('emit StartViewTransition');
eventManager.emit('StartViewTransition');
await domUpdateComplete;
traceTransitionEvents('awaited domUpdateComplete');
},
types,
});
} catch {
// eslint-disable-next-line tscompat/tscompat
transition = document.startViewTransition(async () => {
// Good time to remove any view-transition-name styles created during
// BeforeStartViewTransition, then trigger the actual view transition.
traceTransitionEvents('emit StartViewTransition');
eventManager.emit('StartViewTransition');
await domUpdateComplete;
traceTransitionEvents('awaited domUpdateComplete');
});
}
this.#activeViewTransition = transition;
this.#finishedCallbacks.push(() => {
this.#activeViewTransition = null;
});
if (finishedCallback) {
this.#finishedCallbacks.push(finishedCallback);
}
// UpdateCallbackDone is a good time to add any view-transition-name styles
// to the new DOM state, before the 'new' view snapshot is creatd
// eslint-disable-next-line tscompat/tscompat
transition.updateCallbackDone
.then(() => {
traceTransitionEvents('emit UpdateCallbackDone');
eventManager.emit('UpdateCallbackDone');
})
.catch((error: unknown) => traceTransitionEvents('error in UpdateCallbackDone', error));
// Both old/new snapshots are taken - pseudo elements are created, transition is
// about to start
// eslint-disable-next-line tscompat/tscompat
transition.ready
.then(() => eventManager.emit('Ready'))
.catch((error: unknown) => {
this.#notifyFinished();
traceTransitionEvents('error in Ready', error);
});
// Transition is complete
// eslint-disable-next-line tscompat/tscompat
transition.finished
.then(() => {
traceTransitionEvents('emit Finished');
eventManager.emit('Finished');
})
.catch((error: unknown) => traceTransitionEvents('error in Finished', error));
// eslint-disable-next-line tscompat/tscompat
void transition.finished.then(() => this.#notifyFinished());
}
#notifyFinished() {
for (const callback of this.#finishedCallbacks) {
callback();
}
this.#finishedCallbacks = [];
}
}
export const viewTransitionManager = new ViewTransitionManager();

View File

@@ -0,0 +1,13 @@
class AppManager {
#isAssetViewer = $state<boolean>(false);
set isAssetViewer(value: boolean) {
this.#isAssetViewer = value;
}
get isAssetViewer() {
return this.#isAssetViewer;
}
}
export const appManager = new AppManager();

View File

@@ -5,6 +5,14 @@ import type { ZoomImageWheelState } from '@zoom-image/core';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
const createDefaultZoomState = (): ZoomImageWheelState => ({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
@@ -12,13 +20,7 @@ export type Events = {
};
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state<ZoomImageWheelState>({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
#zoomState = $state(createDefaultZoomState());
imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false);
@@ -67,6 +69,10 @@ export class AssetViewerManager extends BaseEventManager<Events> {
this.#zoomState = state;
}
resetZoomState() {
this.zoomState = createDefaultZoomState();
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;

View File

@@ -21,6 +21,10 @@ import type {
export type Events = {
AppInit: [];
ResetSwipeFeedback: [];
ViewerFinishNavigate: [];
AssetViewerAfterNavigate: [];
AuthLogin: [LoginResponseDto];
AuthLogout: [];
AuthUserLoaded: [UserAdminResponseDto];
@@ -37,6 +41,7 @@ export type Events = {
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumUpdate: [AlbumResponseDto];
@@ -74,6 +79,19 @@ export type Events = {
SessionLocked: [];
TransitionToTimeline: [{ id: string }];
TimelineLoaded: [{ id: string | null }];
TransitionToAssetViewer: [];
AssetViewerLoaded: [];
AssetViewerFree: [];
BeforeStartViewTransition: [];
Finished: [];
Ready: [];
UpdateCallbackDone: [];
StartViewTransition: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { FormModal, Icon } from '@immich/ui';
@@ -9,7 +10,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: (success?: true) => void;
onClose: () => void;
assetIds: string[];
}
@@ -30,8 +31,8 @@
return;
}
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
};
const handleSelect = async (option?: ComboBoxOption) => {

View File

@@ -2,6 +2,7 @@ import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
@@ -41,6 +42,7 @@ import {
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiShareVariantOutline,
mdiTagPlusOutline,
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
@@ -49,6 +51,7 @@ import { get } from 'svelte/store';
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const currentAuthUser = get(authUser);
const userPreferences = get(preferences);
const isOwner = !!(currentAuthUser && currentAuthUser.id === asset.ownerId);
const Share: ActionItem = {
@@ -155,7 +158,16 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
type: $t('assets'),
$if: () => asset.hasMetadata,
onAction: () => assetViewerManager.toggleDetailPanel(),
shortcuts: [{ key: 'i' }],
shortcuts: { key: 'i' },
};
const Tag: ActionItem = {
title: $t('add_tag'),
icon: mdiTagPlusOutline,
type: $t('assets'),
$if: () => userPreferences.tags.enabled,
onAction: () => modalManager.show(AssetTagModal, { assetIds: [asset.id] }),
shortcuts: { key: 't' },
};
const Edit: ActionItem = {
@@ -212,6 +224,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
ZoomIn,
ZoomOut,
Copy,
Tag,
Edit,
RefreshFacesJob,
RefreshMetadataJob,

View File

@@ -67,6 +67,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
color: 'danger',
onAction: () => handleDeleteLibrary(library),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const AddFolder: ActionItem = {

View File

@@ -67,6 +67,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
$if: () => get(authUser).id !== user.id && !user.deletedAt,
onAction: () => modalManager.show(UserDeleteConfirmModal, { user }),
shortcuts: { key: 'Backspace' },
shortcutOptions: { ignoreInputFields: true },
};
const getDeleteDate = (deletedAt: string): Date =>

View File

@@ -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,
};
}

View File

@@ -1,3 +1,4 @@
import { writable } from 'svelte/store';
export const photoViewerImgElement = writable<HTMLImageElement>();
export const isSelectingAllAssets = writable(false);

View File

@@ -200,13 +200,10 @@ export const getAssetUrl = ({
sharedLink,
forceOriginal = false,
}: {
asset: AssetResponseDto | undefined;
asset: AssetResponseDto;
sharedLink?: SharedLinkResponseDto;
forceOriginal?: boolean;
}) => {
if (!asset) {
return;
}
const id = asset.id;
const cacheKey = asset.thumbhash;
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {

View File

@@ -0,0 +1,247 @@
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetMediaUrl, getAssetUrl } from '$lib/utils';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
/**
* Quality levels for progressive image loading
*/
type ImageQuality =
| 'basic'
| 'loading-thumbnail'
| 'thumbnail'
| 'loading-preview'
| 'preview'
| 'loading-original'
| 'original';
export interface ImageLoaderState {
previewUrl?: string;
thumbnailUrl?: string;
originalUrl?: string;
quality: ImageQuality;
hasError: boolean;
thumbnailImage: ImageStatus;
previewImage: ImageStatus;
originalImage: ImageStatus;
}
enum ImageStatus {
Unloaded = 'Unloaded',
Success = 'Success',
Error = 'Error',
}
/**
* Coordinates adaptive loading of a single asset image:
* thumbhash → thumbnail → preview → original (on zoom)
*
*/
export class AdaptiveImageLoader {
private state = $state<ImageLoaderState>({
quality: 'basic',
hasError: false,
thumbnailImage: ImageStatus.Unloaded,
previewImage: ImageStatus.Unloaded,
originalImage: ImageStatus.Unloaded,
});
private readonly currentZoomFn?: () => number;
private readonly imageLoader?: LoadImageFunction;
private readonly destroyFunctions: (() => void)[] = [];
readonly thumbnailUrl: string;
readonly previewUrl: string;
readonly originalUrl: string;
readonly asset: AssetResponseDto;
readonly callbacks?: {
currentZoomFn: () => number;
onImageReady?: () => void;
onError?: () => void;
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
};
destroyed = false;
constructor(
asset: AssetResponseDto,
sharedLink: SharedLinkResponseDto | undefined,
callbacks?: {
currentZoomFn: () => number;
onImageReady?: () => void;
onError?: () => void;
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
},
imageLoader?: LoadImageFunction,
) {
imageManager.trackLoad(asset);
this.asset = asset;
this.callbacks = callbacks;
this.imageLoader = imageLoader;
this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail });
this.previewUrl = getAssetUrl({ asset, sharedLink });
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
this.state.thumbnailUrl = this.thumbnailUrl;
}
start() {
if (!this.imageLoader) {
throw new Error('Start requires imageLoader to be specified');
}
this.destroyFunctions.push(
this.imageLoader(
this.thumbnailUrl,
{},
() => this.onThumbnailLoad(),
() => this.onThumbnailError(),
() => this.onThumbnailStart(),
),
);
}
get adaptiveLoaderState(): ImageLoaderState {
return this.state;
}
onThumbnailStart() {
if (this.destroyed) {
return;
}
this.state.quality = 'loading-thumbnail';
}
onThumbnailLoad() {
if (this.destroyed) {
return;
}
this.state.quality = 'thumbnail';
this.state.thumbnailImage = ImageStatus.Success;
this.callbacks?.onImageReady?.();
this.callbacks?.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail');
this.triggerMainImage();
}
onThumbnailError() {
if (this.destroyed) {
return;
}
this.state.hasError = true;
this.state.thumbnailUrl = undefined;
this.state.thumbnailImage = ImageStatus.Error;
this.callbacks?.onError?.();
this.triggerMainImage();
}
triggerMainImage() {
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
}
triggerPreview() {
if (!this.previewUrl) {
// no preview, try original?
this.triggerOriginal();
return false;
}
this.state.hasError = false;
this.state.previewUrl = this.previewUrl;
if (this.imageLoader) {
this.destroyFunctions.push(
this.imageLoader(
this.previewUrl,
{},
() => this.onPreviewLoad(),
() => this.onPreviewError(),
() => this.onPreviewStart(),
),
);
}
}
onPreviewStart() {
if (this.destroyed) {
return;
}
this.state.quality = 'loading-preview';
}
onPreviewLoad() {
if (this.destroyed) {
return;
}
this.state.quality = 'preview';
this.state.previewImage = ImageStatus.Success;
this.callbacks?.onImageReady?.();
this.callbacks?.onQualityUpgrade?.(this.previewUrl, 'preview');
}
onPreviewError() {
if (this.destroyed || imageManager.isCanceled(this.asset)) {
return;
}
this.state.hasError = true;
this.state.previewImage = ImageStatus.Error;
this.state.previewUrl = undefined;
this.callbacks?.onError?.();
this.triggerOriginal();
}
triggerOriginal() {
if (!this.originalUrl) {
return false;
}
this.state.hasError = false;
this.state.originalUrl = this.originalUrl;
if (this.imageLoader) {
this.destroyFunctions.push(
this.imageLoader(
this.originalUrl,
{},
() => this.onOriginalLoad(),
() => this.onOriginalError(),
() => this.onOriginalStart(),
),
);
}
}
onOriginalStart() {
if (this.destroyed || imageManager.isCanceled(this.asset)) {
return;
}
this.state.quality = 'loading-original';
}
onOriginalLoad() {
if (this.destroyed || imageManager.isCanceled(this.asset)) {
return;
}
this.state.quality = 'original';
this.state.originalImage = ImageStatus.Success;
this.callbacks?.onImageReady?.();
}
onOriginalError() {
if (this.destroyed || imageManager.isCanceled(this.asset)) {
return;
}
this.state.hasError = true;
this.state.originalImage = ImageStatus.Error;
this.state.originalUrl = undefined;
this.callbacks?.onError?.();
}
destroy(): void {
this.destroyed = true;
if (this.imageLoader) {
for (const destroy of this.destroyFunctions) {
destroy();
}
return;
}
imageManager.cancel(this.asset);
}
}

View File

@@ -43,6 +43,14 @@ export class BaseEventManager<Events extends EventsBase> {
};
}
once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
const unsubscribe = this.on(event, (...args: Events[T]) => {
unsubscribe();
return callback(...args);
});
return unsubscribe;
}
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
const listeners = this.getListeners(event);
for (const listener of listeners) {

View File

@@ -1,5 +1,3 @@
import { handleError } from '$lib/utils/handle-error';
/**
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
* This class helps manage concurrent operations by tracking which invocations are active
@@ -53,14 +51,19 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
handleError(error, localizedMessage);
if (catchCallback) {
catchCallback(error);
} else {
console.error(error);
}
} finally {
invocation.endInvocation();
finallyCallback?.();
}
}
}

View File

@@ -129,3 +129,19 @@ export type CommonPosition = {
width: number;
height: number;
};
// Scales dimensions to fit within a container (like object-fit: contain)
export const scaleToFit = (
dimensions: { width: number; height: number },
container: { width: number; height: number },
) => {
const scaleX = container.width / dimensions.width;
const scaleY = container.height / dimensions.height;
const scale = Math.min(scaleX, scaleY);
return {
width: dimensions.width * scale,
height: dimensions.height * scale,
};
};

View File

@@ -1,14 +1,12 @@
const broadcast = new BroadcastChannel('immich');
import { ServiceWorkerMessenger } from './sw-messenger';
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
// eslint-disable-next-line compat/compat
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
export function cancelImageUrl(url: string | undefined | null) {
if (!url) {
if (!url || !messenger) {
return;
}
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
messenger.send('cancel', { url });
}

View File

@@ -0,0 +1,17 @@
export class ServiceWorkerMessenger {
readonly #serviceWorker: ServiceWorkerContainer;
constructor(serviceWorker: ServiceWorkerContainer) {
this.#serviceWorker = serviceWorker;
}
/**
* Send a one-way message to the service worker.
*/
send(type: string, data: Record<string, unknown>) {
this.#serviceWorker.controller?.postMessage({
type,
...data,
});
}
}

View File

@@ -31,4 +31,5 @@ export const TUNABLES = {
IMAGE_THUMBNAIL: {
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 100),
},
REDUCE_MOTION: getBoolean(storage.getItem('REDUCE_MOTION'), false),
};

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
@@ -8,6 +8,7 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { appManager } from '$lib/managers/app-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -58,6 +59,8 @@
let showNavigationLoadingBar = $state(false);
appManager.isAssetViewer = isAssetViewerRoute(page);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -83,8 +86,15 @@
showNavigationLoadingBar = true;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
onNavigate(({ to }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
});
afterNavigate(({ to, complete }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
void complete.finally(() => {
showNavigationLoadingBar = false;
});
});
const { serverRestarting } = websocketStore;

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import AdminCard from '$lib/components/AdminCard.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
@@ -48,10 +48,7 @@
const { children, data }: Props = $props();
let user = $state(data.user);
const userPreferences = $state(data.userPreferences);
const userStatistics = $state(data.userStatistics);
const userSessions = $state(data.userSessions);
const { user, userPreferences, userStatistics, userSessions } = $derived(data);
const TiB = 1024 ** 4;
const usage = $derived(user.quotaUsageInBytes ?? 0);
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
@@ -79,9 +76,10 @@
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
const onUpdate = (update: UserAdminResponseDto) => {
const onUpdate = async (update: UserAdminResponseDto) => {
if (update.id === user.id) {
user = update;
data.user = update;
await invalidateAll();
}
};

View File

@@ -16,12 +16,10 @@
let { data }: Props = $props();
const user = $state(data.user);
let isAdmin = $state(user.isAdmin);
let name = $state(user.name);
let email = $state(user.email);
let storageLabel = $state(user.storageLabel || '');
const previousQuota = $state(user.quotaSizeInBytes);
const user = $derived(data.user);
let { isAdmin, name, email } = $derived(user);
let storageLabel = $derived(user.storageLabel || '');
const previousQuota = $derived(user.quotaSizeInBytes);
let quotaSize = $derived(
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,

View File

@@ -1,25 +0,0 @@
import { handleCancel, handlePreload } from './request';
export const installBroadcastChannelListener = () => {
const broadcast = new BroadcastChannel('immich');
// eslint-disable-next-line unicorn/prefer-add-event-listener
broadcast.onmessage = (event) => {
if (!event.data) {
return;
}
const url = new URL(event.data.url, event.origin);
switch (event.data.type) {
case 'preload': {
handlePreload(url);
break;
}
case 'cancel': {
handleCancel(url);
break;
}
}
};
};

View File

@@ -1,42 +0,0 @@
import { version } from '$service-worker';
const CACHE = `cache-${version}`;
let _cache: Cache | undefined;
const getCache = async () => {
if (_cache) {
return _cache;
}
_cache = await caches.open(CACHE);
return _cache;
};
export const get = async (key: string) => {
const cache = await getCache();
if (!cache) {
return;
}
return cache.match(key);
};
export const put = async (key: string, response: Response) => {
if (response.status !== 200) {
return;
}
const cache = await getCache();
if (!cache) {
return;
}
cache.put(key, response.clone());
};
export const prune = async () => {
for (const key of await caches.keys()) {
if (key !== CACHE) {
await caches.delete(key);
}
}
};

View File

@@ -2,9 +2,9 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installBroadcastChannelListener } from './broadcast-channel';
import { prune } from './cache';
import { handleRequest } from './request';
import { installMessageListener } from './messaging';
import { handleFetch as handleAssetFetch } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
@@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
const handleActivate = (event: ExtendableEvent) => {
event.waitUntil(sw.clients.claim());
event.waitUntil(prune());
};
const handleInstall = (event: ExtendableEvent) => {
event.waitUntil(sw.skipWaiting());
// do not preload app resources
};
const handleFetch = (event: FetchEvent): void => {
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
// Cache requests for thumbnails
const url = new URL(event.request.url);
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
event.respondWith(handleRequest(event.request));
event.respondWith(handleAssetFetch(event.request));
return;
}
};
@@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => {
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installBroadcastChannelListener();
installMessageListener();

View File

@@ -0,0 +1,33 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { handleCancel } from './request';
const sw = globalThis as unknown as ServiceWorkerGlobalScope;
export const installMessageListener = () => {
sw.addEventListener('message', (event) => {
if (!event.data?.type) {
return;
}
switch (event.data.type) {
case 'cancel': {
const url = event.data.url ? new URL(event.data.url, self.location.origin) : undefined;
if (!url) {
return;
}
const client = event.source;
if (!client) {
return;
}
handleCancel(url);
break;
}
}
});
};

View File

@@ -1,73 +1,69 @@
import { get, put } from './cache';
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
const pendingRequests = new Map<string, AbortController>();
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
const assertResponse = (response: Response) => {
if (!(response instanceof Response)) {
throw new TypeError('Fetch did not return a valid Response object');
}
type PendingRequest = {
controller: AbortController;
promise: Promise<Response>;
cleanupTimeout?: ReturnType<typeof setTimeout>;
};
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
const pendingRequests = new Map<string, PendingRequest>();
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
const CANCELATION_MESSAGE = 'Request canceled by application';
const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const handleFetch = (request: URL | Request): Promise<Response> => {
const requestKey = getRequestKey(request);
const existing = pendingRequests.get(requestKey);
if (existing) {
// Clone the response since response bodies can only be read once
// Each caller gets an independent clone they can consume
return existing.promise.then((response) => response.clone());
}
if (isRequest(request)) {
return request.url;
}
const pendingRequest: PendingRequest = {
controller: new AbortController(),
promise: undefined as unknown as Promise<Response>,
cleanupTimeout: undefined,
};
pendingRequests.set(requestKey, pendingRequest);
throw new Error(`Invalid request: ${request}`);
};
// NOTE: fetch returns after headers received, not the body
pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal })
.catch((error: unknown) => {
const standardError = error instanceof Error ? error : new Error(String(error));
if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
throw standardError;
})
.finally(() => {
// Schedule cleanup after timeout to allow response body streaming to complete
const cleanupTimeout = setTimeout(() => {
pendingRequests.delete(requestKey);
}, CLEANUP_TIMEOUT_MS);
pendingRequest.cleanupTimeout = cleanupTimeout;
});
export const handlePreload = async (request: URL | Request) => {
try {
return await handleRequest(request);
} catch (error) {
console.error(`Preload failed: ${error}`);
}
};
export const handleRequest = async (request: URL | Request) => {
const cacheKey = getCacheKey(request);
const cachedResponse = await get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
try {
const cancelToken = new AbortController();
pendingRequests.set(cacheKey, cancelToken);
const response = await fetch(request, { signal: cancelToken.signal });
assertResponse(response);
put(cacheKey, response);
return response;
} catch (error) {
if (error.name === 'AbortError') {
// dummy response avoids network errors in the console for these requests
return new Response(undefined, { status: 204 });
}
console.log('Not an abort error', error);
throw error;
} finally {
pendingRequests.delete(cacheKey);
}
// Clone for the first caller to keep the original response unconsumed for future callers
return pendingRequest.promise.then((response) => response.clone());
};
export const handleCancel = (url: URL) => {
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
const requestKey = getRequestKey(url);
pendingRequest.abort();
pendingRequests.delete(cacheKey);
const pendingRequest = pendingRequests.get(requestKey);
if (pendingRequest) {
pendingRequest.controller.abort(CANCELATION_MESSAGE);
if (pendingRequest.cleanupTimeout) {
clearTimeout(pendingRequest.cleanupTimeout);
}
pendingRequests.delete(requestKey);
}
};