Compare commits

..

10 Commits

Author SHA1 Message Date
Alex 067df1d635 Merge branch 'main' into debug/blank-timeline 2026-06-22 09:31:20 -05:00
Daniel Dietzler c484bd99b6 fix: ignore external libraries for integrity report checksum check (#29248) 2026-06-22 13:56:24 +00:00
Anthony Clerici c0bf5a4c56 fix(server): use VBR for QSV so the max bitrate is respected (#29240)
* fix(server): use VBR for QSV so the max bitrate is respected

* update test
2026-06-22 09:56:20 -04:00
MuySup d9d50d2848 fix: turkish readme translation (#29234)
* Translation completed

3-2-1 rule translated

* Fix formatting of warning message in Turkish README
2026-06-22 09:55:58 -04:00
Daniel Dietzler c7453a67fd fix: detail panel people reactivity and iterator consumption (#29250) 2026-06-22 15:47:09 +02:00
Daniel Dietzler e918e3a313 feat: keyboard seeking for new video player (#29208) 2026-06-22 09:42:59 -04:00
Alex 0dc029d616 Merge branch 'main' of github.com:immich-app/immich into debug/blank-timeline 2026-06-17 14:10:05 -05:00
shenlong-tanwen 430e1fed7d debug: full local sync 2026-06-17 22:48:25 +05:30
shenlong-tanwen 9c698d6694 debug: more logs
# Conflicts:
#	mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart
#	mobile/lib/presentation/widgets/timeline/timeline.widget.dart
2026-06-17 22:12:45 +05:30
shenlong-tanwen 2cc920ccdc debug: blank timeline rendering 2026-06-17 22:11:52 +05:30
29 changed files with 412 additions and 185 deletions
@@ -138,7 +138,9 @@ class LocalSyncService {
final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await _nativeSyncApi.getAlbums();
final getAlbumsTime = stopwatch.elapsedMilliseconds;
final dbAlbums = await _localAlbumRepository.getAll(sortBy: {SortLocalAlbumsBy.id});
final getAllTime = stopwatch.elapsedMilliseconds;
await diffSortedLists(
dbAlbums,
@@ -148,10 +150,15 @@ class LocalSyncService {
onlyFirst: removeAlbum,
onlySecond: addAlbum,
);
final diffTime = stopwatch.elapsedMilliseconds;
await _nativeSyncApi.checkpointSync();
stopwatch.stop();
_log.info("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
_log.info(
"Full device sync took - ${stopwatch.elapsedMilliseconds}ms "
"(getAlbums=${getAlbumsTime}ms, getAll=${getAllTime - getAlbumsTime}ms, "
"diff=${diffTime - getAllTime}ms, checkpoint=${stopwatch.elapsedMilliseconds - diffTime}ms)",
);
} on PlatformException catch (e, s) {
if (e.code == _kSyncCancelledCode) {
_log.warning("Full device sync cancelled");
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:logging/logging.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -90,6 +91,7 @@ class TimelineFactory {
}
class TimelineService {
static final Logger _log = Logger('TimelineService');
final TimelineAssetSource _assetSource;
final TimelineBucketSource _bucketSource;
final TimelineOrigin origin;
@@ -105,34 +107,49 @@ class TimelineService {
: this._(assetSource: query.assetSource, bucketSource: query.bucketSource, origin: query.origin);
TimelineService._({required this._assetSource, required this._bucketSource, required this.origin}) {
_bucketSubscription = _bucketSource().listen((buckets) {
_mutex.run(() async {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
_bucketSubscription = _bucketSource().listen(
(buckets) {
_mutex.run(() async {
try {
final totalAssets = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
_log.info(
'[$origin] bucket emission: ${buckets.length} buckets / $totalAssets assets '
'(current _totalAssets=$_totalAssets, _bufferOffset=$_bufferOffset, _buffer=${_buffer.length})',
);
if (totalAssets == 0) {
_bufferOffset = 0;
_buffer = [];
} else {
final int offset;
final int count;
// When the buffer is empty or the old bufferOffset is greater than the new total assets,
// we need to reset the buffer and load the first batch of assets.
if (_bufferOffset >= totalAssets || _buffer.isEmpty) {
offset = 0;
count = kTimelineAssetLoadBatchSize;
} else {
offset = _bufferOffset;
count = math.min(_buffer.length, totalAssets - _bufferOffset);
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
_log.info('[$origin] buffer reloaded: offset=$offset requested=$count got=${_buffer.length}');
}
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
} catch (error, stack) {
_log.severe('[$origin] bucket reload FAILED — _totalAssets stuck at $_totalAssets', error, stack);
rethrow;
}
_buffer = await _assetSource(offset, count);
_bufferOffset = offset;
}
// change the state's total assets count only after the buffer is reloaded
_totalAssets = totalAssets;
EventStream.shared.emit(const TimelineReloadEvent());
});
});
});
},
onError: (Object error, StackTrace stack) {
_log.severe('[$origin] bucket stream errored', error, stack);
},
);
}
Stream<List<Bucket>> Function() get watchBuckets => _bucketSource;
@@ -164,6 +181,13 @@ class TimelineService {
_buffer = await _assetSource(start, len);
_bufferOffset = start;
if (!hasRange(index, count)) {
_log.warning(
'[$origin] _loadAssets($index, $count): buffer loaded (offset=$start, got=${_buffer.length}) but still '
'out of range — _totalAssets=$_totalAssets. getAssets is about to throw RangeError.',
);
}
return getAssets(index, count);
}
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:logging/logging.dart';
class FixedSegment extends Segment {
final double tileHeight;
@@ -90,6 +91,7 @@ class FixedSegment extends Segment {
}
class _FixedSegmentRow extends ConsumerWidget {
static final Logger _log = Logger('TimelineRow');
final int assetIndex;
final int assetCount;
final double tileHeight;
@@ -109,8 +111,20 @@ class _FixedSegmentRow extends ConsumerWidget {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = columnCount <= (context.isMobile ? 2 : 3);
final inRange = timelineService.hasRange(assetIndex, assetCount);
if (timelineService.hasRange(assetIndex, assetCount)) {
if (assetIndex == 0) {
_log.info(
'row[0] inRange=$inRange isScrubbing=$isScrubbing totalAssets=${timelineService.totalAssets} '
'branch=${inRange
? "assets"
: isScrubbing
? "placeholder(scrubbing)"
: "future(load)"}',
);
}
if (inRange) {
return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
@@ -129,6 +143,13 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
if (snapshot.hasError) {
_log.warning(
'render row loadAssets($assetIndex, $assetCount) failed (totalAssets=${timelineService.totalAssets})',
snapshot.error,
snapshot.stackTrace,
);
}
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
},
);
@@ -13,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:intl/intl.dart' hide TextDirection;
import 'package:logging/logging.dart';
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
@@ -84,6 +85,7 @@ List<_Segment> _buildSegments({required List<Segment> layoutSegments, required d
}
class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixin {
static final Logger _log = Logger('Scrubber');
String? _lastLabel;
double _thumbTopOffset = 0.0;
bool _isDragging = false;
@@ -114,6 +116,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void initState() {
super.initState();
_log.info('Scrubber initState');
_isDragging = false;
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_thumbAnimationController = AnimationController(vsync: this, duration: kTimelineScrubberFadeInDuration);
@@ -134,7 +137,10 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
void didUpdateWidget(covariant Scrubber oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.layoutSegments.lastOrNull?.endOffset != widget.layoutSegments.lastOrNull?.endOffset) {
final oldEnd = oldWidget.layoutSegments.lastOrNull?.endOffset;
final newEnd = widget.layoutSegments.lastOrNull?.endOffset;
if (oldEnd != newEnd) {
_log.info('Scrubber layoutSegments endOffset $oldEnd -> $newEnd (isDragging=$_isDragging)');
_segments = _buildSegments(layoutSegments: widget.layoutSegments, timelineHeight: _scrubberHeight);
_monthCount = getMonthCount();
}
@@ -142,6 +148,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
@override
void dispose() {
if (_isDragging || _currentScrubberDate != null || _scrubberDebouncer != null) {
_log.warning(
'Scrubber dispose mid-scrub '
'(isDragging=$_isDragging, pendingDate=$_currentScrubberDate, '
'debouncerPending=${_scrubberDebouncer != null}) — scrubbing reset may be orphaned',
);
} else {
_log.info('Scrubber dispose');
}
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeOutTimer?.cancel();
@@ -208,6 +223,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragStart(DragStartDetails _) {
_log.info('scrub dragStart');
setState(() {
_isDragging = true;
_labelAnimationController.forward();
@@ -222,9 +238,15 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
if (_scrubberHeight <= 0) {
_log.warning('drag ignored: scrubberHeight=$_scrubberHeight <= 0');
return;
}
final maxScrollExtent = _scrollController.hasClients ? _scrollController.position.maxScrollExtent : -1;
if (maxScrollExtent <= 0) {
_log.warning('drag ineffective: hasClients=${_scrollController.hasClients} maxScrollExtent=$maxScrollExtent');
}
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
@@ -344,6 +366,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
}
void _onDragEnd(DragEndDetails _) {
_log.info('scrub dragEnd -> setScrubbing(false)');
_labelAnimationController.reverse();
setState(() {
_isDragging = false;
@@ -7,6 +7,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builde
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:logging/logging.dart';
class TimelineArgs {
final double maxWidth;
@@ -71,14 +72,27 @@ class TimelineState {
}
class TimelineStateNotifier extends Notifier<TimelineState> {
static final Logger _log = Logger('TimelineState');
void setScrubbing(bool isScrubbing) {
if (state.isScrubbing != isScrubbing) {
_log.info('isScrubbing ${state.isScrubbing} -> $isScrubbing (from ${_callSite()})');
}
state = state.copyWith(isScrubbing: isScrubbing);
}
void setScrolling(bool isScrolling) {
if (state.isScrolling != isScrolling) {
_log.info('isScrolling ${state.isScrolling} -> $isScrolling (from ${_callSite()})');
}
state = state.copyWith(isScrolling: isScrolling);
}
static String _callSite() {
final frames = StackTrace.current.toString().split('\n');
return frames.length > 2 ? frames[2].trim() : 'unknown';
}
@override
TimelineState build() => const TimelineState(isScrubbing: false, isScrolling: false);
}
@@ -96,6 +110,11 @@ final timelineSegmentProvider = StreamProvider.autoDispose<List<Segment>>((ref)
final timelineService = ref.watch(timelineServiceProvider);
yield* timelineService.watchBuckets().map((buckets) {
final layoutTotal = buckets.fold<int>(0, (acc, bucket) => acc + bucket.assetCount);
Logger('TimelineService').info(
'[${timelineService.origin}] segment layout: '
'${buckets.length} buckets / $layoutTotal assets (service.totalAssets=${timelineService.totalAssets})',
);
return FixedSegmentBuilder(
buckets: buckets,
tileHeight: tileExtent,
@@ -28,6 +28,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
import 'package:logging/logging.dart';
class Timeline extends StatelessWidget {
const Timeline({
@@ -136,6 +137,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
}
class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
static final Logger _log = Logger('Timeline');
late final ScrollController _scrollController;
StreamSubscription? _eventSubscription;
@@ -153,6 +155,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void initState() {
super.initState();
_log.info('SliverTimeline initState');
_scrollController = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
@@ -179,6 +182,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
}
void _onEvent(Event event) {
_log.info('event ${event.runtimeType}');
switch (event) {
case ScrollToTopEvent():
{
@@ -186,7 +190,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineState.setScrubbing(true);
_scrollController
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
.whenComplete(() {
_log.info('ScrollToTop animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
}
case ScrollToDateEvent scrollToDateEvent:
@@ -246,6 +253,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
@override
void dispose() {
_log.info('SliverTimeline dispose');
_scrollController.dispose();
_eventSubscription?.cancel();
super.dispose();
@@ -286,8 +294,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => timelineState.setScrubbing(false));
.whenComplete(() {
_log.info('ScrollToDate animation done -> setScrubbing(false)');
timelineState.setScrubbing(false);
});
} else {
_log.info('ScrollToDate: no matching segment for $date -> setScrubbing(false)');
timelineState.setScrubbing(false);
}
});
@@ -5,6 +5,9 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:logging/logging.dart';
final _log = Logger('TimelineProvider');
final timelineRepositoryProvider = Provider<DriftTimelineRepository>(
(ref) => DriftTimelineRepository(ref.watch(driftProvider)),
@@ -18,7 +21,11 @@ final timelineServiceProvider = Provider<TimelineService>(
(ref) {
final timelineUsers = ref.watch(timelineUsersProvider).valueOrNull ?? [];
final timelineService = ref.watch(timelineFactoryProvider).main(timelineUsers);
ref.onDispose(timelineService.dispose);
_log.info('main TimelineService built users=$timelineUsers');
ref.onDispose(() {
_log.info('main TimelineService disposed');
timelineService.dispose();
});
return timelineService;
},
// Empty dependencies to inform the framework that this provider
@@ -36,8 +43,12 @@ final timelineFactoryProvider = Provider<TimelineFactory>(
final timelineUsersProvider = StreamProvider<List<String>>((ref) {
final currentUserId = ref.watch(currentUserProvider.select((u) => u?.id));
if (currentUserId == null) {
_log.info('timelineUsers: currentUserId=null -> []');
return Stream.value([]);
}
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId);
return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId).map((users) {
_log.info('timelineUsers emission: $users');
return users;
});
});
+2 -2
View File
@@ -38,8 +38,8 @@
</p>
> [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos!
>
> ⚠️ Değerli fotoğraflarınız ve videolarınız için daima [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) yedekleme planını uygulayın!
>
> [!NOTE]
> Kurulum dahil olmak üzere resmi belgeleri https://immich.app/ adresinde bulabilirsiniz.
@@ -129,6 +129,7 @@ from
and "integrity_report"."type" = $1
where
"asset"."deletedAt" is null
and "asset"."isExternal" = false
and "integrity_report"."createdAt" >= $2
and "integrity_report"."createdAt" <= $3
order by
@@ -177,6 +177,7 @@ export class IntegrityRepository {
'asset.id as assetId',
'integrity_report.id as reportId',
])
.where('asset.isExternal', '=', sql.lit(false))
.$if(startMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '>=', startMarker!))
.$if(endMarker !== undefined, (qb) => qb.where('integrity_report.createdAt', '<=', endMarker!))
.orderBy('integrity_report.createdAt', 'asc')
@@ -2939,6 +2939,8 @@ describe(MediaService.name, () => {
'7',
'-global_quality:v',
'23',
'-b:v',
'6897k',
'-maxrate',
'10000k',
'-bufsize',
+6
View File
@@ -788,6 +788,12 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
const options = [`-${this.useCQP() ? 'q:v' : 'global_quality:v'}`, `${this.config.crf}`];
const bitrates = this.getBitrateDistribution();
if (bitrates.max > 0) {
// Workaround for https://github.com/immich-app/immich/issues/29220, to be revisited
// QSV seems to ignore -maxrate without -b:v
// -b:v alongside global_quality uses QVBR
if (!this.useCQP()) {
options.push('-b:v', `${bitrates.target}${bitrates.unit}`);
}
options.push('-maxrate', `${bitrates.max}${bitrates.unit}`, '-bufsize', `${bitrates.max * 2}${bitrates.unit}`);
}
return options;
@@ -686,6 +686,22 @@ describe(IntegrityService.name, () => {
nextCursor: undefined,
});
});
it('should skip external library files', async () => {
const { sut, ctx } = setup();
const job = ctx.getMock(JobRepository);
job.queue.mockResolvedValue(void 0);
const { user } = await ctx.newUser();
await ctx.newAsset({ ownerId: user.id, isExternal: true });
await sut.handleChecksumFiles({ refreshOnly: false });
await expect(
ctx.get(IntegrityRepository).getIntegrityReport({ limit: 100 }, IntegrityReport.ChecksumFail),
).resolves.toEqual({ items: [], nextCursor: undefined });
});
});
describe('handleChecksumRefresh', () => {
@@ -20,6 +20,7 @@
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
@@ -68,6 +69,7 @@
onAssetChange?: (asset: AssetResponseDto) => void;
preAction?: PreAction;
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (assetId: string) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
@@ -83,6 +85,7 @@
onAssetChange,
preAction,
onAction,
onUndoDelete,
onClose,
onRemoveFromAlbum,
onRandom,
@@ -311,6 +314,11 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.DELETE:
case AssetAction.TRASH: {
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
@@ -493,6 +501,7 @@
{stack}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
@@ -4,9 +4,11 @@
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/ArchiveAction.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/DeleteAction.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/KeepThisDeleteOthers.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/RatingAction.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
@@ -23,8 +25,9 @@
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions, handleTrashOrDelete } from '$lib/services/asset.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -34,7 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, shortcut, Tooltip, type ActionItem } from '@immich/ui';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -45,6 +48,7 @@
stack?: StackResponseDto | null;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
@@ -58,6 +62,7 @@
stack = null,
preAction,
onAction,
onUndoDelete = undefined,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
@@ -83,10 +88,6 @@
const sharedLink = getSharedLink();
</script>
<svelte:document
use:shortcut={{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => handleTrashOrDelete(asset, true) }}
/>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<div
@@ -127,8 +128,10 @@
{/if}
<ActionButton action={Actions.Edit} />
<ActionButton action={Actions.Delete} />
<ActionButton action={Actions.PermanentlyDelete} />
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
{#if !sharedLink}
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
@@ -136,7 +139,10 @@
<ActionMenuItem action={Actions.Download} />
<ActionMenuItem action={Actions.DownloadOriginal} />
<ActionMenuItem action={Actions.Restore} />
{#if !isLocked && asset.isTrashed}
<RestoreAction {asset} {onAction} />
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
@@ -324,6 +324,18 @@
shortcut: { key: ' ' },
onShortcut: () => (videoPlayer?.paused ? videoPlayer?.play() : videoPlayer?.pause()),
},
{
shortcut: { shift: true, key: 'ArrowLeft' },
onShortcut: () =>
videoPlayer ? (videoPlayer.currentTime = Math.max(videoPlayer.currentTime - 0.4, 0)) : undefined,
},
{
shortcut: { shift: true, key: 'ArrowRight' },
onShortcut: () =>
videoPlayer
? (videoPlayer.currentTime = Math.min(videoPlayer.currentTime + 0.4, videoPlayer.duration))
: undefined,
},
]}
/>
@@ -0,0 +1,48 @@
import type { AssetResponseDto } from '@immich/sdk';
import '@testing-library/jest-dom';
import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import DeleteAction from './DeleteAction.svelte';
let asset: AssetResponseDto;
describe('DeleteAction component', () => {
beforeEach(() => {
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
});
});
describe('given an asset which is not trashed yet', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: false });
});
it('displays a button to move the asset to the trash bin', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
describe('but if the asset is already trashed', () => {
beforeEach(() => {
asset = assetFactory.build({ isTrashed: true });
});
it('displays a button to permanently delete the asset', () => {
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
asset,
onAction: vi.fn(),
preAction: vi.fn(),
});
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
});
@@ -0,0 +1,75 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import { AssetAction } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
import { IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction, PreAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
preAction: PreAction;
onUndoDelete?: OnUndoDelete;
}
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
const trashOrDelete = async (forceRequest?: boolean) => {
const timelineAsset = toTimelineAsset(asset);
const force = forceDefault || forceRequest;
if (force) {
if ($showDeleteModal) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
return;
}
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
await deleteAssetsUtil(
false,
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
[timelineAsset],
onUndoDelete,
);
};
</script>
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
]}
/>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
onclick={() => trashOrDelete()}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { restoreAssets, type AssetResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { mdiHistory } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
onAction: OnAction;
}
let { asset = $bindable(), onAction }: Props = $props();
const handleRestoreAsset = async () => {
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
</script>
<MenuOption icon={mdiHistory} onClick={handleRestoreAsset} text={$t('restore')} />
@@ -5,6 +5,9 @@ import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
type ActionMap = {
[AssetAction.ARCHIVE]: { asset: TimelineAsset };
[AssetAction.UNARCHIVE]: { asset: TimelineAsset };
[AssetAction.TRASH]: { asset: TimelineAsset };
[AssetAction.DELETE]: { asset: TimelineAsset };
[AssetAction.RESTORE]: { asset: TimelineAsset };
[AssetAction.STACK]: { stack: StackResponseDto };
[AssetAction.UNSTACK]: { assets: TimelineAsset[] };
[AssetAction.SET_STACK_PRIMARY_ASSET]: { stack: StackResponseDto };
@@ -22,7 +22,6 @@
import { t } from 'svelte-i18n';
import ControlAppBar from '../shared-components/ControlAppBar.svelte';
import GalleryViewer from '../shared-components/gallery-viewer/GalleryViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
interface Props {
sharedLink: SharedLinkResponseDto;
@@ -64,20 +63,15 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
await goto(Route.photos());
break;
}
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
// Only used for single asset shared link
if (assetIds.includes(assets[0].id)) {
await goto(Route.photos());
}
};
</script>
{#if sharedLink?.allowUpload || assets.length > 1}
@@ -138,8 +132,6 @@
{/if}
</header>
{:else if assets.length === 1}
<OnEvents {onAssetsDelete} />
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer cursor={{ current: asset }} onAction={handleAction} />
@@ -4,7 +4,6 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/Thumbnail.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import type { AssetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
@@ -286,7 +285,9 @@
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE: {
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),
@@ -304,17 +305,6 @@
}
};
const onAssetsDelete = async (assetIds: string[]) => {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets = assets.filter((asset) => !assetIds.includes(asset.id));
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetIds.includes(assetCursor.current.id) && nextAsset) {
await navigateToAsset(nextAsset);
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
@@ -348,8 +338,6 @@
<svelte:document onselectstart={onSelectStart} use:shortcuts={shortcutList} onscroll={() => updateSlidingWindow()} />
<OnEvents {onAssetsDelete} />
{#if assets.length > 0}
<div
style:position="relative"
@@ -1,12 +1,12 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/AssetViewer.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AssetAction } from '$lib/constants';
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 { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@@ -78,14 +78,6 @@
};
};
/** Find the next asset to show or close the viewer */
const navigateOrCloseViewer = async (id: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(id));
};
//TODO: replace this with async derived in svelte 6
$effect(() => {
const asset = assetViewerManager.asset;
@@ -117,20 +109,35 @@
const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
if (!assetIds.includes(assetCursor.current.id)) {
return;
}
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current.id));
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// must update manager before performing any navigation
timelineManager.removeAssets([action.asset.id]);
await navigateOrCloseViewer(action.asset.id);
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(action.asset.id));
break;
}
// no default
@@ -192,19 +199,9 @@
// no default
}
};
const onAssetsDelete = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (assetIds.includes(assetCursor.current.id)) {
await navigateOrCloseViewer(assetCursor.current.id);
}
};
const onAssetsRestore = async (assets: AssetResponseDto[]) => {
timelineManager.upsertAssets(assets.map((a) => toTimelineAsset(a)));
if (assets.length !== 1) {
// don't reopen asset viewer if multiple assets were restored (bulk action)
const handleUndoDelete = async (assets: TimelineAsset[]) => {
timelineManager.upsertAssets(assets);
if (assets.length === 0) {
return;
}
@@ -237,8 +234,6 @@
});
</script>
<OnEvents {onAssetsDelete} {onAssetsRestore} />
{#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
@@ -254,6 +249,7 @@
handleAction(action);
assetCacheManager.invalidate();
}}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose}
+3
View File
@@ -3,6 +3,9 @@ export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12
export enum AssetAction {
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
@@ -36,7 +36,6 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetsRestore: [AssetResponseDto[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -31,12 +31,6 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
-77
View File
@@ -3,9 +3,7 @@ import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
deleteAssets,
getAssetInfo,
restoreAssets,
runAssetJobs,
updateAsset,
type AssetJobsDto,
@@ -17,15 +15,12 @@ import {
mdiCogRefreshOutline,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDeleteForeverOutline,
mdiDeleteOutline,
mdiDownload,
mdiDownloadBox,
mdiFaceRecognition,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
mdiHistory,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMagnifyMinusOutline,
@@ -39,18 +34,14 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
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 AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
@@ -105,7 +96,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const isDeletionPermanent = asset.isTrashed || !featureFlagsManager.value.trash;
const Share: ActionItem = {
title: $t('share'),
@@ -252,29 +242,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDeleteOutline,
$if: () => isOwner && !isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset),
shortcuts: { key: 'Delete' },
};
const PermanentlyDelete: ActionItem = {
title: $t('permanently_delete'),
icon: mdiDeleteForeverOutline,
$if: () => isOwner && isDeletionPermanent,
onAction: () => handleTrashOrDelete(asset, true),
shortcuts: { key: 'Delete', shift: true },
};
const Restore: ActionItem = {
title: $t('restore'),
icon: mdiHistory,
$if: () => asset.visibility !== AssetVisibility.Locked && asset.isTrashed,
onAction: () => handleRestore(asset),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -319,9 +286,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
Delete,
PermanentlyDelete,
Restore,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
@@ -403,47 +367,6 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
}
};
export const handleTrashOrDelete = async (asset: AssetResponseDto, force?: boolean) => {
const $t = await getFormatter();
if (force && get(showDeleteModal)) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
if (!confirmed) {
return;
}
}
try {
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force } });
eventManager.emit('AssetsDelete', [asset.id]);
if (force) {
toastManager.primary($t('permanently_deleted_asset'));
} else {
toastManager.primary(
{
description: $t('moved_to_trash'),
button: { label: $t('undo'), color: 'secondary', onclick: () => handleRestore(asset) },
},
{ timeout: 5000 },
);
}
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}
};
const handleRestore = async (asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
eventManager.emit('AssetsRestore', [asset]);
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}
};
const getAssetJobMessage = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
+3 -2
View File
@@ -24,7 +24,8 @@ class FaceManager {
});
readonly people = $derived.by(() => {
const people = new SvelteMap<string, PersonResponseDto>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const people = new Map<string, PersonResponseDto>();
for (const face of this.data) {
if (face.person) {
@@ -32,7 +33,7 @@ class FaceManager {
}
}
return people.values();
return Array.from(people.values());
});
readonly facesByPersonId = $derived.by(() => {
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import LargeAssetData from './LargeAssetData.svelte';
@@ -36,14 +37,16 @@
return asset;
};
const onAssetsDelete = async (assetIds: string[]) => {
if (assetIds.includes(assetCursor.current.id)) {
const preAction = async (payload: Action) => {
if (payload.type == 'trash') {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
assetViewerManager.showAssetViewer(false);
}
};
const onAssetsDelete = (assetIds: string[]) => {
assets = assets.filter(({ id }) => !assetIds.includes(id));
};
@@ -81,6 +84,7 @@
cursor={assetCursor}
showNavigation={assets.length > 1}
{onRandom}
{preAction}
onClose={() => {
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));