mirror of
https://github.com/immich-app/immich.git
synced 2026-05-16 04:22:17 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb061d9830 | |||
| c7cf2714ef | |||
| 5b65683813 | |||
| 4544371c3d |
@@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||
import { errorDto } from 'src/responses';
|
||||
import { app, utils } from 'src/utils';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('/admin/database-backups', () => {
|
||||
let cookie: string | undefined;
|
||||
@@ -13,9 +13,6 @@ describe('/admin/database-backups', () => {
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
|
||||
@@ -143,8 +143,9 @@ export const timelineUtils = {
|
||||
return page.locator('#asset-grid');
|
||||
},
|
||||
async waitForTimelineLoad(page: Page) {
|
||||
await expect(timelineUtils.locator(page)).toBeInViewport();
|
||||
await page.locator('#asset-grid[data-initialized]').waitFor();
|
||||
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
|
||||
await page.locator('#virtual-timeline:not(.invisible)').waitFor();
|
||||
},
|
||||
async getScrollTop(page: Page) {
|
||||
const queryTop = () =>
|
||||
@@ -163,14 +164,17 @@ export const assetViewerUtils = {
|
||||
return page.locator('#immich-asset-viewer');
|
||||
},
|
||||
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
|
||||
await page
|
||||
.locator(
|
||||
`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`,
|
||||
)
|
||||
.or(
|
||||
page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}&edited=true"]`),
|
||||
)
|
||||
.waitFor();
|
||||
const imgLocator = page.locator(`[data-viewer-content] img[data-testid="preview"][src*="${asset.id}"]`);
|
||||
const videoLocator = page.locator(`[data-viewer-content] video[poster*="${asset.id}"]`);
|
||||
await imgLocator.or(videoLocator).waitFor();
|
||||
|
||||
if ((await videoLocator.count()) === 0) {
|
||||
await expect
|
||||
.poll(() => imgLocator.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth > 0))
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
await expect(page.locator('#immich-asset-viewer')).not.toHaveAttribute('data-navigating');
|
||||
},
|
||||
async expectActiveAssetToBe(page: Page, assetId: string) {
|
||||
const activeElement = () =>
|
||||
|
||||
@@ -568,8 +568,6 @@ export const utils = {
|
||||
name: ManualJobName.BackupDatabase,
|
||||
});
|
||||
|
||||
await utils.waitForQueueFinish(accessToken, 'backupDatabase');
|
||||
|
||||
return utils.poll(
|
||||
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||
|
||||
@@ -11,7 +11,6 @@ class RemoteAsset extends BaseAsset {
|
||||
final String ownerId;
|
||||
final String? stackId;
|
||||
final DateTime? uploadedAt;
|
||||
final DateTime? deletedAt;
|
||||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
@@ -32,7 +31,6 @@ class RemoteAsset extends BaseAsset {
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
required super.isEdited,
|
||||
this.deletedAt,
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
@@ -50,8 +48,6 @@ class RemoteAsset extends BaseAsset {
|
||||
@override
|
||||
bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage;
|
||||
|
||||
bool get isTrashed => deletedAt != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '''Asset {
|
||||
@@ -90,8 +86,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash == other.thumbHash &&
|
||||
visibility == other.visibility &&
|
||||
stackId == other.stackId &&
|
||||
uploadedAt == other.uploadedAt &&
|
||||
deletedAt == other.deletedAt;
|
||||
uploadedAt == other.uploadedAt;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -103,8 +98,7 @@ class RemoteAsset extends BaseAsset {
|
||||
thumbHash.hashCode ^
|
||||
visibility.hashCode ^
|
||||
stackId.hashCode ^
|
||||
uploadedAt.hashCode ^
|
||||
deletedAt.hashCode;
|
||||
uploadedAt.hashCode;
|
||||
|
||||
RemoteAsset copyWith({
|
||||
String? id,
|
||||
@@ -125,7 +119,6 @@ class RemoteAsset extends BaseAsset {
|
||||
String? livePhotoVideoId,
|
||||
String? stackId,
|
||||
bool? isEdited,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: id ?? this.id,
|
||||
@@ -146,7 +139,6 @@ class RemoteAsset extends BaseAsset {
|
||||
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
|
||||
stackId: stackId ?? this.stackId,
|
||||
isEdited: isEdited ?? this.isEdited,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +156,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
super.uploadedAt,
|
||||
super.deletedAt,
|
||||
super.width,
|
||||
super.height,
|
||||
super.durationMs,
|
||||
@@ -202,7 +193,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
DateTime? deletedAt,
|
||||
int? width,
|
||||
int? height,
|
||||
int? durationMs,
|
||||
@@ -224,7 +214,6 @@ class RemoteAssetExif extends RemoteAsset {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
uploadedAt: uploadedAt ?? this.uploadedAt,
|
||||
deletedAt: deletedAt ?? this.deletedAt,
|
||||
width: width ?? this.width,
|
||||
height: height ?? this.height,
|
||||
durationMs: durationMs ?? this.durationMs,
|
||||
|
||||
@@ -74,6 +74,5 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
|
||||
localId: localId,
|
||||
stackId: stackId,
|
||||
isEdited: isEdited,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
|
||||
@@ -32,10 +31,6 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
@@ -65,9 +60,8 @@ import 'package:sqlite_async/sqlite_async.dart';
|
||||
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
|
||||
)
|
||||
class Drift extends $Drift {
|
||||
Drift(super.executor);
|
||||
|
||||
Drift.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
Drift([QueryExecutor? executor])
|
||||
: super(executor ?? driftDatabase(name: 'immich', native: const DriftNativeOptions(shareAcrossIsolates: true)));
|
||||
|
||||
Future<void> reset() async {
|
||||
// https://github.com/simolus3/drift/commit/bd80a46264b6dd833ef4fd87fffc03f5a832ab41#diff-3f879e03b4a35779344ef16170b9353608dd9c42385f5402ec6035aac4dd8a04R76-R94
|
||||
@@ -311,18 +305,3 @@ class DriftDatabaseRepository {
|
||||
|
||||
Future<T> transaction<T>(Future<T> Function() callback) => _db.transaction(callback);
|
||||
}
|
||||
|
||||
Future<SqliteConnection> openSqliteConnection({required String name}) async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
final file = File(p.join(dbFolder.path, '$name.sqlite'));
|
||||
return SqliteDatabase(path: file.path);
|
||||
}
|
||||
|
||||
Future<void> configureSqliteCache() async {
|
||||
// Make sqlite3 pick a more suitable location for temporary files - the
|
||||
// one from the system may be inaccessible due to sand-boxing.
|
||||
final cacheBase = (await getTemporaryDirectory()).path;
|
||||
// We can't access /tmp on Android, which sqlite3 would try by default.
|
||||
// Explicitly tell it about the correct temporary directory.
|
||||
sqlite3.tempDirectory = cacheBase;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_sqlite_async/drift_sqlite_async.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.drift.dart';
|
||||
import 'package:sqlite_async/sqlite_async.dart';
|
||||
|
||||
@DriftDatabase(tables: [LogMessageEntity])
|
||||
class DriftLogger extends $DriftLogger {
|
||||
DriftLogger.fromExecutor(super.executor);
|
||||
|
||||
DriftLogger.sqlite(SqliteConnection db) : super(SqliteAsyncDriftConnection(db));
|
||||
DriftLogger([QueryExecutor? executor])
|
||||
: super(
|
||||
executor ?? driftDatabase(name: 'immich_logs', native: const DriftNativeOptions(shareAcrossIsolates: true)),
|
||||
);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
@@ -19,8 +19,7 @@ class DriftLogger extends $DriftLogger {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
await customStatement('PRAGMA synchronous = NORMAL');
|
||||
await customStatement('PRAGMA journal_mode = WAL');
|
||||
await customStatement('PRAGMA busy_timeout = 30000'); // 30s
|
||||
await customStatement('PRAGMA cache_size = -32000'); // 32MB
|
||||
await customStatement('PRAGMA busy_timeout = 500');
|
||||
await customStatement('PRAGMA temp_store = MEMORY');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,11 +35,10 @@ class BaseActionButton extends ConsumerWidget {
|
||||
final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final iconSize = iconTheme.size ?? 24.0;
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||
|
||||
if (iconOnly) {
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||
@@ -47,18 +46,17 @@ class BaseActionButton extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (menuItem) {
|
||||
final iconColor = this.iconColor;
|
||||
final theme = context.themeData;
|
||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||
|
||||
return MenuItemButton(
|
||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
||||
leadingIcon: Icon(iconData, color: iconColor),
|
||||
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
||||
onPressed: onPressed,
|
||||
child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)),
|
||||
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: MaterialButton(
|
||||
|
||||
+2
-9
@@ -18,15 +18,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
final bool useShortLabel;
|
||||
|
||||
const DeletePermanentActionButton({
|
||||
super.key,
|
||||
required this.source,
|
||||
this.iconOnly = false,
|
||||
this.menuItem = false,
|
||||
this.useShortLabel = false,
|
||||
});
|
||||
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
@@ -71,7 +64,7 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
||||
return BaseActionButton(
|
||||
maxWidth: 110.0,
|
||||
iconData: Icons.delete_forever,
|
||||
label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context),
|
||||
label: "delete_permanently".t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RestoreActionButton extends ConsumerWidget {
|
||||
final ActionSource source;
|
||||
final bool iconOnly;
|
||||
final bool menuItem;
|
||||
|
||||
const RestoreActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||
|
||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).restoreTrash(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final successMessage = 'assets_restored_count'.t(context: context, args: {'count': result.count.toString()});
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return BaseActionButton(
|
||||
iconData: Icons.history_rounded,
|
||||
label: 'restore'.t(context: context),
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
onPressed: () => _onTap(context, ref),
|
||||
maxWidth: 100.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,15 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
@@ -37,31 +33,23 @@ class ViewerBottomBar extends ConsumerWidget {
|
||||
final showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||
final serverInfo = ref.watch(serverInfoProvider);
|
||||
final isInTrash = ref.read(timelineServiceProvider).origin == TimelineOrigin.trash;
|
||||
|
||||
final originalTheme = context.themeData;
|
||||
|
||||
final actions = <Widget>[
|
||||
if (isInTrash && isOwner && asset.hasRemote)
|
||||
const RestoreActionButton(source: ActionSource.viewer)
|
||||
else
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
|
||||
if (!isInLockedView) ...[
|
||||
if (!isInTrash) ...[
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
],
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
// edit sync was added in 2.6.0
|
||||
if (asset.isEditable && serverInfo.serverVersion >= const SemVer(major: 2, minor: 6, patch: 0))
|
||||
const EditImageActionButton(),
|
||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||
|
||||
if (isOwner) ...[
|
||||
if (asset.isLocalOnly)
|
||||
const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
else if (asset.isTrashed)
|
||||
const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true)
|
||||
else
|
||||
const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
asset.isLocalOnly
|
||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget {
|
||||
timelineOrigin: timelineOrigin,
|
||||
);
|
||||
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
||||
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context);
|
||||
|
||||
return MenuAnchor(
|
||||
consumeOutsideTap: true,
|
||||
|
||||
@@ -21,7 +21,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_f
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_picture_action_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||
@@ -82,7 +81,6 @@ enum ActionButtonType {
|
||||
moveToLockFolder,
|
||||
removeFromLockFolder,
|
||||
removeFromAlbum,
|
||||
restoreTrash,
|
||||
trash,
|
||||
deleteLocal,
|
||||
deletePermanent,
|
||||
@@ -114,17 +112,12 @@ enum ActionButtonType {
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.isTrashEnabled && //
|
||||
context.timelineOrigin != TimelineOrigin.trash,
|
||||
ActionButtonType.restoreTrash =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
context.asset.hasRemote && //
|
||||
context.timelineOrigin == TimelineOrigin.trash,
|
||||
context.isTrashEnabled,
|
||||
ActionButtonType.deletePermanent =>
|
||||
context.isOwner && //
|
||||
context.asset.hasRemote && //
|
||||
(!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView),
|
||||
context.asset.hasRemote && //
|
||||
!context.isTrashEnabled ||
|
||||
context.isInLockedView,
|
||||
ActionButtonType.delete =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
@@ -208,11 +201,6 @@ enum ActionButtonType {
|
||||
),
|
||||
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||
ActionButtonType.restoreTrash => RestoreActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
menuItem: menuItem,
|
||||
),
|
||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||
source: context.source,
|
||||
iconOnly: iconOnly,
|
||||
@@ -304,7 +292,6 @@ enum ActionButtonType {
|
||||
ActionButtonType.moveToLockFolder => 10,
|
||||
ActionButtonType.deleteLocal => 10,
|
||||
ActionButtonType.delete => 10,
|
||||
ActionButtonType.restoreTrash => 10,
|
||||
// 90: advancedInfo
|
||||
ActionButtonType.advancedInfo => 90,
|
||||
// 1: others
|
||||
@@ -322,15 +309,13 @@ class ActionButtonBuilder {
|
||||
ActionButtonType.delete,
|
||||
ActionButtonType.archive,
|
||||
ActionButtonType.unarchive,
|
||||
ActionButtonType.restoreTrash,
|
||||
ActionButtonType.deletePermanent,
|
||||
};
|
||||
|
||||
static List<Widget> build(ActionButtonContext context) {
|
||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||
}
|
||||
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) {
|
||||
final visibleButtons = defaultViewerKebabMenuOrder
|
||||
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||
.toList();
|
||||
@@ -346,7 +331,7 @@ class ActionButtonBuilder {
|
||||
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||
result.add(const Divider(height: 1));
|
||||
}
|
||||
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
|
||||
result.add(type.buildButton(context, buildContext, false, true));
|
||||
lastGroup = type.kebabMenuGroup;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,8 @@ void configureFileDownloaderNotifications() {
|
||||
|
||||
abstract final class Bootstrap {
|
||||
static Future<(Drift, DriftLogger)> initDomain({bool listenStoreUpdates = true, bool shouldBufferLogs = true}) async {
|
||||
await configureSqliteCache();
|
||||
final drift = Drift.sqlite(await openSqliteConnection(name: 'immich'));
|
||||
final logDb = DriftLogger.sqlite(await openSqliteConnection(name: 'immich_logs'));
|
||||
final drift = Drift();
|
||||
final logDb = DriftLogger();
|
||||
final DriftStoreRepository storeRepo = DriftStoreRepository(drift);
|
||||
|
||||
await StoreService.init(storeRepository: storeRepo, listenUpdates: listenStoreUpdates);
|
||||
|
||||
+16
-24
@@ -370,11 +370,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.32.1"
|
||||
drift_sqlite_async:
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_sqlite_async
|
||||
sha256: "1b6e99562fc5d35fe5e3696741720a8aca47f4c3eee35d4b9b94be819f53a6f6"
|
||||
name: drift_flutter
|
||||
sha256: "887fdec622174dc7eaefd0048403e34ee07cc18626ac8a7544cc3b8a4a172166"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
@@ -1619,38 +1619,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlcipher_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlcipher_flutter_libs
|
||||
sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0+eol"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "56da3e13ed7d28a66f930aa2b2b29db6736a233f08283326e96321dd812030f5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.1"
|
||||
sqlite3_connection_pool:
|
||||
sqlite3_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_connection_pool
|
||||
sha256: "90b25972c7699d84da97df1c5919804275560b4ab8a158bbec890434b9718f65"
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.4"
|
||||
sqlite3_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_web
|
||||
sha256: d876398a9f2cbf115d93fc34901f8fa129b58b13b5fa9377156ed3a9a05695e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
sqlite_async:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite_async
|
||||
sha256: "4c243c5386eba3a7102f98999388a7e0a7f2632e4e06dafb3b4f5a44170a26f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.14.1"
|
||||
version: "0.6.0+eol"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-3
@@ -19,7 +19,7 @@ dependencies:
|
||||
crypto: ^3.0.7
|
||||
device_info_plus: ^12.4.0
|
||||
drift: ^2.32.1
|
||||
drift_sqlite_async: 0.3.0
|
||||
drift_flutter: ^0.3.0
|
||||
dynamic_color: ^1.8.1
|
||||
easy_localization: ^3.0.8
|
||||
ffi: ^2.2.0
|
||||
@@ -66,8 +66,6 @@ dependencies:
|
||||
share_plus: ^10.1.4
|
||||
sliver_tools: ^0.2.12
|
||||
stream_transform: ^2.1.1
|
||||
sqlite3: ^3.3.1
|
||||
sqlite_async: 0.14.1
|
||||
thumbhash: 0.1.0+1
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.2
|
||||
|
||||
@@ -131,7 +131,7 @@ void main() {
|
||||
durationMs: 0,
|
||||
orientation: 0,
|
||||
isFavorite: false,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final assetsToRestore = [LocalAssetStub.image1];
|
||||
@@ -215,7 +215,7 @@ void main() {
|
||||
isFavorite: false,
|
||||
createdAt: 1700000000,
|
||||
updatedAt: 1732000000,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image,
|
||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
||||
);
|
||||
|
||||
final localAsset = platformAsset.toLocalAsset();
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||
|
||||
LocalAsset createLocalAsset({
|
||||
@@ -38,7 +37,6 @@ RemoteAsset createRemoteAsset({
|
||||
DateTime? updatedAt,
|
||||
DateTime? uploadedAt,
|
||||
bool isFavorite = false,
|
||||
DateTime? deletedAt,
|
||||
}) {
|
||||
return RemoteAsset(
|
||||
id: 'remote-id',
|
||||
@@ -52,7 +50,6 @@ RemoteAsset createRemoteAsset({
|
||||
uploadedAt: uploadedAt ?? DateTime.now(),
|
||||
isFavorite: isFavorite,
|
||||
isEdited: false,
|
||||
deletedAt: deletedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -461,62 +458,6 @@ void main() {
|
||||
|
||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should not show when asset is already trashed', () {
|
||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.trash.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('restoreTrash button', () {
|
||||
test('should show when owner, not locked, has remote, and is in trash timeline', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.restoreTrash.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when not in trash timeline', () {
|
||||
final remoteAsset = createRemoteAsset();
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: false,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.timeline,
|
||||
timelineOrigin: TimelineOrigin.main,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.restoreTrash.shouldShow(context), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('deletePermanent button', () {
|
||||
@@ -553,24 +494,6 @@ void main() {
|
||||
|
||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
|
||||
});
|
||||
|
||||
test('should show when asset is trashed even with trash enabled', () {
|
||||
final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024));
|
||||
final context = ActionButtonContext(
|
||||
asset: remoteAsset,
|
||||
isOwner: true,
|
||||
isArchived: false,
|
||||
isTrashEnabled: true,
|
||||
isInLockedView: false,
|
||||
currentAlbum: null,
|
||||
advancedTroubleshooting: false,
|
||||
isStacked: false,
|
||||
source: ActionSource.viewer,
|
||||
timelineOrigin: TimelineOrigin.trash,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('delete button', () {
|
||||
|
||||
@@ -171,8 +171,8 @@ export class JobRepository {
|
||||
options: this.getJobOptions(item) || undefined,
|
||||
} as JobItem & { data: any; options: JobsOptions | undefined };
|
||||
|
||||
if (job.options?.jobId || job.options?.deduplication) {
|
||||
// need to use add() instead of addBulk() for jobId/deduplication to take effect
|
||||
if (job.options?.jobId) {
|
||||
// need to use add() instead of addBulk() for jobId deduplication
|
||||
promises.push(this.getQueue(queueName).add(item.name, item.data, job.options));
|
||||
} else {
|
||||
itemsByQueue[queueName] = itemsByQueue[queueName] || [];
|
||||
@@ -230,13 +230,10 @@ export class JobRepository {
|
||||
return { priority: 1 };
|
||||
}
|
||||
case JobName.FacialRecognitionQueueAll: {
|
||||
return { deduplication: { id: JobName.FacialRecognitionQueueAll } };
|
||||
return { jobId: JobName.FacialRecognitionQueueAll };
|
||||
}
|
||||
case JobName.VersionCheck: {
|
||||
return { deduplication: { id: JobName.VersionCheck } };
|
||||
}
|
||||
case JobName.DatabaseBackup: {
|
||||
return { deduplication: { id: JobName.DatabaseBackup } };
|
||||
return { jobId: JobName.VersionCheck };
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
|
||||
@@ -132,7 +132,7 @@ export class MachineLearningRepository {
|
||||
private async check(url: string) {
|
||||
let healthy = false;
|
||||
try {
|
||||
const response = await fetch(new URL('ping', url), {
|
||||
const response = await fetch(new URL('/ping', url), {
|
||||
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
|
||||
});
|
||||
if (response.ok) {
|
||||
@@ -170,7 +170,7 @@ export class MachineLearningRepository {
|
||||
...this.config.urls.filter((url) => !this.isHealthy(url)),
|
||||
]) {
|
||||
try {
|
||||
const response = await fetch(new URL('predict', url), { method: 'POST', body: formData });
|
||||
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
this.setHealthy(url, true);
|
||||
return response.json();
|
||||
|
||||
+175
@@ -76,6 +76,11 @@
|
||||
--immich-dark-bg: 10 10 10;
|
||||
--immich-dark-fg: 229 231 235;
|
||||
--immich-dark-gray: 33 33 33;
|
||||
|
||||
/* view transition variables */
|
||||
--vt-duration-default: 250ms;
|
||||
--vt-duration-hero: 280ms;
|
||||
--vt-memory-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
@@ -176,3 +181,173 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
::view-transition-image-pair(info) {
|
||||
isolation: auto;
|
||||
}
|
||||
::view-transition-old(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideOutRight forwards;
|
||||
}
|
||||
::view-transition-new(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideInRight forwards;
|
||||
}
|
||||
|
||||
html[dir='rtl']::view-transition-old(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideOutLeft forwards;
|
||||
}
|
||||
html[dir='rtl']::view-transition-new(info) {
|
||||
animation: var(--vt-duration-default) 0s panelSlideInLeft forwards;
|
||||
}
|
||||
|
||||
::view-transition-group(exclude-previousbutton),
|
||||
::view-transition-group(exclude-nextbutton),
|
||||
::view-transition-group(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
::view-transition-old(exclude-previousbutton),
|
||||
::view-transition-old(exclude-nextbutton),
|
||||
::view-transition-old(exclude) {
|
||||
visibility: hidden;
|
||||
}
|
||||
::view-transition-new(exclude-previousbutton),
|
||||
::view-transition-new(exclude-nextbutton),
|
||||
::view-transition-new(exclude) {
|
||||
animation: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
::view-transition-group(hero) {
|
||||
animation-duration: var(--vt-duration-hero);
|
||||
animation-timing-function: var(--vt-memory-easing);
|
||||
}
|
||||
::view-transition-old(hero) {
|
||||
animation: none;
|
||||
display: none;
|
||||
}
|
||||
::view-transition-new(hero) {
|
||||
animation: none;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
@keyframes panelSlideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelSlideOutLeft {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
objectFit?: 'contain' | 'cover';
|
||||
container: Size;
|
||||
imageClass?: string;
|
||||
transitionName?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
onImageReady?: () => void;
|
||||
onError?: () => void;
|
||||
@@ -35,6 +37,8 @@
|
||||
sharedLink,
|
||||
objectFit = 'contain',
|
||||
container,
|
||||
imageClass,
|
||||
transitionName,
|
||||
onUrlChange,
|
||||
onImageReady,
|
||||
onError,
|
||||
@@ -152,11 +156,12 @@
|
||||
{@render backdrop?.()}
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0"
|
||||
class={['pointer-events-none absolute inset-0', imageClass]}
|
||||
style:inset-inline-start={insetInlineStart}
|
||||
style:top
|
||||
style:width
|
||||
style:height
|
||||
style:view-transition-name={transitionName ?? assetViewerManager.transitionName}
|
||||
>
|
||||
{#if show.alphaBackground}
|
||||
<AlphaBackground />
|
||||
|
||||
@@ -14,6 +14,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 { getAssetActions } from '$lib/services/asset.service';
|
||||
import { faceManager } from '$lib/stores/face.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
@@ -39,7 +40,7 @@
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { slide } from 'svelte/transition';
|
||||
import Thumbnail from '../assets/thumbnail/Thumbnail.svelte';
|
||||
import ActivityStatus from './ActivityStatus.svelte';
|
||||
import ActivityViewer from './ActivityViewer.svelte';
|
||||
@@ -149,8 +150,45 @@
|
||||
}
|
||||
};
|
||||
|
||||
let detailPanelTransitionName = $state<string | undefined>();
|
||||
let navigationBarTransitionName = $state<string | undefined>();
|
||||
let previousButtonTransitionName = $state<string | undefined>();
|
||||
let nextButtonTransitionName = $state<string | undefined>();
|
||||
|
||||
const activateViewTransitionNames = () => {
|
||||
detailPanelTransitionName = 'info';
|
||||
assetViewerManager.transitionName = 'hero';
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
syncAssetViewerOpenClass(true);
|
||||
|
||||
const unsubAssetViewerEvents = assetViewerManager.on({
|
||||
ViewerOpenTransition: activateViewTransitionNames,
|
||||
ViewerCloseTransition: activateViewTransitionNames,
|
||||
});
|
||||
const unsubViewTransitionEvents = viewTransitionManager.on({
|
||||
PrepareOldSnapshot: (types) => {
|
||||
if (types.includes('timeline')) {
|
||||
navigationBarTransitionName = 'exclude';
|
||||
previousButtonTransitionName = 'exclude-previousbutton';
|
||||
nextButtonTransitionName = 'exclude-nextbutton';
|
||||
}
|
||||
},
|
||||
PrepareNewSnapshot: (types) => {
|
||||
const isViewer = types.includes('viewer');
|
||||
navigationBarTransitionName = isViewer ? 'exclude' : undefined;
|
||||
previousButtonTransitionName = isViewer ? 'exclude-previousbutton' : undefined;
|
||||
nextButtonTransitionName = isViewer ? 'exclude-nextbutton' : undefined;
|
||||
},
|
||||
Finished: () => {
|
||||
navigationBarTransitionName = undefined;
|
||||
previousButtonTransitionName = undefined;
|
||||
nextButtonTransitionName = undefined;
|
||||
assetViewerManager.transitionName = undefined;
|
||||
detailPanelTransitionName = undefined;
|
||||
},
|
||||
});
|
||||
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
@@ -169,6 +207,8 @@
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAssetViewerEvents();
|
||||
unsubViewTransitionEvents();
|
||||
slideshowStateUnsubscribe();
|
||||
slideshowNavigationUnsubscribe();
|
||||
};
|
||||
@@ -195,6 +235,7 @@
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
let navigating = $state(false);
|
||||
const navigateAsset = (order?: 'previous' | 'next') => {
|
||||
if (!order) {
|
||||
if ($slideshowState === SlideshowState.PlaySlideshow) {
|
||||
@@ -210,7 +251,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
void tracker.invoke(async () => {
|
||||
navigating = true;
|
||||
const navigation = tracker.invoke(async () => {
|
||||
const isShuffle =
|
||||
$slideshowState === SlideshowState.PlaySlideshow && $slideshowNavigation === SlideshowNavigation.Shuffle;
|
||||
|
||||
@@ -247,6 +289,7 @@
|
||||
|
||||
await handleStopSlideshow();
|
||||
}, $t('error_while_navigating'));
|
||||
void navigation.finally(() => (navigating = false));
|
||||
};
|
||||
|
||||
const navigateStack = (direction: 'previous' | 'next') => {
|
||||
@@ -480,7 +523,8 @@
|
||||
|
||||
<section
|
||||
id="immich-asset-viewer"
|
||||
class="fixed inset-s-0 top-0 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
class="fixed inset-s-0 top-0 z-10 grid size-full grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
|
||||
data-navigating={navigating || undefined}
|
||||
use:focusTrap
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'ArrowUp' }, onShortcut: () => navigateStack('previous') },
|
||||
@@ -490,7 +534,10 @@
|
||||
>
|
||||
<!-- Top navigation bar -->
|
||||
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
|
||||
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
|
||||
<div
|
||||
class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"
|
||||
style:view-transition-name={navigationBarTransitionName}
|
||||
>
|
||||
<AssetViewerNavBar
|
||||
{asset}
|
||||
{album}
|
||||
@@ -523,7 +570,11 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && previousAsset}
|
||||
<div class="col-span-1 col-start-1 row-span-full row-start-1 my-auto justify-self-start">
|
||||
<div
|
||||
data-test-id="previous-asset"
|
||||
class="col-span-1 col-start-1 row-span-full row-start-1 my-auto justify-self-start"
|
||||
style:view-transition-name={previousButtonTransitionName}
|
||||
>
|
||||
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -601,19 +652,24 @@
|
||||
</div>
|
||||
|
||||
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && !assetViewerManager.isFaceEditMode && nextAsset}
|
||||
<div class="col-span-1 col-start-4 row-span-full row-start-1 my-auto justify-self-end">
|
||||
<div
|
||||
data-test-id="next-asset"
|
||||
class="col-span-1 col-start-4 row-span-full row-start-1 my-auto justify-self-end"
|
||||
style:view-transition-name={nextButtonTransitionName}
|
||||
>
|
||||
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDetailPanel || assetViewerManager.isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="detail-panel"
|
||||
class={[
|
||||
'row-span-4 row-start-1 overflow-y-auto bg-light transition-all dark:border-l dark:border-s-immich-dark-gray',
|
||||
showDetailPanel ? 'w-90' : 'w-100',
|
||||
]}
|
||||
style:view-transition-name={detailPanelTransitionName}
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
@@ -662,7 +718,7 @@
|
||||
|
||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && authManager.authenticated}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
transition:slide={{ axis: 'x', duration: 150 }}
|
||||
id="activity-panel"
|
||||
class="row-span-5 row-start-1 w-90 overflow-y-auto transition-all md:w-115 dark:border-l dark:border-s-immich-dark-gray"
|
||||
translate="yes"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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 = {
|
||||
asset: AssetResponseDto;
|
||||
@@ -20,7 +19,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full place-content-center place-items-center select-none">
|
||||
<div class="flex h-dvh w-dvw place-content-center place-items-center select-none">
|
||||
{#await Promise.all([loadAssetData(assetId), import('./PhotoSphereViewerAdapter.svelte')])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, { default: PhotoSphereViewer }]}
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
zoomSpeed: 0.5,
|
||||
fisheye: false,
|
||||
});
|
||||
viewer.addEventListener('ready', () => assetViewerManager.emit('ViewerOpenTransitionReady'), { once: true });
|
||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||
// zoomLevel is 0-100
|
||||
@@ -250,7 +251,12 @@
|
||||
<AssetViewerEvents {onZoom} />
|
||||
|
||||
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
|
||||
<div class="mb-0 size-full" bind:this={container}></div>
|
||||
<div
|
||||
id="sphere"
|
||||
class="mb-0 h-dvh w-dvw"
|
||||
bind:this={container}
|
||||
style:view-transition-name={assetViewerManager.transitionName}
|
||||
></div>
|
||||
|
||||
<style>
|
||||
/* Reset the default tooltip styling */
|
||||
|
||||
@@ -28,12 +28,11 @@
|
||||
cursor: AssetCursor;
|
||||
element?: HTMLDivElement;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
};
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
@@ -228,11 +227,11 @@
|
||||
{onUrlChange}
|
||||
onImageReady={() => {
|
||||
visibleImageReady = true;
|
||||
onReady?.();
|
||||
assetViewerManager.emit('ViewerOpenTransitionReady');
|
||||
}}
|
||||
onError={() => {
|
||||
onError?.();
|
||||
onReady?.();
|
||||
assetViewerManager.emit('ViewerOpenTransitionReady');
|
||||
}}
|
||||
bind:imgRef={assetViewerManager.imgRef}
|
||||
bind:ref={adaptiveImage}
|
||||
|
||||
@@ -181,6 +181,8 @@
|
||||
playsinline
|
||||
{...useSwipe(onSwipe)}
|
||||
class="h-full object-contain"
|
||||
style:view-transition-name={assetViewerManager.transitionName}
|
||||
onloadedmetadata={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
|
||||
oncanplay={(e) => handleCanPlay(e.currentTarget)}
|
||||
onended={onVideoEnded}
|
||||
onplaying={(e) => {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -19,7 +18,7 @@
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full place-content-center place-items-center select-none">
|
||||
<div class="flex h-full place-content-center place-items-center select-none">
|
||||
{#await modules}
|
||||
<LoadingSpinner />
|
||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { ResizeBoundary, transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
@@ -74,6 +75,8 @@
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
class="h-full transition-transform select-none motion-reduce:transition-none"
|
||||
style:transform={imageTransform}
|
||||
onload={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
|
||||
onerror={() => assetViewerManager.emit('ViewerOpenTransitionReady')}
|
||||
/>
|
||||
<div
|
||||
class={[
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/NavigationBar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/UserSidebar.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { page } from '$app/state';
|
||||
import { isAssetViewerRoute } from '$lib/utils/navigation';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -48,7 +50,7 @@
|
||||
|
||||
<header>
|
||||
{#if !hideNavbar}
|
||||
<NavigationBar onUploadClick={() => openFileUploadDialog()} />
|
||||
<NavigationBar hidden={isAssetViewerRoute(page)} onUploadClick={() => openFileUploadDialog()} />
|
||||
{/if}
|
||||
</header>
|
||||
<div
|
||||
@@ -64,7 +66,7 @@
|
||||
<UserSidebar />
|
||||
{/if}
|
||||
|
||||
<main class="relative">
|
||||
<main class="relative w-full">
|
||||
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import SkipLink from '$lib/elements/SkipLink.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
@@ -27,21 +28,35 @@
|
||||
onUploadClick?: () => void;
|
||||
// TODO: remove once this is only used in <AppShellHeader>
|
||||
noBorder?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
let { onUploadClick, noBorder = false }: Props = $props();
|
||||
let { onUploadClick, noBorder = false, hidden = false }: Props = $props();
|
||||
|
||||
let viewTransitionName = $state<string | undefined>();
|
||||
let shouldShowAccountInfoPanel = $state(false);
|
||||
let shouldShowNotificationPanel = $state(false);
|
||||
let innerWidth: number = $state(0);
|
||||
const hasUnreadNotifications = $derived(notificationManager.notifications.length > 0);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await notificationManager.refresh();
|
||||
} catch (error) {
|
||||
onMount(() => {
|
||||
notificationManager.refresh().catch((error) => {
|
||||
console.error('Failed to load notifications on mount', error);
|
||||
}
|
||||
});
|
||||
|
||||
return viewTransitionManager.on({
|
||||
PrepareOldSnapshot: (types) => {
|
||||
if (types.includes('viewer')) {
|
||||
viewTransitionName = 'exclude';
|
||||
}
|
||||
},
|
||||
PrepareNewSnapshot: (types) => {
|
||||
viewTransitionName = types.includes('timeline') ? 'exclude' : undefined;
|
||||
},
|
||||
Finished: () => {
|
||||
viewTransitionName = undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
@@ -49,7 +64,11 @@
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
<nav id="dashboard-navbar" class="h-(--navbar-height) w-dvw text-sm max-md:h-(--navbar-height-md)">
|
||||
<nav
|
||||
id="dashboard-navbar"
|
||||
class={['h-(--navbar-height) w-dvw text-sm max-md:h-(--navbar-height-md)', hidden && 'invisible']}
|
||||
style:view-transition-name={viewTransitionName}
|
||||
>
|
||||
<SkipLink text={$t('skip_to_content')} />
|
||||
<div
|
||||
class="grid h-full grid-cols-[--spacing(32)_auto] items-center py-2 sidebar:grid-cols-[--spacing(64)_auto] {noBorder
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
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';
|
||||
@@ -12,10 +11,11 @@
|
||||
let { isUploading } = uploadAssetsStore;
|
||||
|
||||
type Props = {
|
||||
heroTransitionAssetId?: string | null;
|
||||
suspendTransitions?: boolean;
|
||||
viewerAssets: ViewerAsset[];
|
||||
width: number;
|
||||
height: number;
|
||||
manager: VirtualScrollManager;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
@@ -27,9 +27,17 @@
|
||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
||||
};
|
||||
|
||||
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
|
||||
const {
|
||||
heroTransitionAssetId,
|
||||
suspendTransitions = false,
|
||||
viewerAssets,
|
||||
width,
|
||||
height,
|
||||
thumbnail,
|
||||
customThumbnailLayout,
|
||||
}: Props = $props();
|
||||
|
||||
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const transitionDuration = $derived(suspendTransitions && !$isUploading ? 0 : 150);
|
||||
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||
</script>
|
||||
|
||||
@@ -38,11 +46,13 @@
|
||||
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||
{@const position = viewerAsset.position!}
|
||||
{@const asset = viewerAsset.asset!}
|
||||
{@const transitionName = heroTransitionAssetId === 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"
|
||||
style:view-transition-name={transitionName}
|
||||
style:top={position.top + 'px'}
|
||||
style:inset-inline-start={position.left + 'px'}
|
||||
style:width={position.width + 'px'}
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot, filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount, tick, type Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
toViewerHeroAssetId?: string | null;
|
||||
thumbnail: Snippet<
|
||||
[
|
||||
{
|
||||
@@ -28,16 +33,16 @@
|
||||
singleSelect: boolean;
|
||||
assetInteraction: AssetMultiSelectManager;
|
||||
timelineMonth: TimelineMonth;
|
||||
manager: VirtualScrollManager;
|
||||
onTimelineDaySelect: (timelineDay: TimelineDay, assets: TimelineAsset[]) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
toViewerHeroAssetId,
|
||||
thumbnail: thumbnailWithGroup,
|
||||
customThumbnailLayout,
|
||||
singleSelect,
|
||||
assetInteraction,
|
||||
timelineMonth,
|
||||
manager,
|
||||
onTimelineDaySelect,
|
||||
}: Props = $props();
|
||||
|
||||
@@ -55,6 +60,34 @@
|
||||
});
|
||||
return getDateLocaleString(date);
|
||||
};
|
||||
|
||||
let toTimelineHeroAssetId = $state<string | null>(null);
|
||||
let heroTransitionAssetId = $derived(toTimelineHeroAssetId ?? toViewerHeroAssetId ?? null);
|
||||
|
||||
const handleViewerCloseTransition = ({ id }: { id: string }) => {
|
||||
const asset = timelineMonth.findAssetById({ id });
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
handlePromiseError(
|
||||
viewTransitionManager.startTransition({
|
||||
types: ['timeline'],
|
||||
performUpdate: async () => {
|
||||
assetViewerManager.emit('ViewerCloseTransitionReady');
|
||||
const event = await eventManager.untilNext('TimelineLoaded');
|
||||
toTimelineHeroAssetId = event.id;
|
||||
await tick();
|
||||
},
|
||||
onFinished: () => {
|
||||
toTimelineHeroAssetId = null;
|
||||
focusAsset(asset.id);
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
onMount(() => assetViewerManager.on({ ViewerCloseTransition: handleViewerCloseTransition }));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each filterIsInOrNearViewport(timelineMonth.timelineDays) as timelineDay, groupIndex (timelineDay.day)}
|
||||
@@ -99,7 +132,8 @@
|
||||
</div>
|
||||
|
||||
<AssetLayout
|
||||
{manager}
|
||||
{heroTransitionAssetId}
|
||||
suspendTransitions={timelineMonth.timelineManager.suspendTransitions}
|
||||
viewerAssets={timelineDay.viewerAssets}
|
||||
height={timelineDay.height}
|
||||
width={timelineDay.width}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
invisible: boolean;
|
||||
/** Offset from the top of the timeline (e.g., for headers) */
|
||||
timelineTopOffset?: number;
|
||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
||||
@@ -39,6 +40,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
invisible = false,
|
||||
timelineTopOffset = 0,
|
||||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
@@ -509,6 +511,7 @@
|
||||
aria-valuemin={toScrollY(0)}
|
||||
data-id="scrubber"
|
||||
class="absolute inset-e-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
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { startViewerTransition } from '$lib/utils/transition-utils';
|
||||
import type { TimelineDay } from '$lib/managers/timeline-manager/timeline-day.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import type { TimelineMonth } from '$lib/managers/timeline-manager/timeline-month.svelte';
|
||||
@@ -20,6 +22,7 @@
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
@@ -99,6 +102,7 @@
|
||||
// Overall scroll percentage through the entire timeline (0-1)
|
||||
let timelineScrollPercent: number = $state(0);
|
||||
let scrubberWidth = $state(0);
|
||||
let toViewerHeroAssetId = $state<string | null>(null);
|
||||
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const maxMd = $derived(mediaQueryManager.maxMd);
|
||||
@@ -207,7 +211,7 @@
|
||||
timelineManager.viewportWidth = rect.width;
|
||||
}
|
||||
}
|
||||
const scrollTarget = assetViewerManager.gridScrollTarget?.at;
|
||||
const scrollTarget = getScrollTarget();
|
||||
let scrolled = false;
|
||||
if (scrollTarget) {
|
||||
scrolled = await scrollAndLoadAsset(scrollTarget);
|
||||
@@ -219,7 +223,7 @@
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
invisible = isAssetViewerRoute(page) ? true : false;
|
||||
};
|
||||
|
||||
// note: only modified once in afterNavigate()
|
||||
@@ -237,10 +241,13 @@
|
||||
hasNavigatedToOrFromAssetViewer = isNavigatingToAssetViewer !== isNavigatingFromAssetViewer;
|
||||
});
|
||||
|
||||
const getScrollTarget = () => {
|
||||
return assetViewerManager.gridScrollTarget?.at ?? page.params.assetId ?? null;
|
||||
};
|
||||
// afterNavigate is only called after navigation to a new URL, {complete} will resolve
|
||||
// after successful navigation.
|
||||
afterNavigate(({ complete }) => {
|
||||
void complete.finally(() => {
|
||||
void complete.finally(async () => {
|
||||
const isAssetViewerPage = isAssetViewerRoute(page);
|
||||
|
||||
// Set initial load state only once - if initialLoadWasAssetViewer is null, then
|
||||
@@ -251,6 +258,12 @@
|
||||
}
|
||||
|
||||
void scrollAfterNavigate();
|
||||
if (!isAssetViewerPage) {
|
||||
const scrollTarget = getScrollTarget();
|
||||
await tick();
|
||||
|
||||
eventManager.emit('TimelineLoaded', { id: scrollTarget });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -258,7 +271,7 @@
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
if (!enableRouting && !isAssetViewerRoute(page)) {
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
@@ -545,7 +558,7 @@
|
||||
assetInteraction.selectAll = timelineManager.assetCount === assetInteraction.assets.length;
|
||||
};
|
||||
|
||||
const _onClick = (
|
||||
const defaultThumbnailClick = (
|
||||
timelineManager: TimelineManager,
|
||||
assets: TimelineAsset[],
|
||||
groupTitle: string,
|
||||
@@ -557,6 +570,27 @@
|
||||
}
|
||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (asset: TimelineAsset, timelineDay: TimelineDay) => {
|
||||
if (typeof onThumbnailClick === 'function' || isSelectionMode || assetInteraction.selectionActive) {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, timelineDay, defaultThumbnailClick);
|
||||
} else {
|
||||
defaultThumbnailClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const openViewer = () => void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
handlePromiseError(
|
||||
startViewerTransition(
|
||||
asset.id,
|
||||
openViewer,
|
||||
(id) => (toViewerHeroAssetId = id),
|
||||
() => (toViewerHeroAssetId = null),
|
||||
),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
||||
@@ -587,6 +621,7 @@
|
||||
{#if timelineManager.months.length > 0}
|
||||
<Scrubber
|
||||
{timelineManager}
|
||||
{invisible}
|
||||
height={timelineManager.viewportHeight}
|
||||
timelineTopOffset={timelineManager.topSectionHeight}
|
||||
timelineBottomOffset={timelineManager.bottomSectionHeight}
|
||||
@@ -618,6 +653,7 @@
|
||||
id="asset-grid"
|
||||
class={['h-full overflow-y-auto outline-none scrollbar-hidden', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
|
||||
style:margin-inline-end={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
data-initialized={timelineManager.isInitialized || undefined}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
@@ -666,11 +702,11 @@
|
||||
style:width="100%"
|
||||
>
|
||||
<Month
|
||||
{toViewerHeroAssetId}
|
||||
{assetInteraction}
|
||||
{customThumbnailLayout}
|
||||
{singleSelect}
|
||||
{timelineMonth}
|
||||
manager={timelineManager}
|
||||
onTimelineDaySelect={handleGroupSelect}
|
||||
>
|
||||
{#snippet thumbnail({ asset, position, timelineDay, groupIndex })}
|
||||
@@ -684,13 +720,7 @@
|
||||
{asset}
|
||||
{albumUsers}
|
||||
{groupIndex}
|
||||
onClick={(asset) => {
|
||||
if (typeof onThumbnailClick === 'function') {
|
||||
onThumbnailClick(asset, timelineManager, timelineDay, _onClick);
|
||||
} else {
|
||||
_onClick(timelineManager, timelineDay.getAssets(), timelineDay.groupTitle, asset);
|
||||
}
|
||||
}}
|
||||
onClick={(asset) => handleThumbnailClick(asset, timelineDay)}
|
||||
onSelect={() => {
|
||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||
assetSelectHandler(timelineManager, asset, timelineDay.getAssets(), timelineDay.groupTitle);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
@@ -97,6 +98,14 @@
|
||||
};
|
||||
|
||||
const handleClose = async (assetId: string) => {
|
||||
if (viewTransitionManager.isSupported()) {
|
||||
const transitionReady = assetViewerManager.untilNext('ViewerCloseTransitionReady', {
|
||||
signal: AbortSignal.timeout(200),
|
||||
});
|
||||
assetViewerManager.emit('ViewerCloseTransition', { id: assetId });
|
||||
await transitionReady;
|
||||
}
|
||||
|
||||
invisible = true;
|
||||
assetViewerManager.gridScrollTarget = { at: assetId };
|
||||
await navigate({
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import { ViewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
|
||||
function mockViewTransition({
|
||||
updateCallbackDone = Promise.resolve(),
|
||||
finished = Promise.resolve(),
|
||||
ready = Promise.resolve(),
|
||||
skipTransition = vi.fn(),
|
||||
}: {
|
||||
updateCallbackDone?: Promise<void>;
|
||||
finished?: Promise<void>;
|
||||
ready?: Promise<void>;
|
||||
skipTransition?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return { updateCallbackDone, finished, ready, skipTransition };
|
||||
});
|
||||
}
|
||||
|
||||
describe('ViewTransitionManager', () => {
|
||||
let manager: ViewTransitionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new ViewTransitionManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (document as Partial<typeof document> & { startViewTransition?: unknown }).startViewTransition;
|
||||
});
|
||||
|
||||
describe('when View Transition API is not supported', () => {
|
||||
it('should still call performUpdate', async () => {
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.startTransition({ performUpdate });
|
||||
|
||||
expect(performUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should call onFinished after performUpdate', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const performUpdate = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('performUpdate');
|
||||
});
|
||||
const onFinished = vi.fn().mockImplementation(() => {
|
||||
callOrder.push('onFinished');
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate, onFinished });
|
||||
|
||||
expect(onFinished).toHaveBeenCalledOnce();
|
||||
expect(callOrder).toEqual(['performUpdate', 'onFinished']);
|
||||
});
|
||||
|
||||
it('should not call prepareOldSnapshot or prepareNewSnapshot', async () => {
|
||||
const prepareOldSnapshot = vi.fn();
|
||||
const prepareNewSnapshot = vi.fn();
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await manager.startTransition({ performUpdate, prepareOldSnapshot, prepareNewSnapshot });
|
||||
|
||||
expect(prepareOldSnapshot).not.toHaveBeenCalled();
|
||||
expect(prepareNewSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when a transition is already active', () => {
|
||||
it('should skip the first transition and run the second', async () => {
|
||||
let resolveFirstUpdate!: () => void;
|
||||
const firstUpdateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveFirstUpdate = resolve;
|
||||
});
|
||||
const firstFinished = new Promise<void>(() => {});
|
||||
const firstSkipTransition = vi.fn();
|
||||
|
||||
let callCount = 0;
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
callCount++;
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
updateCallbackDone: firstUpdateCallbackDone,
|
||||
finished: firstFinished,
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: firstSkipTransition,
|
||||
};
|
||||
}
|
||||
return {
|
||||
updateCallbackDone: Promise.resolve(),
|
||||
finished: Promise.resolve(),
|
||||
ready: Promise.resolve(),
|
||||
skipTransition: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const secondPerformUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const firstPromise = manager.startTransition({
|
||||
performUpdate: async () => {},
|
||||
});
|
||||
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
// While first is active, start a second — should skip the first and proceed
|
||||
await manager.startTransition({ performUpdate: secondPerformUpdate });
|
||||
expect(firstSkipTransition).toHaveBeenCalledOnce();
|
||||
expect(secondPerformUpdate).toHaveBeenCalledOnce();
|
||||
|
||||
resolveFirstUpdate();
|
||||
await firstPromise;
|
||||
});
|
||||
});
|
||||
|
||||
describe('skipTransitions', () => {
|
||||
it('should return false when no transition is active', () => {
|
||||
expect(manager.skipTransitions()).toBe(false);
|
||||
});
|
||||
|
||||
it('should call skipTransition on the active transition and return true', async () => {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
const skipTransition = vi.fn();
|
||||
|
||||
mockViewTransition({ updateCallbackDone, finished, skipTransition });
|
||||
|
||||
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
const skipped = manager.skipTransitions();
|
||||
expect(skipped).toBe(true);
|
||||
expect(skipTransition).toHaveBeenCalledOnce();
|
||||
|
||||
resolveUpdate();
|
||||
resolveFinished();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should allow a new transition after skipping', async () => {
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
|
||||
mockViewTransition({ updateCallbackDone, finished });
|
||||
|
||||
const promise = manager.startTransition({ performUpdate: async () => {} });
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
manager.skipTransitions();
|
||||
resolveUpdate();
|
||||
resolveFinished();
|
||||
await promise;
|
||||
|
||||
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
mockViewTransition({ updateCallbackDone: Promise.resolve(), finished: Promise.resolve() });
|
||||
|
||||
await manager.startTransition({ performUpdate: secondUpdate });
|
||||
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should propagate error from performUpdate when API is not supported', async () => {
|
||||
const error = new Error('update failed');
|
||||
const performUpdate = vi.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(manager.startTransition({ performUpdate })).rejects.toThrow('update failed');
|
||||
});
|
||||
|
||||
it('should clean up activeViewTransition when performUpdate throws (API supported)', async () => {
|
||||
const error = new Error('update failed');
|
||||
let resolveFinished!: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
const updateCallbackDone = updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||
});
|
||||
|
||||
await expect(manager.startTransition({ performUpdate: () => Promise.reject(error) })).rejects.toThrow(
|
||||
'update failed',
|
||||
);
|
||||
|
||||
resolveFinished();
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
const secondUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
mockViewTransition();
|
||||
|
||||
await manager.startTransition({ performUpdate: secondUpdate });
|
||||
expect(secondUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback path', () => {
|
||||
it('should fall back to function argument when object argument throws', async () => {
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
const prepareNewSnapshot = vi.fn();
|
||||
const finished = Promise.resolve();
|
||||
const updateCallbackDone = Promise.resolve();
|
||||
|
||||
let callCount = 0;
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn().mockImplementation((arg: unknown) => {
|
||||
callCount++;
|
||||
if (callCount === 1 && typeof arg !== 'function') {
|
||||
throw new TypeError('object form not supported');
|
||||
}
|
||||
const updateFn = typeof arg === 'function' ? arg : (arg as { update: () => Promise<void> }).update;
|
||||
void updateFn();
|
||||
return { updateCallbackDone, finished, ready: Promise.resolve(), skipTransition: vi.fn() };
|
||||
});
|
||||
|
||||
await manager.startTransition({ performUpdate, prepareNewSnapshot, types: ['test'] });
|
||||
|
||||
expect(performUpdate).toHaveBeenCalledOnce();
|
||||
expect(prepareNewSnapshot).toHaveBeenCalledOnce();
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
expect(document.startViewTransition).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal', () => {
|
||||
it('should pass an AbortSignal to performUpdate', async () => {
|
||||
const performUpdate = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockViewTransition();
|
||||
|
||||
await manager.startTransition({ performUpdate });
|
||||
|
||||
expect(performUpdate).toHaveBeenCalledWith(expect.any(AbortSignal));
|
||||
});
|
||||
|
||||
it('should abort the signal when transition.ready rejects', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
let resolveUpdate!: () => void;
|
||||
const updateCallbackDone = new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
});
|
||||
|
||||
const readyError = new Error('Transition was aborted because of timeout in DOM update');
|
||||
|
||||
mockViewTransition({
|
||||
updateCallbackDone,
|
||||
finished: Promise.reject(readyError),
|
||||
ready: Promise.reject(readyError),
|
||||
});
|
||||
|
||||
const performUpdate = vi.fn().mockImplementation((signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
return new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
const promise = manager.startTransition({ performUpdate });
|
||||
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
await new Promise<void>((r) => queueMicrotask(r));
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
|
||||
resolveUpdate();
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should not abort the signal when transition completes normally', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
|
||||
mockViewTransition();
|
||||
|
||||
await manager.startTransition({
|
||||
performUpdate: (signal) => {
|
||||
capturedSignal = signal;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass a non-aborted signal in the unsupported fallback path', async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
|
||||
await manager.startTransition({
|
||||
performUpdate: (signal) => {
|
||||
capturedSignal = signal;
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(capturedSignal?.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSupported', () => {
|
||||
it('should return false when startViewTransition is not in document', () => {
|
||||
expect(manager.isSupported()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when startViewTransition is in document', () => {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
document.startViewTransition = vi.fn();
|
||||
|
||||
expect(manager.isSupported()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { tick } from 'svelte';
|
||||
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
|
||||
type TransitionEvents = {
|
||||
PrepareOldSnapshot: [string[]];
|
||||
PrepareNewSnapshot: [string[]];
|
||||
Finished: [string[]];
|
||||
};
|
||||
|
||||
interface TransitionRequest {
|
||||
types?: string[];
|
||||
prepareOldSnapshot?: () => void;
|
||||
performUpdate: (signal: AbortSignal) => Promise<void>;
|
||||
prepareNewSnapshot?: () => void;
|
||||
onFinished?: () => void;
|
||||
}
|
||||
|
||||
export class ViewTransitionManager extends BaseEventManager<TransitionEvents> {
|
||||
#activeViewTransition: ViewTransition | null = null;
|
||||
#activeOnFinished: (() => void) | undefined = undefined;
|
||||
|
||||
isSupported() {
|
||||
return 'startViewTransition' in document;
|
||||
}
|
||||
|
||||
skipTransitions() {
|
||||
const skipped = !!this.#activeViewTransition;
|
||||
this.#activeViewTransition?.skipTransition();
|
||||
this.#activeViewTransition = null;
|
||||
const onFinished = this.#activeOnFinished;
|
||||
this.#activeOnFinished = undefined;
|
||||
onFinished?.();
|
||||
return skipped;
|
||||
}
|
||||
|
||||
async startTransition({
|
||||
types,
|
||||
prepareOldSnapshot,
|
||||
performUpdate,
|
||||
prepareNewSnapshot,
|
||||
onFinished,
|
||||
}: TransitionRequest) {
|
||||
if (this.#activeViewTransition) {
|
||||
this.skipTransitions();
|
||||
}
|
||||
|
||||
const resolvedTypes = types ?? [];
|
||||
|
||||
if (!this.isSupported()) {
|
||||
await performUpdate(AbortSignal.timeout(10_000));
|
||||
onFinished?.();
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('PrepareOldSnapshot', resolvedTypes);
|
||||
prepareOldSnapshot?.();
|
||||
await tick();
|
||||
|
||||
const abortController = new AbortController();
|
||||
const update = async () => {
|
||||
await performUpdate(abortController.signal);
|
||||
this.emit('PrepareNewSnapshot', resolvedTypes);
|
||||
prepareNewSnapshot?.();
|
||||
await tick();
|
||||
};
|
||||
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition({ update, types });
|
||||
} catch {
|
||||
// Fallback: browsers supporting VT Level 1 but not Level 2 (object form with types) will throw
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
transition = document.startViewTransition(update);
|
||||
}
|
||||
|
||||
this.#activeViewTransition = transition;
|
||||
this.#activeOnFinished = onFinished;
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.ready.catch((error: unknown) => {
|
||||
abortController.abort(error);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
void transition.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (this.#activeViewTransition === transition) {
|
||||
this.#activeViewTransition = null;
|
||||
this.#activeOnFinished = undefined;
|
||||
this.emit('Finished', resolvedTypes);
|
||||
onFinished?.();
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line tscompat/tscompat
|
||||
await transition.updateCallbackDone;
|
||||
}
|
||||
}
|
||||
|
||||
export const viewTransitionManager = new ViewTransitionManager();
|
||||
@@ -34,12 +34,17 @@ export type Events = {
|
||||
ZoomChange: [ZoomImageWheelState];
|
||||
Copy: [];
|
||||
FaceEditModeChange: [boolean];
|
||||
ViewerOpenTransitionReady: [];
|
||||
ViewerOpenTransition: [];
|
||||
ViewerCloseTransition: [{ id: string }];
|
||||
ViewerCloseTransitionReady: [];
|
||||
};
|
||||
|
||||
class AssetViewerManager extends BaseEventManager<Events> {
|
||||
#zoomState = $state(createDefaultZoomState());
|
||||
#animationFrameId: number | null = null;
|
||||
|
||||
transitionName = $state<string | undefined>();
|
||||
imgRef = $state<HTMLImageElement | undefined>();
|
||||
imageLoaderStatus = $state<ImageLoaderStatus | undefined>();
|
||||
#isImageLoading = $derived.by(() => {
|
||||
|
||||
@@ -89,6 +89,8 @@ export type Events = {
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
|
||||
WebsocketConnect: [];
|
||||
|
||||
TimelineLoaded: [{ id: string | null }];
|
||||
};
|
||||
|
||||
export const eventManager = new BaseEventManager<Events>();
|
||||
|
||||
@@ -19,7 +19,7 @@ class LanguageManager {
|
||||
|
||||
this.rtl = item.rtl ?? false;
|
||||
|
||||
document.body.setAttribute('dir', item.rtl ? 'rtl' : 'ltr');
|
||||
document.documentElement.setAttribute('dir', item.rtl ? 'rtl' : 'ltr');
|
||||
|
||||
eventManager.emit('LanguageChange', item);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,50 @@ export class BaseEventManager<Events extends EventsBase> {
|
||||
};
|
||||
}
|
||||
|
||||
private once<T extends keyof Events>(event: T, callback: EventCallback<Events, T>) {
|
||||
const unsubscribe = this.#onEvent(event, (...args: Events[T]) => {
|
||||
unsubscribe();
|
||||
return callback(...args);
|
||||
});
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
untilNext<T extends keyof Events>(
|
||||
event: T,
|
||||
{ timeoutMs = 10_000, signal }: { timeoutMs?: number; signal?: AbortSignal } = {},
|
||||
): Promise<Events[T] extends [] ? void : Events[T][0]> {
|
||||
type Result = Events[T] extends [] ? void : Events[T][0];
|
||||
return new Promise<Result>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = () => {
|
||||
if (settled) {
|
||||
return false;
|
||||
}
|
||||
settled = true;
|
||||
unsubscribe();
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
return true;
|
||||
};
|
||||
const unsubscribe = this.once(event, (...args: Events[T]) => {
|
||||
if (settle()) {
|
||||
resolve(args[0] as Result);
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (settle()) {
|
||||
reject(new Error(`untilNext('${String(event)}') timed out after ${timeoutMs}ms`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
const onAbort = () => {
|
||||
if (settle()) {
|
||||
resolve(undefined as Result);
|
||||
}
|
||||
};
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
emit<T extends keyof Events>(event: T, ...params: Events[T]) {
|
||||
const listeners = this.getListeners(event);
|
||||
for (const listener of listeners) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { tick } from 'svelte';
|
||||
import { viewTransitionManager } from '$lib/managers/ViewTransitionManager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
|
||||
export async function startViewerTransition(
|
||||
heroAssetId: string,
|
||||
openViewer: () => void,
|
||||
activateHeroAsset: (assetId: string) => void,
|
||||
deactivateHeroAsset: () => void,
|
||||
) {
|
||||
await viewTransitionManager.startTransition({
|
||||
types: ['viewer'],
|
||||
prepareOldSnapshot: () => {
|
||||
activateHeroAsset(heroAssetId);
|
||||
},
|
||||
performUpdate: async (signal) => {
|
||||
deactivateHeroAsset();
|
||||
const ready = assetViewerManager.untilNext('ViewerOpenTransitionReady', { signal });
|
||||
openViewer();
|
||||
await ready;
|
||||
assetViewerManager.emit('ViewerOpenTransition');
|
||||
await tick();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:display-none={assetViewerManager.isViewing}>
|
||||
<div class:invisible={assetViewerManager.isViewing}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<UploadCover />
|
||||
@@ -31,7 +31,4 @@
|
||||
:root {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,18 +4,15 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
|
||||
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiHeart } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -43,15 +40,6 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onViewAsset = async (id: string) => {
|
||||
const asset = await getAssetInfo({ ...authManager.params, id });
|
||||
assetViewerManager.setAsset(asset);
|
||||
};
|
||||
|
||||
const assetCursor = $derived({
|
||||
current: assetViewerManager.asset!,
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonThumbnailReady} />
|
||||
@@ -134,20 +122,15 @@
|
||||
draggable="false">{$t('view_all')}</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex h-24 max-w-fit flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
|
||||
{#each recents as item (item.data.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative h-full flex-auto"
|
||||
onclick={() => onViewAsset(item.data.id)}
|
||||
draggable="false"
|
||||
>
|
||||
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
|
||||
<img
|
||||
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
|
||||
alt={$getAltText(toTimelineAsset(item.data))}
|
||||
class="size-full min-w-max rounded-xl object-cover"
|
||||
/>
|
||||
</button>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,15 +140,3 @@
|
||||
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
|
||||
{/if}
|
||||
</UserPageLayout>
|
||||
|
||||
{#if assetViewerManager.isViewing}
|
||||
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
|
||||
<Portal target="body">
|
||||
<AssetViewer
|
||||
cursor={assetCursor}
|
||||
showNavigation={false}
|
||||
onClose={() => assetViewerManager.showAssetViewer(false)}
|
||||
/>
|
||||
</Portal>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user