mirror of
https://github.com/immich-app/immich.git
synced 2026-02-03 18:48:01 -08:00
Compare commits
19 Commits
docs/contr
...
push-zpwso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
197bc5d387 | ||
|
|
6a16f3697f | ||
|
|
d4901c8818 | ||
|
|
f58dcb2f76 | ||
|
|
5c046df37d | ||
|
|
a99a085583 | ||
|
|
205e87007a | ||
|
|
9a90e4f942 | ||
|
|
3ea65f8d27 | ||
|
|
38c1f0b1fd | ||
|
|
5212bca3d0 | ||
|
|
2990bde0bb | ||
|
|
af1ecaf5cc | ||
|
|
3870ebc3c6 | ||
|
|
0a9d969b47 | ||
|
|
94965f6d66 | ||
|
|
8872d2c7ae | ||
|
|
23445fdcc1 | ||
|
|
25f2273e24 |
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
function imageLocator(page: Page) {
|
||||
return page.getByAltText('Image taken').locator('visible=true');
|
||||
}
|
||||
test.describe('Photo Viewer', () => {
|
||||
let admin: LoginResponseDto;
|
||||
let asset: AssetMediaResponseDto;
|
||||
@@ -26,31 +23,44 @@ test.describe('Photo Viewer', () => {
|
||||
|
||||
test('loads original photo when zoomed', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
|
||||
await expect(original).toBeInViewport();
|
||||
await expect(original).toHaveAttribute('src', /original/);
|
||||
});
|
||||
|
||||
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
|
||||
await page.goto(`/photos/${rawAsset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const box = await imageLocator(page).boundingBox();
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const original = page.getByTestId('original').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const box = await thumbnail.boundingBox();
|
||||
expect(box).toBeTruthy();
|
||||
const { x, y, width, height } = box!;
|
||||
await page.mouse.move(x + width / 2, y + height / 2);
|
||||
await page.mouse.wheel(0, -1);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
|
||||
await expect(original).toHaveAttribute('src', /fullsize/);
|
||||
});
|
||||
|
||||
test('reloads photo when checksum changes', async ({ page }) => {
|
||||
await page.goto(`/photos/${asset.id}`);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
|
||||
const initialSrc = await imageLocator(page).getAttribute('src');
|
||||
|
||||
const thumbnail = page.getByTestId('thumbnail').filter({ visible: true });
|
||||
const preview = page.getByTestId('preview').filter({ visible: true });
|
||||
|
||||
await expect(thumbnail).toHaveAttribute('src', /thumbnail/);
|
||||
const initialSrc = await thumbnail.getAttribute('src');
|
||||
await utils.replaceAsset(admin.accessToken, asset.id);
|
||||
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
|
||||
await expect(preview).not.toHaveAttribute('src', initialSrc!);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
|
||||
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
|
||||
},
|
||||
selectedAsset(page: Page) {
|
||||
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
|
||||
return page.locator('[data-thumbnail-focus-container][data-selected]');
|
||||
},
|
||||
async clickAssetId(page: Page, assetId: string) {
|
||||
await thumbnailUtils.withAssetId(page, assetId).click();
|
||||
@@ -103,11 +103,8 @@ export const thumbnailUtils = {
|
||||
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
|
||||
},
|
||||
async expectSelectedReadonly(page: Page, assetId: string) {
|
||||
// todo - need a data attribute for selected
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
|
||||
),
|
||||
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
|
||||
).toBeVisible();
|
||||
},
|
||||
async expectTimelineHasOnScreenAssets(page: Page) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --check .",
|
||||
|
||||
@@ -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"
|
||||
|
||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -882,7 +882,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.5.2"
|
||||
version = "2.5.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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
|
||||
|
||||
|
||||
4
mobile/openapi/lib/api/albums_api.dart
generated
4
mobile/openapi/lib/api/albums_api.dart
generated
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
391
web/src/app.css
391
web/src/app.css
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
207
web/src/lib/actions/image-loader.svelte.ts
Normal file
207
web/src/lib/actions/image-loader.svelte.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
/**
|
||||
* Converts a ClassValue to a string suitable for className assignment.
|
||||
* Handles strings, arrays, and objects similar to how clsx works.
|
||||
*/
|
||||
function classValueToString(value: ClassValue | undefined): string {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((v) => classValueToString(v))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
// Object/dictionary case
|
||||
return Object.entries(value)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export interface ImageLoaderProperties {
|
||||
imgClass?: ClassValue;
|
||||
alt?: string;
|
||||
draggable?: boolean;
|
||||
role?: string;
|
||||
style?: string;
|
||||
title?: string | null;
|
||||
loading?: 'lazy' | 'eager';
|
||||
dataAttributes?: Record<string, string>;
|
||||
}
|
||||
export interface ImageSourceProperty {
|
||||
src: string | undefined;
|
||||
}
|
||||
export interface ImageLoaderCallbacks {
|
||||
onStart?: () => void;
|
||||
onLoad?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onElementCreated?: (element: HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
const updateImageAttributes = (img: HTMLImageElement, params: ImageLoaderProperties) => {
|
||||
if (params.alt !== undefined) {
|
||||
img.alt = params.alt;
|
||||
}
|
||||
if (params.draggable !== undefined) {
|
||||
img.draggable = params.draggable;
|
||||
}
|
||||
if (params.imgClass) {
|
||||
img.className = classValueToString(params.imgClass);
|
||||
}
|
||||
if (params.role) {
|
||||
img.role = params.role;
|
||||
}
|
||||
if (params.style !== undefined) {
|
||||
img.setAttribute('style', params.style);
|
||||
}
|
||||
if (params.title !== undefined && params.title !== null) {
|
||||
img.title = params.title;
|
||||
}
|
||||
if (params.loading !== undefined) {
|
||||
img.loading = params.loading;
|
||||
}
|
||||
if (params.dataAttributes) {
|
||||
for (const [key, value] of Object.entries(params.dataAttributes)) {
|
||||
img.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const destroyImageElement = (
|
||||
imgElement: HTMLImageElement,
|
||||
currentSrc: string | undefined,
|
||||
handleLoad: () => void,
|
||||
handleError: () => void,
|
||||
) => {
|
||||
imgElement.removeEventListener('load', handleLoad);
|
||||
imgElement.removeEventListener('error', handleError);
|
||||
cancelImageUrl(currentSrc);
|
||||
imgElement.remove();
|
||||
};
|
||||
|
||||
const createImageElement = (
|
||||
src: string | undefined,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
onElementCreated?: (imgElement: HTMLImageElement) => void,
|
||||
) => {
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
updateImageAttributes(img, properties);
|
||||
|
||||
img.addEventListener('load', onLoad);
|
||||
img.addEventListener('error', onError);
|
||||
|
||||
onStart?.();
|
||||
|
||||
if (src) {
|
||||
img.src = src;
|
||||
onElementCreated?.(img);
|
||||
}
|
||||
|
||||
return img;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
src: string,
|
||||
properties: ImageLoaderProperties,
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
) {
|
||||
let destroyed = false;
|
||||
const wrapper = (fn: (() => void) | undefined) => () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
fn?.();
|
||||
};
|
||||
const wrappedOnLoad = wrapper(onLoad);
|
||||
const wrappedOnError = wrapper(onError);
|
||||
const wrappedOnStart = wrapper(onStart);
|
||||
const img = createImageElement(src, properties, wrappedOnLoad, wrappedOnError, wrappedOnStart);
|
||||
if (!img) {
|
||||
return () => void 0;
|
||||
}
|
||||
return () => {
|
||||
destroyed = true;
|
||||
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadImageFunction = typeof loadImage;
|
||||
|
||||
/**
|
||||
* 1. Creates and appends an <img> element to the parent
|
||||
* 2. Coordinates with service worker before src triggers fetch
|
||||
* 3. Adds load/error listeners
|
||||
* 4. Cancels SW request when element is removed from DOM
|
||||
*/
|
||||
export function imageLoader(
|
||||
node: HTMLElement,
|
||||
params: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks,
|
||||
) {
|
||||
let currentSrc = params.src;
|
||||
let currentCallbacks = params;
|
||||
let imgElement: HTMLImageElement | undefined = undefined;
|
||||
|
||||
const handleLoad = () => {
|
||||
currentCallbacks.onLoad?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
currentCallbacks.onError?.(new Error(`Failed to load image: ${currentSrc}`));
|
||||
};
|
||||
|
||||
const handleElementCreated = (img: HTMLImageElement) => {
|
||||
if (img) {
|
||||
node.append(img);
|
||||
currentCallbacks.onElementCreated?.(img);
|
||||
}
|
||||
};
|
||||
|
||||
const createImage = () => {
|
||||
imgElement = createImageElement(currentSrc, params, handleLoad, handleError, params.onStart, handleElementCreated);
|
||||
};
|
||||
createImage();
|
||||
|
||||
return {
|
||||
update(newParams: ImageSourceProperty & ImageLoaderProperties & ImageLoaderCallbacks) {
|
||||
// If src changed, recreate the image element
|
||||
if (newParams.src !== currentSrc) {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
currentSrc = newParams.src;
|
||||
currentCallbacks = newParams;
|
||||
|
||||
createImage();
|
||||
return;
|
||||
}
|
||||
|
||||
currentCallbacks = newParams;
|
||||
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
updateImageAttributes(imgElement, newParams);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
if (imgElement) {
|
||||
destroyImageElement(imgElement, currentSrc, handleLoad, handleError);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
244
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal file
244
web/src/lib/components/asset-viewer/adaptive-image.svelte
Normal 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>
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
114
web/src/lib/components/asset-viewer/letterboxes.svelte
Normal file
114
web/src/lib/components/asset-viewer/letterboxes.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { SlideshowLook, SlideshowState } from '$lib/stores/slideshow.store';
|
||||
|
||||
interface Props {
|
||||
transitionName?: string | null | undefined;
|
||||
slideshowState: SlideshowState;
|
||||
slideshowLook: SlideshowLook;
|
||||
hasThumbhash: boolean;
|
||||
scaledDimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
container: {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
let { transitionName, slideshowState, slideshowLook, hasThumbhash, scaledDimensions, container }: Props = $props();
|
||||
|
||||
const blurredSlideshow = $derived(
|
||||
slideshowState !== SlideshowState.None && slideshowLook === SlideshowLook.BlurredBackground && hasThumbhash,
|
||||
);
|
||||
|
||||
const shouldShowLetterboxes = $derived(!!transitionName && transitionName !== 'hero' && !blurredSlideshow);
|
||||
|
||||
const transitionLetterboxLeft = $derived(shouldShowLetterboxes ? 'letterbox-left' : null);
|
||||
const transitionLetterboxRight = $derived(shouldShowLetterboxes ? 'letterbox-right' : null);
|
||||
const transitionLetterboxTop = $derived(shouldShowLetterboxes ? 'letterbox-top' : null);
|
||||
const transitionLetterboxBottom = $derived(shouldShowLetterboxes ? 'letterbox-bottom' : null);
|
||||
|
||||
// Letterbox regions (the empty space around the main box)
|
||||
const letterboxLeft = $derived.by(() => {
|
||||
const { width } = scaledDimensions;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
return {
|
||||
width: leftOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxRight = $derived.by(() => {
|
||||
const { width } = scaledDimensions;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
const rightOffset = leftOffset;
|
||||
return {
|
||||
width: rightOffset + 'px',
|
||||
height: container.height + 'px',
|
||||
left: container.width - rightOffset + 'px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxTop = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const topOffset = (container.height - height) / 2;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: topOffset + 'px',
|
||||
left: leftOffset + 'px',
|
||||
top: '0px',
|
||||
};
|
||||
});
|
||||
|
||||
const letterboxBottom = $derived.by(() => {
|
||||
const { width, height } = scaledDimensions;
|
||||
const topOffset = (container.height - height) / 2;
|
||||
const bottomOffset = topOffset;
|
||||
const leftOffset = (container.width - width) / 2;
|
||||
return {
|
||||
width: width + 'px',
|
||||
height: bottomOffset + 'px',
|
||||
left: leftOffset + 'px',
|
||||
top: container.height - bottomOffset + 'px',
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Letterbox regions (empty space around image) -->
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxLeft}
|
||||
style:left={letterboxLeft.left}
|
||||
style:top={letterboxLeft.top}
|
||||
style:width={letterboxLeft.width}
|
||||
style:height={letterboxLeft.height}
|
||||
></div>
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxRight}
|
||||
style:left={letterboxRight.left}
|
||||
style:top={letterboxRight.top}
|
||||
style:width={letterboxRight.width}
|
||||
style:height={letterboxRight.height}
|
||||
></div>
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxTop}
|
||||
style:left={letterboxTop.left}
|
||||
style:top={letterboxTop.top}
|
||||
style:width={letterboxTop.width}
|
||||
style:height={letterboxTop.height}
|
||||
></div>
|
||||
<div
|
||||
class="absolute"
|
||||
style:view-transition-name={transitionLetterboxBottom}
|
||||
style:left={letterboxBottom.left}
|
||||
style:top={letterboxBottom.top}
|
||||
style:width={letterboxBottom.width}
|
||||
style:height={letterboxBottom.height}
|
||||
></div>
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,68 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { zoomImageAction } from '$lib/actions/zoom-image';
|
||||
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerFadeDuration } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
|
||||
import { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { AssetCursor } from './asset-viewer.svelte';
|
||||
|
||||
interface Props {
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement | undefined;
|
||||
haveFadeTransition?: boolean;
|
||||
sharedLink?: SharedLinkResponseDto | undefined;
|
||||
onPreviousAsset?: (() => void) | null;
|
||||
onNextAsset?: (() => void) | null;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
transitionName?: string;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
}
|
||||
|
||||
let {
|
||||
cursor,
|
||||
element = $bindable(),
|
||||
haveFadeTransition = true,
|
||||
sharedLink = undefined,
|
||||
onPreviousAsset = null,
|
||||
onNextAsset = null,
|
||||
}: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, transitionName, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
|
||||
let imageLoaded: boolean = $state(false);
|
||||
let originalImageLoaded: boolean = $state(false);
|
||||
let imageError: boolean = $state(false);
|
||||
|
||||
let loader = $state<HTMLImageElement>();
|
||||
|
||||
assetViewerManager.zoomState = {
|
||||
currentRotation: 0,
|
||||
currentZoom: 1,
|
||||
enable: true,
|
||||
currentPositionX: 0,
|
||||
currentPositionY: 0,
|
||||
};
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
});
|
||||
@@ -110,29 +84,11 @@
|
||||
handlePromiseError(onCopy());
|
||||
};
|
||||
|
||||
const onSwipe = (event: SwipeCustomEvent) => {
|
||||
if (assetViewerManager.zoom > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ocrManager.showOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onNextAsset && event.detail.direction === 'left') {
|
||||
onNextAsset();
|
||||
}
|
||||
|
||||
if (onPreviousAsset && event.detail.direction === 'right') {
|
||||
onPreviousAsset();
|
||||
}
|
||||
};
|
||||
|
||||
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
|
||||
let currentPreviewUrl = $state<string>();
|
||||
|
||||
$effect(() => {
|
||||
if (imageLoaderUrl) {
|
||||
void cast(imageLoaderUrl);
|
||||
if (currentPreviewUrl) {
|
||||
void cast(currentPreviewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,35 +106,18 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onload = () => {
|
||||
imageLoaded = true;
|
||||
originalImageLoaded = targetImageSize === AssetMediaSize.Fullsize || targetImageSize === 'original';
|
||||
};
|
||||
|
||||
const onerror = () => {
|
||||
imageError = imageLoaded = true;
|
||||
};
|
||||
|
||||
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
|
||||
|
||||
let imageLoaderUrl = $derived(
|
||||
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
|
||||
);
|
||||
|
||||
let containerWidth = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
|
||||
let lastUrl: string | undefined;
|
||||
|
||||
const container = $derived({
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
let swipeFeedbackReset = $state<(() => void) | undefined>();
|
||||
$effect(() => {
|
||||
if (lastUrl && lastUrl !== imageLoaderUrl) {
|
||||
untrack(() => {
|
||||
imageLoaded = false;
|
||||
originalImageLoaded = false;
|
||||
imageError = false;
|
||||
});
|
||||
}
|
||||
lastUrl = imageLoaderUrl;
|
||||
// Reset swipe feedback when asset changes
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
asset.id;
|
||||
untrack(() => swipeFeedbackReset?.());
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -192,46 +131,35 @@
|
||||
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
|
||||
]}
|
||||
/>
|
||||
{#if imageError}
|
||||
<div id="broken-asset" class="h-full w-full">
|
||||
<BrokenAsset class="text-xl h-full w-full" />
|
||||
</div>
|
||||
{/if}
|
||||
<img bind:this={loader} style="display:none" src={imageLoaderUrl} alt="" aria-hidden="true" {onload} {onerror} />
|
||||
<div
|
||||
bind:this={element}
|
||||
|
||||
<SwipeFeedback
|
||||
bind:element
|
||||
class="relative h-full w-full select-none"
|
||||
bind:clientWidth={containerWidth}
|
||||
bind:clientHeight={containerHeight}
|
||||
disabled={isOcrActive || assetViewerManager.zoom > 1}
|
||||
disableSwipeLeft={!cursor.nextAsset}
|
||||
disableSwipeRight={!cursor.previousAsset}
|
||||
bind:reset={swipeFeedbackReset}
|
||||
{onSwipe}
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<div id="spinner" class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if !imageError}
|
||||
<div
|
||||
use:zoomImageAction={{ disabled: isOcrActive }}
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full w-full"
|
||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||
>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={imageLoaderUrl}
|
||||
alt=""
|
||||
class="-z-1 absolute top-0 start-0 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={assetViewerManager.imgRef}
|
||||
src={imageLoaderUrl}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
<AdaptiveImage
|
||||
{asset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={isOcrActive}
|
||||
imageClass={$slideshowState === SlideshowState.None ? 'object-contain' : slideshowLookCssMapping[$slideshowLook]}
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
onImageReady={() => onReady?.()}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
}}
|
||||
bind:imgElement={assetViewerManager.imgRef}
|
||||
{transitionName}
|
||||
>
|
||||
{#snippet overlays()}
|
||||
<!-- eslint-disable-next-line svelte/require-each-key -->
|
||||
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
|
||||
<div
|
||||
@@ -243,23 +171,38 @@
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{#if isFaceEditMode.value && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes delayedVisibility {
|
||||
to {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
#broken-asset,
|
||||
#spinner {
|
||||
visibility: hidden;
|
||||
animation: 0s linear 0.4s forwards delayedVisibility;
|
||||
}
|
||||
</style>
|
||||
{#snippet leftPreview()}
|
||||
{#if cursor.previousAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.previousAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet rightPreview()}
|
||||
{#if cursor.nextAsset}
|
||||
<AdaptiveImage
|
||||
asset={cursor.nextAsset}
|
||||
{sharedLink}
|
||||
{container}
|
||||
zoomDisabled={true}
|
||||
imageClass="object-contain"
|
||||
slideshowState={$slideshowState}
|
||||
slideshowLook={$slideshowLook}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SwipeFeedback>
|
||||
|
||||
395
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
395
web/src/lib/components/asset-viewer/swipe-feedback.svelte
Normal file
@@ -0,0 +1,395 @@
|
||||
<script lang="ts">
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
disableSwipeLeft?: boolean;
|
||||
disableSwipeRight?: boolean;
|
||||
onSwipeEnd?: (offsetX: number) => void;
|
||||
onSwipeMove?: (offsetX: number) => void;
|
||||
onSwipe?: (direction: 'left' | 'right') => void;
|
||||
swipeThreshold?: number;
|
||||
class?: string;
|
||||
transitionName?: string;
|
||||
element?: HTMLDivElement;
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
reset?: () => void;
|
||||
children: Snippet;
|
||||
leftPreview?: Snippet;
|
||||
rightPreview?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
disabled = false,
|
||||
disableSwipeLeft = false,
|
||||
disableSwipeRight = false,
|
||||
onSwipeEnd,
|
||||
onSwipeMove,
|
||||
onSwipe,
|
||||
swipeThreshold = 45,
|
||||
class: className = '',
|
||||
transitionName,
|
||||
element = $bindable(),
|
||||
clientWidth = $bindable(),
|
||||
clientHeight = $bindable(),
|
||||
reset = $bindable(),
|
||||
children,
|
||||
leftPreview,
|
||||
rightPreview,
|
||||
}: Props = $props();
|
||||
|
||||
interface SwipeAnimations {
|
||||
currentImageAnimation: Animation;
|
||||
previewAnimation: Animation | null;
|
||||
abortController: AbortController;
|
||||
}
|
||||
|
||||
const ANIMATION_DURATION_MS = 300;
|
||||
// Tolerance to avoid edge cases where animation is nearly complete but not exactly at end
|
||||
const ANIMATION_COMPLETION_TOLERANCE_MS = 5;
|
||||
// Minimum velocity to trigger swipe (tuned for natural flick gesture)
|
||||
const MIN_SWIPE_VELOCITY = 0.11; // pixels per millisecond
|
||||
// Require 25% drag progress if velocity is too low (prevents accidental swipes)
|
||||
const MIN_PROGRESS_THRESHOLD = 0.25;
|
||||
const ENABLE_SCALE_ANIMATION = false;
|
||||
|
||||
let contentElement: HTMLElement | undefined = $state();
|
||||
let leftPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
let rightPreviewContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let startX = $state(0);
|
||||
let currentOffsetX = $state(0);
|
||||
let dragStartTime: number | null = $state(null);
|
||||
|
||||
let leftAnimations: SwipeAnimations | null = $state(null);
|
||||
let rightAnimations: SwipeAnimations | null = $state(null);
|
||||
let isSwipeInProgress = $state(false);
|
||||
|
||||
const cursorStyle = $derived(disabled ? '' : isSwipeInProgress ? 'wait' : isDragging ? 'grabbing' : 'grab');
|
||||
|
||||
const isValidPointerEvent = (event: PointerEvent) =>
|
||||
event.isPrimary && (event.pointerType !== 'mouse' || event.button === 0);
|
||||
|
||||
const createSwipeAnimations = (direction: 'left' | 'right'): SwipeAnimations | null => {
|
||||
if (!contentElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const createAnimationKeyframes = (direction: 'left' | 'right', isPreview: boolean) => {
|
||||
const scale = (s: number) => (ENABLE_SCALE_ANIMATION ? ` scale(${s})` : '');
|
||||
const sign = direction === 'left' ? -1 : 1;
|
||||
|
||||
if (isPreview) {
|
||||
return [
|
||||
{ transform: `translateX(${sign * -100}vw)${scale(0)}`, opacity: '0', offset: 0 },
|
||||
{ transform: `translateX(${sign * -80}vw)${scale(0.2)}`, opacity: '0', offset: 0.2 },
|
||||
{ transform: `translateX(${sign * -50}vw)${scale(0.5)}`, opacity: '0.5', offset: 0.5 },
|
||||
{ transform: `translateX(${sign * -20}vw)${scale(0.8)}`, opacity: '1', offset: 0.8 },
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ transform: `translateX(0)${scale(1)}`, opacity: '1', offset: 0 },
|
||||
{ transform: `translateX(${sign * 100}vw)${scale(0)}`, opacity: '0', offset: 1 },
|
||||
];
|
||||
};
|
||||
|
||||
contentElement.style.transformOrigin = 'center';
|
||||
|
||||
const currentImageAnimation = contentElement.animate(createAnimationKeyframes(direction, false), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
|
||||
// Preview slides in from opposite side of swipe direction
|
||||
const previewContainer = direction === 'left' ? rightPreviewContainer : leftPreviewContainer;
|
||||
let previewAnimation: Animation | null = null;
|
||||
|
||||
if (previewContainer) {
|
||||
previewContainer.style.transformOrigin = 'center';
|
||||
previewAnimation = previewContainer.animate(createAnimationKeyframes(direction, true), {
|
||||
duration: ANIMATION_DURATION_MS,
|
||||
easing: 'linear',
|
||||
fill: 'both',
|
||||
});
|
||||
}
|
||||
|
||||
currentImageAnimation.pause();
|
||||
previewAnimation?.pause();
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
return { currentImageAnimation, previewAnimation, abortController };
|
||||
};
|
||||
|
||||
const setAnimationTime = (animations: SwipeAnimations, time: number) => {
|
||||
animations.currentImageAnimation.currentTime = time;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.currentTime = time;
|
||||
}
|
||||
};
|
||||
|
||||
const playAnimation = (animations: SwipeAnimations, playbackRate: number) => {
|
||||
animations.currentImageAnimation.playbackRate = playbackRate;
|
||||
if (animations.previewAnimation) {
|
||||
animations.previewAnimation.playbackRate = playbackRate;
|
||||
}
|
||||
animations.currentImageAnimation.play();
|
||||
animations.previewAnimation?.play();
|
||||
};
|
||||
|
||||
const cancelAnimations = (animations: SwipeAnimations | null) => {
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
animations.abortController.abort();
|
||||
animations.currentImageAnimation.cancel();
|
||||
animations.previewAnimation?.cancel();
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isValidPointerEvent(event) || !element || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
startDrag(event);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const startDrag = (event: PointerEvent) => {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = true;
|
||||
startX = event.clientX;
|
||||
currentOffsetX = 0;
|
||||
|
||||
element.setPointerCapture(event.pointerId);
|
||||
dragStartTime = Date.now();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (disabled || !contentElement || !isDragging || isSwipeInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawOffsetX = event.clientX - startX;
|
||||
const direction = rawOffsetX < 0 ? 'left' : 'right';
|
||||
|
||||
if ((direction === 'left' && disableSwipeLeft) || (direction === 'right' && disableSwipeRight)) {
|
||||
currentOffsetX = 0;
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
return;
|
||||
}
|
||||
|
||||
currentOffsetX = rawOffsetX;
|
||||
const animationTime = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1) * ANIMATION_DURATION_MS;
|
||||
|
||||
if (direction === 'left') {
|
||||
if (!leftAnimations) {
|
||||
leftAnimations = createSwipeAnimations('left');
|
||||
}
|
||||
if (leftAnimations) {
|
||||
setAnimationTime(leftAnimations, animationTime);
|
||||
}
|
||||
if (rightAnimations) {
|
||||
cancelAnimations(rightAnimations);
|
||||
rightAnimations = null;
|
||||
}
|
||||
} else {
|
||||
if (!rightAnimations) {
|
||||
rightAnimations = createSwipeAnimations('right');
|
||||
}
|
||||
if (rightAnimations) {
|
||||
setAnimationTime(rightAnimations, animationTime);
|
||||
}
|
||||
if (leftAnimations) {
|
||||
cancelAnimations(leftAnimations);
|
||||
leftAnimations = null;
|
||||
}
|
||||
}
|
||||
onSwipeMove?.(currentOffsetX);
|
||||
event.preventDefault(); // Prevent scrolling during drag
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (!isDragging || !isValidPointerEvent(event) || !contentElement || !element) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
if (element.hasPointerCapture(event.pointerId)) {
|
||||
element.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
const dragDuration = dragStartTime ? Date.now() - dragStartTime : 0;
|
||||
const velocity = dragDuration > 0 ? Math.abs(currentOffsetX) / dragDuration : 0;
|
||||
const progress = Math.min(Math.abs(currentOffsetX) / window.innerWidth, 1);
|
||||
|
||||
if (
|
||||
Math.abs(currentOffsetX) < swipeThreshold ||
|
||||
(velocity < MIN_SWIPE_VELOCITY && progress <= MIN_PROGRESS_THRESHOLD)
|
||||
) {
|
||||
resetPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
isSwipeInProgress = true;
|
||||
|
||||
onSwipeEnd?.(currentOffsetX);
|
||||
completeTransition(currentOffsetX > 0 ? 'right' : 'left');
|
||||
};
|
||||
|
||||
const resetPosition = () => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = currentOffsetX < 0 ? 'left' : 'right';
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
|
||||
if (!animations) {
|
||||
currentOffsetX = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, -1);
|
||||
|
||||
const handleFinish = () => {
|
||||
cancelAnimations(animations);
|
||||
if (direction === 'left') {
|
||||
leftAnimations = null;
|
||||
} else {
|
||||
rightAnimations = null;
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const completeTransition = (direction: 'left' | 'right') => {
|
||||
if (!contentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const animations = direction === 'left' ? leftAnimations : rightAnimations;
|
||||
if (!animations) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = Number(animations.currentImageAnimation.currentTime) || 0;
|
||||
|
||||
if (currentTime >= ANIMATION_DURATION_MS - ANIMATION_COMPLETION_TOLERANCE_MS) {
|
||||
onSwipe?.(direction);
|
||||
return;
|
||||
}
|
||||
|
||||
playAnimation(animations, 1);
|
||||
|
||||
const handleFinish = () => {
|
||||
if (contentElement) {
|
||||
onSwipe?.(direction);
|
||||
}
|
||||
};
|
||||
animations.currentImageAnimation.addEventListener('finish', handleFinish, {
|
||||
signal: animations.abortController.signal,
|
||||
});
|
||||
};
|
||||
|
||||
const resetPreviewContainers = () => {
|
||||
cancelAnimations(leftAnimations);
|
||||
cancelAnimations(rightAnimations);
|
||||
leftAnimations = null;
|
||||
rightAnimations = null;
|
||||
|
||||
if (contentElement) {
|
||||
contentElement.style.transform = '';
|
||||
contentElement.style.transition = '';
|
||||
contentElement.style.opacity = '';
|
||||
}
|
||||
currentOffsetX = 0;
|
||||
};
|
||||
|
||||
const finishSwipeInProgress = () => {
|
||||
isSwipeInProgress = false;
|
||||
};
|
||||
|
||||
const resetSwipeFeedback = () => {
|
||||
resetPreviewContainers();
|
||||
finishSwipeInProgress();
|
||||
};
|
||||
|
||||
reset = resetSwipeFeedback;
|
||||
onMount(() =>
|
||||
eventManager.onMany({
|
||||
ViewerFinishNavigate: finishSwipeInProgress,
|
||||
ResetSwipeFeedback: resetSwipeFeedback,
|
||||
}),
|
||||
);
|
||||
|
||||
onDestroy(() => {
|
||||
resetSwipeFeedback();
|
||||
if (element) {
|
||||
element.style.cursor = '';
|
||||
}
|
||||
if (contentElement) {
|
||||
contentElement.style.cursor = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Listen on window to catch pointer release outside element (due to setPointerCapture) -->
|
||||
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
bind:clientWidth
|
||||
bind:clientHeight
|
||||
class={className}
|
||||
style:cursor={cursorStyle}
|
||||
style:view-transition-name={transitionName}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
role="presentation"
|
||||
>
|
||||
{#if leftPreview}
|
||||
<!-- Swiping right reveals left preview -->
|
||||
<div
|
||||
bind:this={leftPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={rightAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render leftPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rightPreview}
|
||||
<!-- Swiping left reveals right preview -->
|
||||
<div
|
||||
bind:this={rightPreviewContainer}
|
||||
class="absolute inset-0"
|
||||
style:pointer-events="none"
|
||||
style:display={leftAnimations ? 'block' : 'none'}
|
||||
>
|
||||
{@render rightPreview()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div bind:this={contentElement} class="h-full w-full" style:cursor={cursorStyle}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,24 +2,34 @@
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiImageBrokenVariant } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
class?: ClassValue;
|
||||
hideMessage?: boolean;
|
||||
width?: string | undefined;
|
||||
height?: string | undefined;
|
||||
}
|
||||
|
||||
let { class: className = '', hideMessage = false, width = undefined, height = undefined }: Props = $props();
|
||||
|
||||
let clientWidth = $state(0);
|
||||
let textClass = $derived(clientWidth < 100 ? 'text-xs' : clientWidth < 150 ? 'text-sm' : 'text-base');
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4 {className}"
|
||||
bind:clientWidth
|
||||
class={[
|
||||
'flex flex-col overflow-hidden max-h-full max-w-full justify-center items-center bg-gray-100/40 dark:bg-gray-700/40 dark:text-gray-100 p-4',
|
||||
className,
|
||||
]}
|
||||
style:width
|
||||
style:height
|
||||
>
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
|
||||
{#if clientWidth >= 75}
|
||||
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
|
||||
{/if}
|
||||
{#if !hideMessage}
|
||||
<span class="text-center">{$t('error_loading_image')}</span>
|
||||
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { imageLoader } from '$lib/actions/image-loader.svelte';
|
||||
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiEyeOffOutline } from '@mdi/js';
|
||||
import type { ActionReturn } from 'svelte/action';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
@@ -49,53 +48,46 @@
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
};
|
||||
|
||||
const setErrored = () => {
|
||||
errored = true;
|
||||
onComplete?.(true);
|
||||
};
|
||||
|
||||
function mount(elem: HTMLImageElement): ActionReturn {
|
||||
if (elem.complete) {
|
||||
loaded = true;
|
||||
onComplete?.(false);
|
||||
}
|
||||
return {
|
||||
destroy: () => imageManager.cancelPreloadUrl(url),
|
||||
};
|
||||
}
|
||||
|
||||
let optionalClasses = $derived(
|
||||
let optionalClasses = $derived([
|
||||
[
|
||||
curve && 'rounded-xl',
|
||||
circle && 'rounded-full',
|
||||
shadow && 'shadow-lg',
|
||||
(circle || !heightStyle) && 'aspect-square',
|
||||
border && 'border-3 border-immich-dark-primary/80 hover:border-immich-primary',
|
||||
brokenAssetClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
brokenAssetClass,
|
||||
]);
|
||||
|
||||
let style = $derived(
|
||||
`width: ${widthStyle}; height: ${heightStyle ?? ''}; filter: ${hidden ? 'grayscale(50%)' : 'none'}; opacity: ${hidden ? '0.5' : '1'};`,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if errored}
|
||||
<BrokenAsset class={optionalClasses} width={widthStyle} height={heightStyle} />
|
||||
{:else}
|
||||
<img
|
||||
use:mount
|
||||
onload={setLoaded}
|
||||
onerror={setErrored}
|
||||
style:width={widthStyle}
|
||||
style:height={heightStyle}
|
||||
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||
style:opacity={hidden ? '0.5' : '1'}
|
||||
src={url}
|
||||
alt={loaded || errored ? altText : ''}
|
||||
{title}
|
||||
class={['object-cover', optionalClasses, imageClass]}
|
||||
draggable="false"
|
||||
loading={preload ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<div
|
||||
use:imageLoader={{
|
||||
src: url,
|
||||
onLoad: setLoaded,
|
||||
onError: setErrored,
|
||||
imgClass: ['object-cover', imageClass],
|
||||
style,
|
||||
alt: loaded || errored ? altText : '',
|
||||
draggable: false,
|
||||
title,
|
||||
loading: preload ? 'eager' : 'lazy',
|
||||
}}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if hidden}
|
||||
|
||||
@@ -200,8 +200,9 @@
|
||||
|
||||
<div
|
||||
class={[
|
||||
'focus-visible:outline-none flex overflow-hidden',
|
||||
'group focus-visible:outline-none focus-visible:rounded-lg flex overflow-hidden',
|
||||
disabled ? 'bg-gray-300' : 'dark:bg-neutral-700 bg-neutral-200',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
@@ -223,18 +224,10 @@
|
||||
bind:this={element}
|
||||
data-asset={asset.id}
|
||||
data-thumbnail-focus-container
|
||||
data-selected={selected ? true : undefined}
|
||||
tabindex={0}
|
||||
role="link"
|
||||
>
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-hidden outline-4 -outline-offset-4 outline-immich-primary',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
|
||||
<div
|
||||
class={['group absolute top-0 bottom-0', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="inherit"
|
||||
@@ -247,13 +240,81 @@
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
>
|
||||
<ImageThumbnail
|
||||
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
|
||||
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute h-full w-full pointer-events-none group-focus-visible:rounded-lg">
|
||||
<VideoThumbnail
|
||||
class="group-focus-visible:rounded-lg"
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover group-focus-visible:rounded-lg"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
<!-- icon overlay -->
|
||||
<div>
|
||||
<!-- Gradient overlay on hover -->
|
||||
{#if !usingMobileDevice && !disabled}
|
||||
<div
|
||||
class={[
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
|
||||
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:rounded-lg',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
></div>
|
||||
@@ -261,7 +322,10 @@
|
||||
|
||||
<!-- Dimmed support -->
|
||||
{#if dimmed && !mouseOver}
|
||||
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
|
||||
<div
|
||||
id="a"
|
||||
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
@@ -329,72 +393,6 @@
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
onComplete={(errored) => ((loaded = true), (thumbError = errored))}
|
||||
/>
|
||||
{#if asset.isVideo}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.id, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
curve={selected}
|
||||
durationInSeconds={asset.duration ? timeToSeconds(asset.duration) : 0}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.livePhotoVideoId}
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<VideoThumbnail
|
||||
url={getAssetPlaybackUrl({ id: asset.livePhotoVideoId, cacheKey: asset.thumbhash })}
|
||||
enablePlayback={mouseOver && $playVideoThumbnailOnHover}
|
||||
pauseIcon={mdiMotionPauseOutline}
|
||||
playIcon={mdiMotionPlayOutline}
|
||||
showTime={false}
|
||||
curve={selected}
|
||||
playbackOnIconHover={!$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
|
||||
<!-- GIF -->
|
||||
<div class="absolute top-0 h-full w-full pointer-events-none">
|
||||
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>
|
||||
<ImageThumbnail
|
||||
class={imageClass}
|
||||
{brokenAssetClass}
|
||||
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
|
||||
altText={$getAltText(asset)}
|
||||
widthStyle="{width}px"
|
||||
heightStyle="{height}px"
|
||||
curve={selected}
|
||||
/>
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<span class="pe-2 pt-2">
|
||||
<Icon data-icon-playable-pause icon={mdiMotionPauseOutline} size="24" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (!loaded || thumbError) && asset.thumbhash}
|
||||
<canvas
|
||||
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}
|
||||
data-testid="thumbhash"
|
||||
class="absolute top-0 object-cover"
|
||||
style:width="{width}px"
|
||||
style:height="{height}px"
|
||||
class:rounded-xl={selected}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectionCandidate}
|
||||
@@ -427,11 +425,14 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class={[
|
||||
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:rounded-lg group-focus-visible:outline-2 group-focus-visible:-outline-offset-2',
|
||||
{ 'rounded-xl': selected },
|
||||
]}
|
||||
data-outline
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[data-asset]:focus > [data-outline] {
|
||||
outline-style: solid;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon, LoadingSpinner } from '@immich/ui';
|
||||
import { mdiAlertCircleOutline, mdiPauseCircleOutline, mdiPlayCircleOutline } from '@mdi/js';
|
||||
import { Duration } from 'luxon';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
@@ -12,6 +13,7 @@
|
||||
curve?: boolean;
|
||||
playIcon?: string;
|
||||
pauseIcon?: string;
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -23,6 +25,7 @@
|
||||
curve = false,
|
||||
playIcon = mdiPlayCircleOutline,
|
||||
pauseIcon = mdiPauseCircleOutline,
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
let remainingSeconds = $state(durationInSeconds);
|
||||
@@ -57,7 +60,7 @@
|
||||
{#if enablePlayback}
|
||||
<video
|
||||
bind:this={player}
|
||||
class="h-full w-full object-cover"
|
||||
class={['h-full w-full object-cover', className]}
|
||||
class:rounded-xl={curve}
|
||||
muted
|
||||
autoplay
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -44,12 +45,17 @@
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
let isAssetViewer = $derived(appManager.isAssetViewer);
|
||||
</script>
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
{#if !hideNavbar && !isAssetViewer}
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
|
||||
{#if isAssetViewer}
|
||||
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
tabindex="-1"
|
||||
@@ -58,13 +64,15 @@
|
||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||
>
|
||||
{#if sidebar}
|
||||
{#if isAssetViewer}
|
||||
<div></div>
|
||||
{:else if sidebar}
|
||||
{@render sidebar()}
|
||||
{:else}
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<main class="relative w-full">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
type Props = {
|
||||
animationTargetAssetId?: string | null;
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
manager: VirtualScrollManager;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
@@ -26,10 +20,7 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
const { animationTargetAssetId, viewerAssets, width, height, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
|
||||
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
|
||||
return intersectables.filter(({ intersecting }) => intersecting);
|
||||
@@ -41,18 +32,20 @@
|
||||
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
|
||||
|
||||
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||
<div
|
||||
data-asset-id={asset.id}
|
||||
class="absolute"
|
||||
data-transition-name={transitionName}
|
||||
style:view-transition-name={transitionName}
|
||||
style:top={position.top + 'px'}
|
||||
style:left={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
style:height={position.height + 'px'}
|
||||
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||
animate:flip={{ duration: transitionDuration }}
|
||||
>
|
||||
<!-- animate:flip={{ duration: transitionDuration }} -->
|
||||
{@render thumbnail({ asset, position })}
|
||||
{@render customThumbnailLayout?.(asset)}
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount, tick, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
toAssetViewerTransitionId?: string | null;
|
||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
||||
singleSelect: boolean;
|
||||
assetInteraction: AssetInteraction;
|
||||
monthGroup: MonthGroup;
|
||||
manager: VirtualScrollManager;
|
||||
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
toAssetViewerTransitionId,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
assetInteraction,
|
||||
monthGroup,
|
||||
manager,
|
||||
onDayGroupSelect,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -51,6 +54,32 @@
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
let toTimelineTransitionAssetId = $state<string | null>(null);
|
||||
let animationTargetAssetId = $derived(toTimelineTransitionAssetId ?? toAssetViewerTransitionId ?? null);
|
||||
|
||||
const transitionToTimelineCallback = ({ id }: { id: string }) => {
|
||||
const asset = monthGroup.findAssetById({ id });
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('TimelineLoaded', (event: { id: string | null }) => {
|
||||
toTimelineTransitionAssetId = event.id;
|
||||
void tick().then(resolve);
|
||||
});
|
||||
}),
|
||||
['timeline'],
|
||||
() => {
|
||||
toTimelineTransitionAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
);
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => eventManager.on('TransitionToTimeline', transitionToTimelineCallback));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||
@@ -95,7 +124,7 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
{animationTargetAssetId}
|
||||
viewerAssets={dayGroup.viewerAssets}
|
||||
height={dayGroup.height}
|
||||
width={dayGroup.width}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -437,7 +439,7 @@
|
||||
next = forward
|
||||
? (focusable[(index + 1) % focusable.length] as HTMLElement)
|
||||
: (focusable[(index - 1) % focusable.length] as HTMLElement);
|
||||
next.focus();
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +510,7 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
|
||||
class:invisible
|
||||
style:padding-top={PADDING_TOP + 'px'}
|
||||
style:padding-bottom={PADDING_BOTTOM + 'px'}
|
||||
style:width
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
@@ -28,7 +30,6 @@
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
isSelectionMode?: boolean;
|
||||
singleSelect?: boolean;
|
||||
@@ -104,6 +105,7 @@
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
let toAssetViewerTransitionId = $state<string | null>(null);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||
@@ -211,7 +213,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = $gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -223,7 +225,7 @@
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -241,10 +243,13 @@
|
||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||
});
|
||||
|
||||
const getScrollTarget = () => {
|
||||
return $gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||
};
|
||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||
// after successful navigation.
|
||||
afterNavigate(({ complete }) => {
|
||||
void complete.finally(() => {
|
||||
void complete.finally(async () => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -253,8 +258,13 @@
|
||||
if (isDirectNavigation) {
|
||||
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,7 +274,7 @@
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -561,19 +571,6 @@
|
||||
|
||||
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
|
||||
};
|
||||
|
||||
const _onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -604,6 +601,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -683,11 +681,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toAssetViewerTransitionId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{monthGroup}
|
||||
manager={timelineManager}
|
||||
onDayGroupSelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||
@@ -701,12 +699,56 @@
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
onClick={async (asset) => {
|
||||
const onClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
asset: TimelineAsset,
|
||||
) => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||
return;
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const dispatchClick = () => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, dayGroup, onClick);
|
||||
} else {
|
||||
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||
}
|
||||
};
|
||||
|
||||
const hasThumbnailClick = typeof onThumbnailClick === 'function';
|
||||
const selectingAssets = isSelectionMode || assetInteraction.selectionActive;
|
||||
|
||||
if (!viewTransitionManager.isSupported() || hasThumbnailClick || selectingAssets) {
|
||||
dispatchClick();
|
||||
return;
|
||||
}
|
||||
|
||||
// tag target on the 'old' snapshot
|
||||
toAssetViewerTransitionId = asset.id;
|
||||
await tick();
|
||||
|
||||
eventManager.once('StartViewTransition', () => {
|
||||
toAssetViewerTransitionId = null;
|
||||
dispatchClick();
|
||||
});
|
||||
|
||||
viewTransitionManager.startTransition(
|
||||
new Promise<void>((resolve) => {
|
||||
eventManager.once('AssetViewerFree', () => {
|
||||
void tick().then(() => {
|
||||
eventManager.emit('TransitionToAssetViewer');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}),
|
||||
['viewer'],
|
||||
);
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -97,6 +98,10 @@
|
||||
};
|
||||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
const awaitInit = new Promise<void>((resolve) => eventManager.once('StartViewTransition', resolve));
|
||||
eventManager.emit('TransitionToTimeline', { id: asset.id });
|
||||
await awaitInit;
|
||||
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
$locale = newLocale;
|
||||
}
|
||||
};
|
||||
|
||||
let editedLocale = $derived(findLocale($locale).code);
|
||||
let selectedDate: string = $derived(createDateFormatter(editedLocale).formatDateTime(time));
|
||||
let selectedOption = $derived({
|
||||
|
||||
@@ -4,19 +4,46 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||
|
||||
type AssetLoadState = 'loading' | 'cancelled';
|
||||
|
||||
class ImageManager {
|
||||
// track recently canceled assets, so know if an load "error" is due to
|
||||
// cancelation
|
||||
private assetStates = new Map<string, AssetLoadState>();
|
||||
private readonly MAX_TRACKED_ASSETS = 10;
|
||||
|
||||
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
|
||||
// Remove if exists to reset insertion order
|
||||
this.assetStates.delete(asset.id);
|
||||
this.assetStates.set(asset.id, action);
|
||||
|
||||
// Only keep recent assets (Map maintains insertion order)
|
||||
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
|
||||
const firstKey = this.assetStates.keys().next().value!;
|
||||
this.assetStates.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
isCanceled(asset: AssetResponseDto) {
|
||||
return 'cancelled' === this.assetStates.get(asset.id);
|
||||
}
|
||||
|
||||
trackLoad(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'loading');
|
||||
}
|
||||
|
||||
trackCancelled(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'cancelled');
|
||||
}
|
||||
|
||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
this.trackLoad(asset);
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
|
||||
@@ -24,6 +51,8 @@ class ImageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackCancelled(asset);
|
||||
|
||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||
for (const size of sizes) {
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
@@ -32,12 +61,6 @@ class ImageManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelPreloadUrl(url: string | undefined) {
|
||||
if (url) {
|
||||
cancelImageUrl(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageManager = new ImageManager();
|
||||
|
||||
127
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
127
web/src/lib/managers/ViewTransitionManager.svelte.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function traceTransitionEvents(msg: string, error?: unknown) {
|
||||
// console.log(msg, error);
|
||||
}
|
||||
class ViewTransitionManager {
|
||||
#activeViewTransition = $state<ViewTransition | null>(null);
|
||||
#finishedCallbacks: (() => void)[] = [];
|
||||
|
||||
#splitViewerNavTransitionNames = true;
|
||||
|
||||
constructor() {
|
||||
const root = document.documentElement;
|
||||
const value = getComputedStyle(root).getPropertyValue('--immich-split-viewer-nav').trim();
|
||||
this.#splitViewerNavTransitionNames = value === 'enabled';
|
||||
}
|
||||
|
||||
getTransitionName = (kind: 'old' | 'new', name: string | null | undefined) => {
|
||||
if (name === 'previous' || name === 'next') {
|
||||
return this.#splitViewerNavTransitionNames ? name + '-' + kind : name;
|
||||
} else if (name) {
|
||||
return name;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
get activeViewTransition() {
|
||||
return this.#activeViewTransition;
|
||||
}
|
||||
|
||||
isSupported() {
|
||||
return 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
skipTransitions() {
|
||||
const skippedTransitions = !!this.#activeViewTransition;
|
||||
this.#activeViewTransition?.skipTransition();
|
||||
this.#notifyFinished();
|
||||
return skippedTransitions;
|
||||
}
|
||||
|
||||
startTransition(domUpdateComplete: Promise<unknown>, types?: string[], finishedCallback?: () => unknown) {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('View transition API not available');
|
||||
}
|
||||
if (this.#activeViewTransition) {
|
||||
traceTransitionEvents('Can not start transition - one already active');
|
||||
return;
|
||||
}
|
||||
|
||||
// good time to add view-transition-name styles (if needed)
|
||||
traceTransitionEvents('emit BeforeStartViewTransition');
|
||||
eventManager.emit('BeforeStartViewTransition');
|
||||
|
||||
// next call will create the 'old' view snapshot
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition({
|
||||
update: async () => {
|
||||
// Good time to remove any view-transition-name styles created during
|
||||
// BeforeStartViewTransition, then trigger the actual view transition.
|
||||
traceTransitionEvents('emit StartViewTransition');
|
||||
eventManager.emit('StartViewTransition');
|
||||
|
||||
await domUpdateComplete;
|
||||
traceTransitionEvents('awaited domUpdateComplete');
|
||||
},
|
||||
types,
|
||||
});
|
||||
} catch {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition(async () => {
|
||||
// Good time to remove any view-transition-name styles created during
|
||||
// BeforeStartViewTransition, then trigger the actual view transition.
|
||||
traceTransitionEvents('emit StartViewTransition');
|
||||
eventManager.emit('StartViewTransition');
|
||||
await domUpdateComplete;
|
||||
traceTransitionEvents('awaited domUpdateComplete');
|
||||
});
|
||||
}
|
||||
this.#activeViewTransition = transition;
|
||||
this.#finishedCallbacks.push(() => {
|
||||
this.#activeViewTransition = null;
|
||||
});
|
||||
if (finishedCallback) {
|
||||
this.#finishedCallbacks.push(finishedCallback);
|
||||
}
|
||||
// UpdateCallbackDone is a good time to add any view-transition-name styles
|
||||
// to the new DOM state, before the 'new' view snapshot is creatd
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.updateCallbackDone
|
||||
.then(() => {
|
||||
traceTransitionEvents('emit UpdateCallbackDone');
|
||||
eventManager.emit('UpdateCallbackDone');
|
||||
})
|
||||
.catch((error: unknown) => traceTransitionEvents('error in UpdateCallbackDone', error));
|
||||
// Both old/new snapshots are taken - pseudo elements are created, transition is
|
||||
// about to start
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.ready
|
||||
.then(() => eventManager.emit('Ready'))
|
||||
.catch((error: unknown) => {
|
||||
this.#notifyFinished();
|
||||
traceTransitionEvents('error in Ready', error);
|
||||
});
|
||||
// Transition is complete
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition.finished
|
||||
.then(() => {
|
||||
traceTransitionEvents('emit Finished');
|
||||
eventManager.emit('Finished');
|
||||
})
|
||||
.catch((error: unknown) => traceTransitionEvents('error in Finished', error));
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.finished.then(() => this.#notifyFinished());
|
||||
}
|
||||
|
||||
#notifyFinished() {
|
||||
for (const callback of this.#finishedCallbacks) {
|
||||
callback();
|
||||
}
|
||||
this.#finishedCallbacks = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const viewTransitionManager = new ViewTransitionManager();
|
||||
13
web/src/lib/managers/app-manager.svelte.ts
Normal file
13
web/src/lib/managers/app-manager.svelte.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
class AppManager {
|
||||
#isAssetViewer = $state<boolean>(false);
|
||||
|
||||
set isAssetViewer(value: boolean) {
|
||||
this.#isAssetViewer = value;
|
||||
}
|
||||
|
||||
get isAssetViewer() {
|
||||
return this.#isAssetViewer;
|
||||
}
|
||||
}
|
||||
|
||||
export const appManager = new AppManager();
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readonly, writable } from 'svelte/store';
|
||||
|
||||
function createAssetViewingStore() {
|
||||
const viewingAssetStoreState = writable<AssetResponseDto>();
|
||||
const invisible = writable<boolean>(false);
|
||||
const viewState = writable<boolean>(false);
|
||||
const gridScrollTarget = writable<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
@@ -30,6 +31,7 @@ function createAssetViewingStore() {
|
||||
setAsset,
|
||||
setAssetId,
|
||||
showAssetViewer,
|
||||
invisible,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const photoViewerImgElement = writable<HTMLImageElement>();
|
||||
export const isSelectingAllAssets = writable(false);
|
||||
|
||||
@@ -200,13 +200,10 @@ export const getAssetUrl = ({
|
||||
sharedLink,
|
||||
forceOriginal = false,
|
||||
}: {
|
||||
asset: AssetResponseDto | undefined;
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
forceOriginal?: boolean;
|
||||
}) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const id = asset.id;
|
||||
const cacheKey = asset.thumbhash;
|
||||
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
||||
|
||||
247
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
247
web/src/lib/utils/adaptive-image-loader.svelte.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { LoadImageFunction } from '$lib/actions/image-loader.svelte';
|
||||
import { imageManager } from '$lib/managers/ImageManager.svelte';
|
||||
import { getAssetMediaUrl, getAssetUrl } from '$lib/utils';
|
||||
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
|
||||
/**
|
||||
* Quality levels for progressive image loading
|
||||
*/
|
||||
type ImageQuality =
|
||||
| 'basic'
|
||||
| 'loading-thumbnail'
|
||||
| 'thumbnail'
|
||||
| 'loading-preview'
|
||||
| 'preview'
|
||||
| 'loading-original'
|
||||
| 'original';
|
||||
|
||||
export interface ImageLoaderState {
|
||||
previewUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
originalUrl?: string;
|
||||
quality: ImageQuality;
|
||||
hasError: boolean;
|
||||
thumbnailImage: ImageStatus;
|
||||
previewImage: ImageStatus;
|
||||
originalImage: ImageStatus;
|
||||
}
|
||||
enum ImageStatus {
|
||||
Unloaded = 'Unloaded',
|
||||
Success = 'Success',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates adaptive loading of a single asset image:
|
||||
* thumbhash → thumbnail → preview → original (on zoom)
|
||||
*
|
||||
*/
|
||||
export class AdaptiveImageLoader {
|
||||
private state = $state<ImageLoaderState>({
|
||||
quality: 'basic',
|
||||
hasError: false,
|
||||
thumbnailImage: ImageStatus.Unloaded,
|
||||
previewImage: ImageStatus.Unloaded,
|
||||
originalImage: ImageStatus.Unloaded,
|
||||
});
|
||||
|
||||
private readonly currentZoomFn?: () => number;
|
||||
|
||||
private readonly imageLoader?: LoadImageFunction;
|
||||
private readonly destroyFunctions: (() => void)[] = [];
|
||||
readonly thumbnailUrl: string;
|
||||
readonly previewUrl: string;
|
||||
readonly originalUrl: string;
|
||||
readonly asset: AssetResponseDto;
|
||||
readonly callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
|
||||
};
|
||||
destroyed = false;
|
||||
|
||||
constructor(
|
||||
asset: AssetResponseDto,
|
||||
sharedLink: SharedLinkResponseDto | undefined,
|
||||
callbacks?: {
|
||||
currentZoomFn: () => number;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
onQualityUpgrade?: (url: string, quality: ImageQuality) => void;
|
||||
},
|
||||
imageLoader?: LoadImageFunction,
|
||||
) {
|
||||
imageManager.trackLoad(asset);
|
||||
this.asset = asset;
|
||||
this.callbacks = callbacks;
|
||||
|
||||
this.imageLoader = imageLoader;
|
||||
this.thumbnailUrl = getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, size: AssetMediaSize.Thumbnail });
|
||||
this.previewUrl = getAssetUrl({ asset, sharedLink });
|
||||
this.originalUrl = getAssetUrl({ asset, sharedLink, forceOriginal: true });
|
||||
this.state.thumbnailUrl = this.thumbnailUrl;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.imageLoader) {
|
||||
throw new Error('Start requires imageLoader to be specified');
|
||||
}
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.thumbnailUrl,
|
||||
{},
|
||||
() => this.onThumbnailLoad(),
|
||||
() => this.onThumbnailError(),
|
||||
() => this.onThumbnailStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get adaptiveLoaderState(): ImageLoaderState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
onThumbnailStart() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-thumbnail';
|
||||
}
|
||||
|
||||
onThumbnailLoad() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'thumbnail';
|
||||
this.state.thumbnailImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
this.callbacks?.onQualityUpgrade?.(this.thumbnailUrl, 'thumbnail');
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
onThumbnailError() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.hasError = true;
|
||||
this.state.thumbnailUrl = undefined;
|
||||
this.state.thumbnailImage = ImageStatus.Error;
|
||||
this.callbacks?.onError?.();
|
||||
this.triggerMainImage();
|
||||
}
|
||||
|
||||
triggerMainImage() {
|
||||
const wantsOriginal = (this.currentZoomFn?.() ?? 1) > 1;
|
||||
return wantsOriginal ? this.triggerOriginal() : this.triggerPreview();
|
||||
}
|
||||
|
||||
triggerPreview() {
|
||||
if (!this.previewUrl) {
|
||||
// no preview, try original?
|
||||
this.triggerOriginal();
|
||||
return false;
|
||||
}
|
||||
this.state.hasError = false;
|
||||
this.state.previewUrl = this.previewUrl;
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.previewUrl,
|
||||
{},
|
||||
() => this.onPreviewLoad(),
|
||||
() => this.onPreviewError(),
|
||||
() => this.onPreviewStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onPreviewStart() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-preview';
|
||||
}
|
||||
|
||||
onPreviewLoad() {
|
||||
if (this.destroyed) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'preview';
|
||||
this.state.previewImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
this.callbacks?.onQualityUpgrade?.(this.previewUrl, 'preview');
|
||||
}
|
||||
|
||||
onPreviewError() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.hasError = true;
|
||||
this.state.previewImage = ImageStatus.Error;
|
||||
this.state.previewUrl = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
this.triggerOriginal();
|
||||
}
|
||||
|
||||
triggerOriginal() {
|
||||
if (!this.originalUrl) {
|
||||
return false;
|
||||
}
|
||||
this.state.hasError = false;
|
||||
|
||||
this.state.originalUrl = this.originalUrl;
|
||||
|
||||
if (this.imageLoader) {
|
||||
this.destroyFunctions.push(
|
||||
this.imageLoader(
|
||||
this.originalUrl,
|
||||
{},
|
||||
() => this.onOriginalLoad(),
|
||||
() => this.onOriginalError(),
|
||||
() => this.onOriginalStart(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onOriginalStart() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'loading-original';
|
||||
}
|
||||
|
||||
onOriginalLoad() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
this.state.quality = 'original';
|
||||
this.state.originalImage = ImageStatus.Success;
|
||||
this.callbacks?.onImageReady?.();
|
||||
}
|
||||
|
||||
onOriginalError() {
|
||||
if (this.destroyed || imageManager.isCanceled(this.asset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.hasError = true;
|
||||
this.state.originalImage = ImageStatus.Error;
|
||||
this.state.originalUrl = undefined;
|
||||
this.callbacks?.onError?.();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
if (this.imageLoader) {
|
||||
for (const destroy of this.destroyFunctions) {
|
||||
destroy();
|
||||
}
|
||||
return;
|
||||
}
|
||||
imageManager.cancel(this.asset);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
/**
|
||||
* Tracks the state of asynchronous invocations to handle race conditions and stale operations.
|
||||
* This class helps manage concurrent operations by tracking which invocations are active
|
||||
@@ -53,14 +51,19 @@ export class InvocationTracker {
|
||||
return this.invocationsStarted !== this.invocationsEnded;
|
||||
}
|
||||
|
||||
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
|
||||
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
|
||||
const invocation = this.startInvocation();
|
||||
try {
|
||||
return await invocable();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, localizedMessage);
|
||||
if (catchCallback) {
|
||||
catchCallback(error);
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
invocation.endInvocation();
|
||||
finallyCallback?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,3 +129,19 @@ export type CommonPosition = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Scales dimensions to fit within a container (like object-fit: contain)
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
) => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
import { ServiceWorkerMessenger } from './sw-messenger';
|
||||
|
||||
const hasServiceWorker = globalThis.isSecureContext && 'serviceWorker' in navigator;
|
||||
// eslint-disable-next-line compat/compat
|
||||
const messenger = hasServiceWorker ? new ServiceWorkerMessenger(navigator.serviceWorker) : undefined;
|
||||
|
||||
export function cancelImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
if (!url || !messenger) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'cancel', url });
|
||||
}
|
||||
export function preloadImageUrl(url: string | undefined | null) {
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
broadcast.postMessage({ type: 'preload', url });
|
||||
messenger.send('cancel', { url });
|
||||
}
|
||||
|
||||
17
web/src/lib/utils/sw-messenger.ts
Normal file
17
web/src/lib/utils/sw-messenger.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class ServiceWorkerMessenger {
|
||||
readonly #serviceWorker: ServiceWorkerContainer;
|
||||
|
||||
constructor(serviceWorker: ServiceWorkerContainer) {
|
||||
this.#serviceWorker = serviceWorker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a one-way message to the service worker.
|
||||
*/
|
||||
send(type: string, data: Record<string, unknown>) {
|
||||
this.#serviceWorker.controller?.postMessage({
|
||||
type,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:display-none={$showAssetViewer}>
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<UploadCover />
|
||||
@@ -33,7 +33,4 @@
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import DownloadPanel from '$lib/components/asset-viewer/download-panel.svelte';
|
||||
@@ -8,6 +8,7 @@
|
||||
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
|
||||
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
|
||||
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
|
||||
import { appManager } from '$lib/managers/app-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
@@ -58,6 +59,8 @@
|
||||
|
||||
let showNavigationLoadingBar = $state(false);
|
||||
|
||||
appManager.isAssetViewer = isAssetViewerRoute(page);
|
||||
|
||||
const getMyImmichLink = () => {
|
||||
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
|
||||
};
|
||||
@@ -83,8 +86,15 @@
|
||||
showNavigationLoadingBar = true;
|
||||
});
|
||||
|
||||
afterNavigate(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
onNavigate(({ to }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
});
|
||||
|
||||
afterNavigate(({ to, complete }) => {
|
||||
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
|
||||
void complete.finally(() => {
|
||||
showNavigationLoadingBar = false;
|
||||
});
|
||||
});
|
||||
|
||||
const { serverRestarting } = websocketStore;
|
||||
|
||||
@@ -1,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();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { handleCancel, handlePreload } from './request';
|
||||
|
||||
export const installBroadcastChannelListener = () => {
|
||||
const broadcast = new BroadcastChannel('immich');
|
||||
// eslint-disable-next-line unicorn/prefer-add-event-listener
|
||||
broadcast.onmessage = (event) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(event.data.url, event.origin);
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'preload': {
|
||||
handlePreload(url);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'cancel': {
|
||||
handleCancel(url);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { version } from '$service-worker';
|
||||
|
||||
const CACHE = `cache-${version}`;
|
||||
|
||||
let _cache: Cache | undefined;
|
||||
const getCache = async () => {
|
||||
if (_cache) {
|
||||
return _cache;
|
||||
}
|
||||
_cache = await caches.open(CACHE);
|
||||
return _cache;
|
||||
};
|
||||
|
||||
export const get = async (key: string) => {
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
return cache.match(key);
|
||||
};
|
||||
|
||||
export const put = async (key: string, response: Response) => {
|
||||
if (response.status !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = await getCache();
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
|
||||
cache.put(key, response.clone());
|
||||
};
|
||||
|
||||
export const prune = async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key !== CACHE) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2,9 +2,9 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
import { installBroadcastChannelListener } from './broadcast-channel';
|
||||
import { prune } from './cache';
|
||||
import { handleRequest } from './request';
|
||||
|
||||
import { installMessageListener } from './messaging';
|
||||
import { handleFetch as handleAssetFetch } from './request';
|
||||
|
||||
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
|
||||
|
||||
@@ -12,12 +12,10 @@ const sw = globalThis as unknown as ServiceWorkerGlobalScope;
|
||||
|
||||
const handleActivate = (event: ExtendableEvent) => {
|
||||
event.waitUntil(sw.clients.claim());
|
||||
event.waitUntil(prune());
|
||||
};
|
||||
|
||||
const handleInstall = (event: ExtendableEvent) => {
|
||||
event.waitUntil(sw.skipWaiting());
|
||||
// do not preload app resources
|
||||
};
|
||||
|
||||
const handleFetch = (event: FetchEvent): void => {
|
||||
@@ -28,7 +26,7 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
// Cache requests for thumbnails
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin === self.location.origin && ASSET_REQUEST_REGEX.test(url.pathname)) {
|
||||
event.respondWith(handleRequest(event.request));
|
||||
event.respondWith(handleAssetFetch(event.request));
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -36,4 +34,4 @@ const handleFetch = (event: FetchEvent): void => {
|
||||
sw.addEventListener('install', handleInstall, { passive: true });
|
||||
sw.addEventListener('activate', handleActivate, { passive: true });
|
||||
sw.addEventListener('fetch', handleFetch, { passive: true });
|
||||
installBroadcastChannelListener();
|
||||
installMessageListener();
|
||||
|
||||
33
web/src/service-worker/messaging.ts
Normal file
33
web/src/service-worker/messaging.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,73 +1,69 @@
|
||||
import { get, put } from './cache';
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
const pendingRequests = new Map<string, AbortController>();
|
||||
|
||||
const isURL = (request: URL | RequestInfo): request is URL => (request as URL).href !== undefined;
|
||||
const isRequest = (request: RequestInfo): request is Request => (request as Request).url !== undefined;
|
||||
|
||||
const assertResponse = (response: Response) => {
|
||||
if (!(response instanceof Response)) {
|
||||
throw new TypeError('Fetch did not return a valid Response object');
|
||||
}
|
||||
type PendingRequest = {
|
||||
controller: AbortController;
|
||||
promise: Promise<Response>;
|
||||
cleanupTimeout?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const getCacheKey = (request: URL | Request) => {
|
||||
if (isURL(request)) {
|
||||
return request.toString();
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
|
||||
const getRequestKey = (request: URL | Request): string => (request instanceof URL ? request.href : request.url);
|
||||
|
||||
const CANCELATION_MESSAGE = 'Request canceled by application';
|
||||
const CLEANUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export const handleFetch = (request: URL | Request): Promise<Response> => {
|
||||
const requestKey = getRequestKey(request);
|
||||
const existing = pendingRequests.get(requestKey);
|
||||
|
||||
if (existing) {
|
||||
// Clone the response since response bodies can only be read once
|
||||
// Each caller gets an independent clone they can consume
|
||||
return existing.promise.then((response) => response.clone());
|
||||
}
|
||||
|
||||
if (isRequest(request)) {
|
||||
return request.url;
|
||||
}
|
||||
const pendingRequest: PendingRequest = {
|
||||
controller: new AbortController(),
|
||||
promise: undefined as unknown as Promise<Response>,
|
||||
cleanupTimeout: undefined,
|
||||
};
|
||||
pendingRequests.set(requestKey, pendingRequest);
|
||||
|
||||
throw new Error(`Invalid request: ${request}`);
|
||||
};
|
||||
// NOTE: fetch returns after headers received, not the body
|
||||
pendingRequest.promise = fetch(request, { signal: pendingRequest.controller.signal })
|
||||
.catch((error: unknown) => {
|
||||
const standardError = error instanceof Error ? error : new Error(String(error));
|
||||
if (standardError.name === 'AbortError' || standardError.message === CANCELATION_MESSAGE) {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
throw standardError;
|
||||
})
|
||||
.finally(() => {
|
||||
// Schedule cleanup after timeout to allow response body streaming to complete
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestKey);
|
||||
}, CLEANUP_TIMEOUT_MS);
|
||||
pendingRequest.cleanupTimeout = cleanupTimeout;
|
||||
});
|
||||
|
||||
export const handlePreload = async (request: URL | Request) => {
|
||||
try {
|
||||
return await handleRequest(request);
|
||||
} catch (error) {
|
||||
console.error(`Preload failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRequest = async (request: URL | Request) => {
|
||||
const cacheKey = getCacheKey(request);
|
||||
const cachedResponse = await get(cacheKey);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
try {
|
||||
const cancelToken = new AbortController();
|
||||
pendingRequests.set(cacheKey, cancelToken);
|
||||
const response = await fetch(request, { signal: cancelToken.signal });
|
||||
|
||||
assertResponse(response);
|
||||
put(cacheKey, response);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// dummy response avoids network errors in the console for these requests
|
||||
return new Response(undefined, { status: 204 });
|
||||
}
|
||||
|
||||
console.log('Not an abort error', error);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
pendingRequests.delete(cacheKey);
|
||||
}
|
||||
// Clone for the first caller to keep the original response unconsumed for future callers
|
||||
return pendingRequest.promise.then((response) => response.clone());
|
||||
};
|
||||
|
||||
export const handleCancel = (url: URL) => {
|
||||
const cacheKey = getCacheKey(url);
|
||||
const pendingRequest = pendingRequests.get(cacheKey);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
const requestKey = getRequestKey(url);
|
||||
|
||||
pendingRequest.abort();
|
||||
pendingRequests.delete(cacheKey);
|
||||
const pendingRequest = pendingRequests.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
pendingRequest.controller.abort(CANCELATION_MESSAGE);
|
||||
if (pendingRequest.cleanupTimeout) {
|
||||
clearTimeout(pendingRequest.cleanupTimeout);
|
||||
}
|
||||
pendingRequests.delete(requestKey);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user