Compare commits

..

2 Commits

Author SHA1 Message Date
Alex
99a8740f1b only migrate for images 2026-01-27 22:37:12 -06:00
Alex
a781c78caf fix: width and height migration issue 2026-01-27 21:48:19 -06:00
66 changed files with 815 additions and 3064 deletions

View File

@@ -1,7 +1,10 @@
import { AssetMediaResponseDto, LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { Page, 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;
@@ -23,44 +26,31 @@ test.describe('Photo Viewer', () => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
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();
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).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(original).toBeInViewport();
await expect(original).toHaveAttribute('src', /original/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
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();
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).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(original).toHaveAttribute('src', /fullsize/);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
});
test('reloads photo when checksum changes', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
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 expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const initialSrc = await imageLocator(page).getAttribute('src');
await utils.replaceAsset(admin.accessToken, asset.id);
await expect(preview).not.toHaveAttribute('src', initialSrc!);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).not.toBe(initialSrc);
});
});

View File

@@ -1,116 +0,0 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { assetViewerUtils } from 'src/web/specs/timeline/utils';
const buildSearchUrl = (assetId: string) => {
const searchQuery = encodeURIComponent(JSON.stringify({ originalFileName: 'test' }));
return `/search/photos/${assetId}?query=${searchQuery}`;
};
test.describe.configure({ mode: 'parallel' });
test.describe('search gallery-viewer', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: {
total: searchAssets.length,
count: searchAssets.length,
items: searchAssets,
facets: [],
nextPage: null,
},
},
});
}
await route.fallback();
});
});
test.afterEach(() => {
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/search/photos/:id', () => {
test('Deleting a photo advances to the next photo', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
});
test('Deleting two photos in a row advances to the next photo each time', async ({ page }) => {
const asset = assets[0];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[1]);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Navigating backward then deleting advances to the next photo', async ({ page }) => {
const asset = assets[1];
await page.goto(buildSearchUrl(asset.id));
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[2]);
});
test('Deleting the last photo advances to the previous photo', async ({ page }) => {
const lastAsset = assets[4];
await page.goto(buildSearchUrl(lastAsset.id));
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
await expect(page.getByLabel('View next asset')).toHaveCount(0);
await page.getByLabel('Delete').click();
await assetViewerUtils.waitForViewerLoad(page, assets[3]);
await expect(page.getByLabel('View previous asset')).toBeVisible();
});
});
});

View File

@@ -65,7 +65,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container][data-selected]');
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,8 +103,11 @@ 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}"][data-selected]`),
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {

View File

@@ -75,7 +75,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
pnpm --prefix server run build
( cd ./open-api && bash ./bin/generate-open-api.sh )
uv version --directory machine-learning "$NEXT_SERVER"
uvx --from=toml-cli toml set --toml-path=machine-learning/pyproject.toml project.version "$NEXT_SERVER"
./misc/release/archive-version.js "$NEXT_SERVER"
fi

View File

@@ -1,18 +1,5 @@
experimental_monorepo_root = true
[monorepo]
config_roots = [
"plugins",
"server",
"cli",
"deployment",
"mobile",
"e2e",
"web",
"docs",
".github",
]
[tools]
node = "24.13.0"
flutter = "3.35.7"

View File

@@ -741,7 +741,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -885,7 +885,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -915,7 +915,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -949,7 +949,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -992,7 +992,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1032,7 +1032,7 @@
CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1071,7 +1071,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1115,7 +1115,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -1156,7 +1156,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
CURRENT_PROJECT_VERSION = 233;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -107,7 +107,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>240</string>
<string>233</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -39,14 +39,6 @@ iOS Release to TestFlight
iOS Manual Release
### ios gha_build_only
```sh
[bundle exec] fastlane ios gha_build_only
```
iOS Build Only (no TestFlight upload)
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.

View File

@@ -98,7 +98,12 @@ class AssetService {
height = fetched?.height?.toDouble();
}
return (width: width, height: height, isFlipped: false);
// Check exif for orientation to determine if dimensions should be flipped
// This is important for videos where raw file dimensions may not match display dimensions
final exif = await _remoteAssetRepository.getExif(asset.id);
final isFlipped = exif?.isFlipped ?? false;
return (width: width, height: height, isFlipped: isFlipped);
}
Future<List<(String, String)>> getPlaces(String userId) {

View File

@@ -284,12 +284,16 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
try {
// Only backfill images (type = 1), not videos
// Videos have different dimension handling based on orientation/exif
await db.customStatement('''
UPDATE remote_exif_entity AS remote_exif
SET width = asset.width,
height = asset.height
FROM remote_asset_entity AS asset
WHERE remote_exif.asset_id = asset.id;
WHERE remote_exif.asset_id = asset.id
AND asset.type = 1
AND (remote_exif.width IS NULL OR remote_exif.width = 0 OR remote_exif.height IS NULL OR remote_exif.height = 0);
''');
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");

View File

@@ -118,7 +118,13 @@ abstract final class TestUtils {
return result;
}
static domain.RemoteAsset createRemoteAsset({required String id, int? width, int? height, String? ownerId}) {
static domain.RemoteAsset createRemoteAsset({
required String id,
int? width,
int? height,
String? ownerId,
String? localId,
}) {
return domain.RemoteAsset(
id: id,
checksum: 'checksum1',
@@ -132,6 +138,7 @@ abstract final class TestUtils {
width: width,
height: height,
isEdited: false,
localId: localId,
);
}

23
pnpm-lock.yaml generated
View File

@@ -771,8 +771,11 @@ importers:
specifier: ^7946.0.16
version: 7946.0.16
'@zoom-image/core':
specifier: ^0.42.0
version: 0.42.0
specifier: ^0.41.0
version: 0.41.4
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.48.0)
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -5678,8 +5681,13 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
'@zoom-image/core@0.42.0':
resolution: {integrity: sha512-aF7siQqxqmOVlBd65deaCM7L/6V80Rp7HazZJpxtErh8zAn5itXXKBv1KA1NufSPfRZsXl1QtysxkjB3gVIzxw==}
'@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
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
@@ -18667,10 +18675,15 @@ snapshots:
'@xtuc/long@4.2.2': {}
'@zoom-image/core@0.42.0':
'@zoom-image/core@0.41.4':
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.8(svelte@5.48.0)':
dependencies:
'@zoom-image/core': 0.41.4
svelte: 5.48.0
abab@2.0.6:
optional: true

View File

@@ -79,7 +79,7 @@ export class MaintenanceWorkerService {
this.#secret = state.secret;
this.#status = {
active: true,
action: state.action?.action ?? MaintenanceAction.Start,
action: state.action.action,
};
StorageCore.setMediaLocation(this.detectMediaLocation());
@@ -88,10 +88,7 @@ export class MaintenanceWorkerService {
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
await this.logSecret();
if (state.action) {
void this.runAction(state.action);
}
void this.runAction(state.action);
}
/**

View File

@@ -490,7 +490,7 @@ export interface MemoryData {
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MaintenanceModeState =
| { isMaintenanceMode: true; secret: string; action?: SetMaintenanceModeDto }
| { isMaintenanceMode: true; secret: string; action: SetMaintenanceModeDto }
| { isMaintenanceMode: false };
export type MemoriesState = {
/** memories have already been created through this date */

View File

@@ -37,7 +37,8 @@
"@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.42.0",
"@zoom-image/core": "^0.41.0",
"@zoom-image/svelte": "^0.3.0",
"dom-to-image": "^2.6.0",
"fabric": "^6.5.4",
"geo-coordinates-parser": "^1.7.4",

View File

@@ -74,19 +74,6 @@
--immich-dark-bg: 10 10 10;
--immich-dark-fg: 229 231 235;
--immich-dark-gray: 33 33 33;
/* transitions */
--immich-split-viewer-nav: enabled;
/* view transition variables */
--vt-duration-default: 250ms;
--vt-duration-hero: 280ms;
--vt-duration-viewer-navigation: 270ms;
--vt-duration-slideshow: 1s;
--vt-viewer-slide-easing: cubic-bezier(0.2, 0, 0, 1);
--vt-viewer-slide-distance: 15%;
--vt-viewer-opacity-start: 0.1;
--vt-viewer-blur-max: 4px;
}
button:not(:disabled),
@@ -184,318 +171,3 @@
@apply bg-subtle rounded-lg;
}
}
@layer base {
::view-transition {
background: var(--color-black);
animation-duration: var(--vt-duration-default);
}
::view-transition-old(*),
::view-transition-new(*) {
mix-blend-mode: normal;
animation-duration: inherit;
}
::view-transition-old(*) {
animation-name: fadeOut;
animation-fill-mode: forwards;
}
::view-transition-new(*) {
animation-name: fadeIn;
animation-fill-mode: forwards;
}
::view-transition-old(root) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
::view-transition-new(root) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
html:active-view-transition-type(slideshow) {
&::view-transition-old(root) {
animation: var(--vt-duration-slideshow) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-slideshow) 0s fadeIn forwards;
}
}
html:active-view-transition-type(viewer-nav) {
&::view-transition-old(root) {
animation: var(--vt-duration-hero) 0s fadeOut forwards;
}
&::view-transition-new(root) {
animation: var(--vt-duration-hero) 0s fadeIn forwards;
}
}
::view-transition-old(info) {
animation: var(--vt-duration-default) 0s flyOutRight forwards;
}
::view-transition-new(info) {
animation: var(--vt-duration-default) 0s flyInRight forwards;
}
::view-transition-group(detail-panel) {
z-index: 1;
}
::view-transition-old(detail-panel),
::view-transition-new(detail-panel) {
animation: none;
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
animation-duration: var(--vt-duration-viewer-navigation);
z-index: 4;
}
::view-transition-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: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutLeft forwards;
overflow: hidden;
}
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInRight forwards;
overflow: hidden;
}
::view-transition-old(previous) {
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutRight forwards;
}
::view-transition-old(previous-old) {
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyOutRight forwards;
overflow: hidden;
z-index: -1;
}
::view-transition-new(previous) {
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInLeft forwards;
}
::view-transition-new(previous-new) {
animation: var(--vt-duration-viewer-navigation) var(--vt-viewer-slide-easing) flyInLeft forwards;
overflow: hidden;
}
@keyframes flyInLeft {
from {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutLeft {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(calc(-1 * var(--vt-viewer-slide-distance)));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
@keyframes flyInRight {
from {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
to {
opacity: 1;
filter: blur(0);
}
}
@keyframes flyOutRight {
from {
opacity: 1;
filter: blur(0);
}
to {
transform: translateX(var(--vt-viewer-slide-distance));
opacity: var(--vt-viewer-opacity-start);
filter: blur(var(--vt-viewer-blur-max));
}
}
/* cubic fade curves so combined opacity stays close to 1.0 during crossfade */
@keyframes fadeIn {
from {
opacity: 0;
}
50% {
opacity: 0.85;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
50% {
opacity: 0.85;
}
to {
opacity: 0;
}
}
/* Reduced motion: when system preference is set */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(hero) {
animation-name: none;
}
::view-transition-old(hero) {
animation: none;
display: none;
}
::view-transition-new(hero) {
animation: none;
}
html:active-view-transition-type(viewer) {
&::view-transition-old(hero) {
animation: none;
display: none;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
html:active-view-transition-type(timeline) {
&::view-transition-old(hero) {
animation: var(--vt-duration-default) 0s fadeOut forwards;
}
&::view-transition-new(hero) {
animation: var(--vt-duration-default) 0s fadeIn forwards;
}
}
::view-transition-group(letterbox-left),
::view-transition-group(letterbox-right),
::view-transition-group(letterbox-top),
::view-transition-group(letterbox-bottom) {
z-index: 100;
}
::view-transition-image-pair(letterbox-left),
::view-transition-image-pair(letterbox-right),
::view-transition-image-pair(letterbox-top),
::view-transition-image-pair(letterbox-bottom) {
isolation: auto;
}
::view-transition-old(letterbox-left),
::view-transition-old(letterbox-right),
::view-transition-old(letterbox-top),
::view-transition-old(letterbox-bottom),
::view-transition-new(letterbox-left),
::view-transition-new(letterbox-right),
::view-transition-new(letterbox-top),
::view-transition-new(letterbox-bottom) {
animation: none;
width: 100%;
height: 100%;
object-fit: fill;
}
::view-transition-group(previous),
::view-transition-group(previous-old),
::view-transition-group(next),
::view-transition-group(next-old) {
width: 100% !important;
height: 100% !important;
transform: none !important;
}
::view-transition-old(previous),
::view-transition-old(previous-old),
::view-transition-old(next),
::view-transition-old(next-old) {
animation: var(--vt-duration-viewer-navigation) fadeOut forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
overflow: hidden;
}
::view-transition-new(previous),
::view-transition-new(previous-new),
::view-transition-new(next),
::view-transition-new(next-new) {
animation: var(--vt-duration-viewer-navigation) fadeIn forwards;
transform-origin: center;
height: 100%;
width: 100%;
object-fit: contain;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
<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';
@@ -13,7 +12,7 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
@@ -22,15 +21,14 @@
import { user } from '$lib/stores/user.store';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { AdaptiveImageLoader } from '$lib/utils/adaptive-image-loader.svelte';
import { navigateToAsset } from '$lib/utils/asset-utils';
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,
@@ -38,9 +36,9 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { onDestroy, onMount, tick, untrack } from 'svelte';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly, slide } from 'svelte/transition';
import { fly } from 'svelte/transition';
import Thumbnail from '../assets/thumbnail/thumbnail.svelte';
import ActivityStatus from './activity-status.svelte';
import ActivityViewer from './activity-viewer.svelte';
@@ -89,31 +87,27 @@
onRandom,
}: Props = $props();
const { setAssetId, invisible } = assetViewingStore;
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
slideshowNavigation,
slideshowState,
slideshowTransition,
slideshowRepeat,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
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 asset = $derived(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 slideShowPlaying = $derived($slideshowState === SlideshowState.PlaySlideshow);
let slideShowAscending = $derived($slideshowNavigation === SlideshowNavigation.AscendingOrder);
let slideShowShuffle = $derived($slideshowNavigation === SlideshowNavigation.Shuffle);
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
@@ -123,50 +117,35 @@
};
const refreshStack = async () => {
if (authManager.isSharedLink || !withStacked) {
if (authManager.isSharedLink) {
return;
}
if (!cursor.current.stack) {
stack = undefined;
return;
if (asset.stack) {
stack = await getStack({ id: asset.stack.id });
}
stack = await getStack({ id: cursor.current.stack.id });
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
}
untrack(() => {
imageManager.preload(stack?.assets[1]);
});
};
const handleFavorite = async () => {
if (!album || !album.isActivityEnabled) {
return;
}
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
if (album && album.isActivityEnabled) {
try {
await activityManager.toggleLike();
} catch (error) {
handleError(error, $t('errors.unable_to_change_favorite'));
}
}
};
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;
};
onMount(async () => {
unsubscribes.push(
eventManager.onMany({
TransitionToAssetViewer: addInfoTransition,
TransitionToTimeline: addInfoTransition,
Finished: finished,
}),
slideshowState.subscribe((value) => {
if (value === SlideshowState.PlaySlideshow) {
slideshowHistory.reset();
@@ -183,230 +162,93 @@
}
}),
);
if (!sharedLink) {
await handleGetAllAlbums();
}
});
onDestroy(() => {
activityManager.reset();
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
destroyNextPreloader();
destroyPreviousPreloader();
activityManager.reset();
});
const handleGetAllAlbums = async () => {
if (authManager.isSharedLink) {
return;
}
try {
appearsInAlbums = await getAllAlbums({ assetId: asset.id });
} catch (error) {
console.error('Error getting album that asset belong to', error);
}
};
const closeViewer = () => {
transitionName = 'hero';
onClose?.(asset);
};
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
console.log(asset);
const refreshedAsset = await getAssetInfo({ id: asset.id });
console.log(refreshedAsset);
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}
assetViewerManager.closeEditor();
};
const 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);
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) => {
const navigateAsset = (order?: 'previous' | 'next', e?: Event) => {
if (!order) {
if (slideShowPlaying) {
order = slideShowAscending ? 'previous' : 'next';
if ($slideshowState === SlideshowState.PlaySlideshow) {
order = $slideshowNavigation === SlideshowNavigation.AscendingOrder ? 'previous' : 'next';
} else {
return;
}
}
e?.stopPropagation();
imageManager.cancel(asset);
if (tracker.isActive()) {
return;
}
void tracker.invoke(
() => completeNavigation(order, skipTransition),
(error: unknown) => handleError(error, $t('error_while_navigating')),
() => eventManager.emit('AssetViewerAfterNavigate'),
);
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'));
};
/**
@@ -437,11 +279,10 @@
const handleStopSlideshow = async () => {
try {
if (!document.fullscreenElement) {
return;
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
}
document.body.style.cursor = '';
await document.exitFullscreen();
} catch (error) {
handleError(error, $t('errors.unable_to_exit_fullscreen'));
} finally {
@@ -450,24 +291,16 @@
}
};
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
if (isMouseOver) {
previewStackedAsset = stackedAsset;
}
const handleStackedAssetMouseEvent = (isMouseOver: boolean, asset: AssetResponseDto) => {
previewStackedAsset = isMouseOver ? asset : undefined;
};
const handleStackedAssetsMouseLeave = () => {
previewStackedAsset = undefined;
};
const handlePreAction = (action: Action) => {
preAction?.(action);
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ADD_TO_ALBUM: {
eventManager.emit('AlbumAddAssets');
await handleGetAllAlbums();
break;
}
case AssetAction.DELETE:
@@ -477,10 +310,9 @@
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (!stack) {
break;
if (stack) {
cursor.current = stack.assets[0];
}
cursor.current = stack.assets[0];
break;
}
case AssetAction.STACK:
@@ -528,46 +360,21 @@
const refresh = async () => {
await refreshStack();
await handleGetAllAlbums();
ocrManager.clear();
if (sharedLink) {
return;
if (!sharedLink) {
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
}
if (previewStackedAsset) {
await ocrManager.getAssetOcr(previewStackedAsset.id);
}
await ocrManager.getAssetOcr(asset.id);
};
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
cursor.current;
asset;
untrack(() => handlePromiseError(refresh()));
});
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;
imageManager.preload(cursor.nextAsset);
imageManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
@@ -585,11 +392,9 @@
}
};
const handleAssetViewerFree = () => eventManager.emit('AssetViewerFree');
const viewerKind = $derived.by(() => {
if (previewStackedAsset) {
return asset.type === AssetTypeEnum.Image ? 'PhotoViewer' : 'StackVideoViewer';
return asset.type === AssetTypeEnum.Image ? 'StackPhotoViewer' : 'StackVideoViewer';
}
if (asset.type === AssetTypeEnum.Video) {
return 'VideoViewer';
@@ -632,16 +437,12 @@
<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"
style:view-transition-name="exclude"
>
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
{album}
@@ -673,70 +474,69 @@
{/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"
style:view-transition-name="exclude-leftbutton"
>
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<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 === 'StackVideoViewer'}
<VideoViewer
{transitionName}
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
cursor={{ ...cursor, current: previewStackedAsset! }}
assetId={previewStackedAsset!.id}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
haveFadeTransition={false}
{sharedLink}
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
asset={previewStackedAsset!}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
onReady={handleAssetViewerFree}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{transitionName}
{cursor}
{asset}
assetId={asset.livePhotoVideoId!}
{sharedLink}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
onReady={handleAssetViewerFree}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} {transitionName} onReady={handleAssetViewerFree} />
<ImagePanoramaViewer {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} onReady={handleAssetViewerFree} />
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
{transitionName}
cursor={{ ...cursor, current: asset }}
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
{sharedLink}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous', true)}
onReady={handleAssetViewerFree}
haveFadeTransition={$slideshowState !== SlideshowState.None && $slideshowTransition}
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
{transitionName}
{cursor}
{sharedLink}
{asset}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
onSwipe={(direction) => navigateAsset(direction === 'left' ? 'next' : 'previous')}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onClose={closeViewer}
onVideoEnded={() => navigateAsset(getNavigationTarget())}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
onReady={handleAssetViewerFree}
{playOriginalVideo}
/>
{/if}
@@ -761,23 +561,19 @@
</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"
style:view-transition-name="exclude-rightbutton"
>
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
<div
transition:slide={{ axis: 'x', duration: 150 }}
transition:fly={{ duration: 150 }}
id="detail-panel"
style:view-transition-name={detailPanelTransitionName}
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
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"
translate="yes"
>
<DetailPanel {asset} currentAlbum={album} />
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{/if}
@@ -795,14 +591,10 @@
{#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
role="presentation"
class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar pointer-events-auto"
onmouseleave={handleStackedAssetsMouseLeave}
>
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
{#each stackedAssets as stackedAsset (stackedAsset.id)}
<div
class={['inline-block px-1 relative transition-all pb-2']}
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
>
<Thumbnail
@@ -811,7 +603,7 @@
dimmed={stackedAsset.id !== asset.id}
asset={toTimelineAsset(stackedAsset)}
onClick={() => {
selectedStackAsset = stackedAsset;
cursor.current = stackedAsset;
previewStackedAsset = undefined;
}}
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}

View File

@@ -7,7 +7,6 @@
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';
@@ -18,16 +17,9 @@
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,
getAllAlbums,
getAssetInfo,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
import {
mdiCalendar,
@@ -42,7 +34,6 @@
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';
@@ -52,10 +43,11 @@
interface Props {
asset: AssetResponseDto;
albums?: AlbumResponseDto[];
currentAlbum?: AlbumResponseDto | null;
}
let { asset, currentAlbum = null }: Props = $props();
let { asset, albums = [], currentAlbum = null }: Props = $props();
let showAssetPath = $state(false);
let showEditFaces = $state(false);
@@ -82,40 +74,14 @@
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) {
return;
if (asset.id !== previousId) {
showEditFaces = false;
previousId = asset.id;
}
showEditFaces = false;
previousId = asset.id;
});
const getMegapixel = (width: number, height: number): number | undefined => {

View File

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

View File

@@ -11,7 +11,7 @@
import { t } from 'svelte-i18n';
interface Props {
htmlElement: HTMLImageElement | HTMLVideoElement | undefined | null;
htmlElement: HTMLImageElement | HTMLVideoElement;
containerWidth: number;
containerHeight: number;
assetId: string;
@@ -78,9 +78,6 @@
});
$effect(() => {
if (!htmlElement) {
return;
}
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,

View File

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

View File

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

View File

@@ -1,9 +1,7 @@
<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 {
@@ -30,30 +28,18 @@
};
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 {
transitionName,
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
onReady,
}: Props = $props();
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: 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[] = [];
@@ -158,7 +144,6 @@
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
@@ -190,19 +175,4 @@
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<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}
/>
<div class="h-full w-full mb-0" bind:this={container}></div>

View File

@@ -1,42 +1,68 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AdaptiveImage from '$lib/components/asset-viewer/adaptive-image.svelte';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import SwipeFeedback from '$lib/components/asset-viewer/swipe-feedback.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.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 { SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, 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 { type SharedLinkResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
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 { 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;
sharedLink?: SharedLinkResponseDto;
transitionName?: string;
onReady?: () => void;
onError?: () => void;
onSwipe?: (direction: 'left' | 'right') => void;
element?: HTMLDivElement | undefined;
haveFadeTransition?: boolean;
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
}
let { cursor, element = $bindable(), sharedLink, transitionName, onReady, onError, onSwipe }: Props = $props();
let {
cursor,
element = $bindable(),
haveFadeTransition = true,
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
}: 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 = [];
});
@@ -84,11 +110,29 @@
handlePromiseError(onCopy());
};
let currentPreviewUrl = $state<string>();
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));
$effect(() => {
if (currentPreviewUrl) {
void cast(currentPreviewUrl);
if (imageLoaderUrl) {
void cast(imageLoaderUrl);
}
});
@@ -106,18 +150,35 @@
}
};
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);
const container = $derived({
width: containerWidth,
height: containerHeight,
});
let swipeFeedbackReset = $state<(() => void) | undefined>();
let lastUrl: string | undefined;
$effect(() => {
// Reset swipe feedback when asset changes
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset.id;
untrack(() => swipeFeedbackReset?.());
if (lastUrl && lastUrl !== imageLoaderUrl) {
untrack(() => {
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
});
}
lastUrl = imageLoaderUrl;
});
</script>
@@ -131,35 +192,46 @@
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
]}
/>
<SwipeFeedback
bind:element
{#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}
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}
>
<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()}
{#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"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
<div
@@ -171,38 +243,23 @@
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
{/snippet}
</AdaptiveImage>
</div>
{#if isFaceEditMode.value && assetViewerManager.imgRef}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{#if isFaceEditMode.value}
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
</div>
{#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>
<style>
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
#broken-asset,
#spinner {
visibility: hidden;
animation: 0s linear 0.4s forwards delayedVisibility;
}
</style>

View File

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

View File

@@ -1,8 +1,5 @@
<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';
@@ -13,90 +10,56 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils';
import { scaleToFit } from '$lib/utils/layout-utils';
import { AssetMediaSize, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
import { fade } from 'svelte/transition';
interface Props {
transitionName?: string;
cursor: AssetCursor;
assetId?: string;
sharedLink?: SharedLinkResponseDto;
assetId: string;
loopVideo: boolean;
cacheKey: string | null;
playOriginalVideo: boolean;
onSwipe?: (direction: 'left' | 'right') => void;
onPreviousAsset?: () => void;
onNextAsset?: () => void;
onVideoEnded?: () => void;
onVideoStarted?: () => void;
onClose?: () => void;
onReady?: () => void;
}
let {
transitionName,
cursor,
assetId,
sharedLink,
loopVideo,
cacheKey,
playOriginalVideo,
onSwipe,
onPreviousAsset = () => {},
onNextAsset = () => {},
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: effectiveAssetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: effectiveAssetId, cacheKey }),
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, 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(() => {
if (assetFileUrl && assetFileUrl !== previousAssetFileUrl) {
previousAssetFileUrl = assetFileUrl;
untrack(() => {
isLoading = true;
videoPlayer?.load();
});
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
videoPlayer?.load();
}
});
@@ -106,14 +69,6 @@
}
});
const handleLoadedMetadata = () => {
dimensions = {
width: videoPlayer?.videoWidth ?? 1,
height: videoPlayer?.videoHeight ?? 1,
};
onReady?.();
};
const handleCanPlay = async (video: HTMLVideoElement) => {
try {
if (!video.paused && !isScrubbing) {
@@ -145,119 +100,76 @@
}
};
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>
<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 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>
{#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}
{#if isLoading}
<div class="absolute flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/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}
{/snippet}
{#snippet rightPreview()}
{#if nextAsset}
<AdaptiveImage
asset={nextAsset}
{sharedLink}
container={{ width: containerWidth, height: containerHeight }}
zoomDisabled={true}
imageClass="object-contain"
slideshowState={$slideshowState}
slideshowLook={$slideshowLook}
/>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
{/if}
{/if}
{/snippet}
</SwipeFeedback>
<style>
video:focus {
outline: none;
}
</style>
</div>
{/if}

View File

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

View File

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

View File

@@ -2,34 +2,24 @@
import { Icon } from '@immich/ui';
import { mdiImageBrokenVariant } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: ClassValue;
class?: string;
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
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,
]}
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
>
{#if clientWidth >= 75}
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full min-w-6 min-h-6" />
{/if}
<Icon icon={mdiImageBrokenVariant} size="7em" class="max-w-full" />
{#if !hideMessage}
<span class="text-center {textClass}">{$t('error_loading_image')}</span>
<span class="text-center">{$t('error_loading_image')}</span>
{/if}
</div>

View File

@@ -1,8 +1,9 @@
<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 {
@@ -48,46 +49,53 @@
loaded = true;
onComplete?.(false);
};
const setErrored = () => {
errored = true;
onComplete?.(true);
};
let optionalClasses = $derived([
function mount(elem: HTMLImageElement): ActionReturn {
if (elem.complete) {
loaded = true;
onComplete?.(false);
}
return {
destroy: () => imageManager.cancelPreloadUrl(url),
};
}
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}
<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>
<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'}
/>
{/if}
{#if hidden}

View File

@@ -200,9 +200,8 @@
<div
class={[
'group focus-visible:outline-none focus-visible:rounded-lg flex overflow-hidden',
'focus-visible:outline-none 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"
@@ -224,10 +223,18 @@
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"
@@ -240,81 +247,13 @@
{ '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 group-focus-visible:rounded-lg',
'absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100',
{ 'rounded-xl': selected },
]}
></div>
@@ -322,10 +261,7 @@
<!-- Dimmed support -->
{#if dimmed && !mouseOver}
<div
id="a"
class={['absolute h-full w-full bg-gray-700/40 group-focus-visible:rounded-lg', { 'rounded-xl': selected }]}
></div>
<div id="a" class={['absolute h-full w-full bg-gray-700/40', { 'rounded-xl': selected }]}></div>
{/if}
<!-- Favorite asset star -->
@@ -393,6 +329,72 @@
>
</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}
@@ -425,14 +427,11 @@
{/if}
</button>
{/if}
<!-- Outline on focus -->
<div
class={[
'pointer-events-none absolute z-1 size-full outline-immich-primary dark:outline-immich-dark-primary group-focus-visible:rounded-lg group-focus-visible:outline-2 group-focus-visible:-outline-offset-2',
{ 'rounded-xl': selected },
]}
data-outline
></div>
</div>
</div>
<style>
[data-asset]:focus > [data-outline] {
outline-style: solid;
}
</style>

View File

@@ -2,7 +2,6 @@
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;
@@ -13,7 +12,6 @@
curve?: boolean;
playIcon?: string;
pauseIcon?: string;
class?: ClassValue;
}
let {
@@ -25,7 +23,6 @@
curve = false,
playIcon = mdiPlayCircleOutline,
pauseIcon = mdiPauseCircleOutline,
class: className,
}: Props = $props();
let remainingSeconds = $state(durationInSeconds);
@@ -60,7 +57,7 @@
{#if enablePlayback}
<video
bind:this={player}
class={['h-full w-full object-cover', className]}
class="h-full w-full object-cover"
class:rounded-xl={curve}
muted
autoplay

View File

@@ -6,11 +6,10 @@
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 {
@@ -45,17 +44,12 @@
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 && !isAssetViewer}
{#if !hideNavbar}
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
{/if}
{#if isAssetViewer}
<div class="max-md:h-(--navbar-height-md) h-(--navbar-height)"></div>
{/if}
</header>
<div
tabindex="-1"
@@ -64,15 +58,13 @@
{hideNavbar ? 'pt-(--navbar-height)' : ''}
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
>
{#if isAssetViewer}
<div></div>
{:else if sidebar}
{#if sidebar}
{@render sidebar()}
{:else}
<UserSidebar />
{/if}
<main class="relative w-full">
<main class="relative">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
{@render children?.()}
</div>

View File

@@ -302,7 +302,6 @@
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
@@ -310,8 +309,10 @@
if (assets.length === 0) {
return await goto(Route.photos());
}
if (nextAsset) {
await navigateToAsset(nextAsset);
if (assetCursor.nextAsset) {
await navigateToAsset(assetCursor.nextAsset);
} else if (assetCursor.previousAsset) {
await navigateToAsset(assetCursor.previousAsset);
}
break;
}

View File

@@ -1,14 +1,20 @@
<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<
[
{
@@ -20,7 +26,10 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { animationTargetAssetId, viewerAssets, width, height, thumbnail, customThumbnailLayout }: Props = $props();
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 filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
@@ -32,20 +41,18 @@
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
{@const transitionName = animationTargetAssetId === asset.id ? 'hero' : undefined}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
data-transition-name={transitionName}
style:view-transition-name={transitionName}
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<!-- animate:flip={{ duration: transitionDuration }} -->
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>

View File

@@ -1,37 +1,34 @@
<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 { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.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 { onMount, tick, type Snippet } from 'svelte';
import 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();
@@ -54,32 +51,6 @@
});
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)}
@@ -124,7 +95,7 @@
</div>
<AssetLayout
{animationTargetAssetId}
{manager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}

View File

@@ -11,7 +11,6 @@
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) */
@@ -40,7 +39,6 @@
}
let {
invisible = false,
timelineTopOffset = 0,
timelineBottomOffset = 0,
height = 0,
@@ -439,7 +437,7 @@
next = forward
? (focusable[(index + 1) % focusable.length] as HTMLElement)
: (focusable[(index - 1) % focusable.length] as HTMLElement);
next?.focus();
next.focus();
}
}
}
@@ -510,7 +508,6 @@
aria-valuemin={toScrollY(0)}
data-id="scrubber"
class="absolute end-0 z-1 select-none hover:cursor-row-resize"
class:invisible
style:padding-top={PADDING_TOP + 'px'}
style:padding-bottom={PADDING_BOTTOM + 'px'}
style:width

View File

@@ -12,8 +12,6 @@
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';
@@ -30,6 +28,7 @@
import { DateTime } from 'luxon';
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
isSelectionMode?: boolean;
singleSelect?: boolean;
@@ -105,7 +104,6 @@
// 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);
@@ -213,7 +211,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = getScrollTarget();
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -225,7 +223,7 @@
await tick();
focusAsset(scrollTarget);
}
invisible = isAssetViewerRoute(page) ? true : false;
invisible = false;
};
// note: only modified once in afterNavigate()
@@ -243,13 +241,10 @@
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(async () => {
void complete.finally(() => {
const isAssetViewerPage = isAssetViewerRoute(page);
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
@@ -258,13 +253,8 @@
if (isDirectNavigation) {
initialLoadWasAssetViewer = isAssetViewerPage && !hasNavigatedToOrFromAssetViewer;
}
void scrollAfterNavigate();
if (!isAssetViewerPage) {
const scrollTarget = getScrollTarget();
await tick();
eventManager.emit('TimelineLoaded', { id: scrollTarget });
}
void scrollAfterNavigate();
});
});
@@ -274,7 +264,7 @@
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting && !isAssetViewerRoute(page)) {
if (!enableRouting) {
invisible = false;
}
});
@@ -571,6 +561,19 @@
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} />
@@ -601,7 +604,6 @@
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
{invisible}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
@@ -681,11 +683,11 @@
style:width="100%"
>
<Month
{toAssetViewerTransitionId}
{assetInteraction}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
@@ -699,56 +701,12 @@
{asset}
{albumUsers}
{groupIndex}
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;
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
// tag target on the 'old' snapshot
toAssetViewerTransitionId = asset.id;
await tick();
eventManager.once('StartViewTransition', () => {
toAssetViewerTransitionId = null;
dispatchClick();
});
viewTransitionManager.startTransition(
new Promise<void>((resolve) => {
eventManager.once('AssetViewerFree', () => {
void tick().then(() => {
eventManager.emit('TransitionToAssetViewer');
resolve();
});
});
}),
['viewer'],
);
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {

View File

@@ -4,7 +4,6 @@
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';
@@ -98,10 +97,6 @@
};
const handleClose = async (asset: { id: string }) => {
const awaitInit = new Promise<void>((resolve) => eventManager.once('StartViewTransition', resolve));
eventManager.emit('TransitionToTimeline', { id: asset.id });
await awaitInit;
assetViewingStore.showAssetViewer(false);
invisible = true;
$gridScrollTarget = { at: asset.id };

View File

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

View File

@@ -4,46 +4,19 @@ 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 src = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
this.trackLoad(asset);
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (!url) {
return;
}
const img = new Image();
img.src = src;
img.src = url;
}
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
@@ -51,8 +24,6 @@ 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 });
@@ -61,6 +32,12 @@ class ImageManager {
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
handleUpdateAlbum,
handleUpdateUserAlbumRole,
} from '$lib/services/album.service';
import { user } from '$lib/stores/user.store';
import {
AlbumUserRole,
AssetOrder,
@@ -107,9 +108,9 @@
<div class="ps-2">
<div class="flex items-center gap-2 mb-2">
<div>
<UserAvatar user={album.owner} size="md" />
<UserAvatar user={$user} size="md" />
</div>
<Text class="w-full" size="small">{album.owner.name}</Text>
<Text class="w-full" size="small">{$user.name}</Text>
<Field disabled class="w-32 shrink-0">
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
</Field>

View File

@@ -5,7 +5,6 @@ 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>();
@@ -31,7 +30,6 @@ function createAssetViewingStore() {
setAsset,
setAssetId,
showAssetViewer,
invisible,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
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
@@ -51,19 +53,14 @@ export class InvocationTracker {
return this.invocationsStarted !== this.invocationsEnded;
}
async invoke<T>(invocable: () => Promise<T>, catchCallback?: (error: unknown) => void, finallyCallback?: () => void) {
async invoke<T>(invocable: () => Promise<T>, localizedMessage: string) {
const invocation = this.startInvocation();
try {
return await invocable();
} catch (error: unknown) {
if (catchCallback) {
catchCallback(error);
} else {
console.error(error);
}
handleError(error, localizedMessage);
} finally {
invocation.endInvocation();
finallyCallback?.();
}
}
}

View File

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

View File

@@ -1,12 +1,14 @@
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;
const broadcast = new BroadcastChannel('immich');
export function cancelImageUrl(url: string | undefined | null) {
if (!url || !messenger) {
if (!url) {
return;
}
messenger.send('cancel', { url });
broadcast.postMessage({ type: 'cancel', url });
}
export function preloadImageUrl(url: string | undefined | null) {
if (!url) {
return;
}
broadcast.postMessage({ type: 'preload', url });
}

View File

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

View File

@@ -24,7 +24,7 @@
});
</script>
<div>
<div class:display-none={$showAssetViewer}>
{@render children?.()}
</div>
<UploadCover />
@@ -33,4 +33,7 @@
:root {
overscroll-behavior: none;
}
.display-none {
display: none;
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@@ -23,6 +24,7 @@
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
@@ -46,6 +48,7 @@
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
let searchResultsElement: HTMLElement | undefined = $state();
@@ -79,6 +82,18 @@
untrack(() => handlePromiseError(onSearchQueryUpdate()));
});
const onEscape = () => {
if ($showAssetViewer) {
return;
}
if (assetInteraction.selectionActive) {
assetInteraction.selectedAssets = [];
return;
}
handlePromiseError(goto(previousRoute));
};
$effect(() => {
if (scrollY) {
scrollYHistory = scrollY;
@@ -245,6 +260,7 @@
</script>
<svelte:window bind:scrollY />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
{#if terms}
<section

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, beforeNavigate, goto, onNavigate } from '$app/navigation';
import { afterNavigate, beforeNavigate, goto } 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,7 +8,6 @@
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';
@@ -59,8 +58,6 @@
let showNavigationLoadingBar = $state(false);
appManager.isAssetViewer = isAssetViewerRoute(page);
const getMyImmichLink = () => {
return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
};
@@ -86,15 +83,8 @@
showNavigationLoadingBar = true;
});
onNavigate(({ to }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
});
afterNavigate(({ to, complete }) => {
appManager.isAssetViewer = isAssetViewerRoute(to) ? true : false;
void complete.finally(() => {
showNavigationLoadingBar = false;
});
afterNavigate(() => {
showNavigationLoadingBar = false;
});
const { serverRestarting } = websocketStore;

View File

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

View File

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

View File

@@ -2,9 +2,9 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { installMessageListener } from './messaging';
import { handleFetch as handleAssetFetch } from './request';
import { installBroadcastChannelListener } from './broadcast-channel';
import { prune } from './cache';
import { handleRequest } from './request';
const ASSET_REQUEST_REGEX = /^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/;
@@ -12,10 +12,12 @@ 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 => {
@@ -26,7 +28,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(handleAssetFetch(event.request));
event.respondWith(handleRequest(event.request));
return;
}
};
@@ -34,4 +36,4 @@ const handleFetch = (event: FetchEvent): void => {
sw.addEventListener('install', handleInstall, { passive: true });
sw.addEventListener('activate', handleActivate, { passive: true });
sw.addEventListener('fetch', handleFetch, { passive: true });
installMessageListener();
installBroadcastChannelListener();

View File

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

View File

@@ -1,69 +1,73 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { get, put } from './cache';
type PendingRequest = {
controller: AbortController;
promise: Promise<Response>;
cleanupTimeout?: ReturnType<typeof setTimeout>;
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');
}
};
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());
const getCacheKey = (request: URL | Request) => {
if (isURL(request)) {
return request.toString();
}
const pendingRequest: PendingRequest = {
controller: new AbortController(),
promise: undefined as unknown as Promise<Response>,
cleanupTimeout: undefined,
};
pendingRequests.set(requestKey, pendingRequest);
if (isRequest(request)) {
return request.url;
}
// 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;
});
throw new Error(`Invalid request: ${request}`);
};
// Clone for the first caller to keep the original response unconsumed for future callers
return pendingRequest.promise.then((response) => response.clone());
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);
}
};
export const handleCancel = (url: URL) => {
const requestKey = getRequestKey(url);
const pendingRequest = pendingRequests.get(requestKey);
if (pendingRequest) {
pendingRequest.controller.abort(CANCELATION_MESSAGE);
if (pendingRequest.cleanupTimeout) {
clearTimeout(pendingRequest.cleanupTimeout);
}
pendingRequests.delete(requestKey);
const cacheKey = getCacheKey(url);
const pendingRequest = pendingRequests.get(cacheKey);
if (!pendingRequest) {
return;
}
pendingRequest.abort();
pendingRequests.delete(cacheKey);
};