Compare commits

..

2 Commits

Author SHA1 Message Date
Mees Frensel 698b96d597 refactor(web): stack-related service and actions 2026-06-30 20:14:56 +02:00
Yaros 4099fa6b4a fix(mobile): app doesn't exit full-screen mode (#29301)
* fix(mobile): app doesn't exit full-screen mode

* chore: rename restoreSystemUI to restoreEdgeToEdge
2026-06-24 20:48:01 -05:00
34 changed files with 378 additions and 649 deletions
+3 -1
View File
@@ -33,7 +33,7 @@
"add_to_albums": "Add to albums",
"add_to_albums_count": "Add to albums ({count})",
"add_to_bottom_bar": "Add to",
"add_upload_to_stack": "Add upload to stack",
"add_upload_to_stack": "Upload and {isStack, select, true {add to stack} other {create stack}}",
"add_url": "Add URL",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
@@ -1733,6 +1733,7 @@
"removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_from_stack": "Removed asset from stack",
"removed_memory": "Removed memory",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
@@ -2008,6 +2009,7 @@
"source": "Source",
"stack": "Stack",
"stack_action_prompt": "{count} stacked",
"stack_created": "Stack created",
"stack_duplicates": "Stack duplicates",
"stack_selected_photos": "Stack selected photos",
"stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",
@@ -67,9 +67,6 @@ class URLSessionManager: NSObject {
delegate = URLSessionManagerDelegate()
session = Self.buildSession(delegate: delegate)
super.init()
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
Self.serverUrls = UserDefaults.group.stringArray(forKey: SERVER_URLS_KEY) ?? []
NotificationCenter.default.addObserver(
Self.self,
@@ -81,9 +78,6 @@ class URLSessionManager: NSObject {
func recreateSession() {
session = Self.buildSession(delegate: delegate)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
}
static func setServerUrls(_ urls: [String]) {
@@ -255,6 +249,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
let credential = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
return completion(.useCredential, credential)
}
completion(.performDefaultHandling, nil)
@@ -271,6 +268,9 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
else {
return completion(.performDefaultHandling, nil)
}
if #available(iOS 15, *) {
VideoProxyServer.shared.session = session
}
let credential = URLCredential(user: user, password: password, persistence: .forSession)
completion(.useCredential, credential)
}
@@ -7,7 +7,6 @@ class ServerFeatures {
final bool passwordLogin;
final bool ocr;
final bool smartSearch;
final bool realtimeTranscoding;
const ServerFeatures({
required this.trash,
@@ -16,7 +15,6 @@ class ServerFeatures {
required this.passwordLogin,
this.ocr = false,
this.smartSearch = false,
this.realtimeTranscoding = false,
});
ServerFeatures copyWith({
@@ -26,7 +24,6 @@ class ServerFeatures {
bool? passwordLogin,
bool? ocr,
bool? smartSearch,
bool? realtimeTranscoding,
}) {
return ServerFeatures(
trash: trash ?? this.trash,
@@ -35,13 +32,12 @@ class ServerFeatures {
passwordLogin: passwordLogin ?? this.passwordLogin,
ocr: ocr ?? this.ocr,
smartSearch: smartSearch ?? this.smartSearch,
realtimeTranscoding: realtimeTranscoding ?? this.realtimeTranscoding,
);
}
@override
String toString() {
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch, realtimeTranscoding: $realtimeTranscoding)';
return 'ServerFeatures(trash: $trash, map: $map, oauthEnabled: $oauthEnabled, passwordLogin: $passwordLogin, ocr: $ocr, smartSearch: $smartSearch)';
}
ServerFeatures.fromDto(ServerFeaturesDto dto)
@@ -50,8 +46,7 @@ class ServerFeatures {
oauthEnabled = dto.oauth,
passwordLogin = dto.passwordLogin,
ocr = dto.ocr,
smartSearch = dto.smartSearch,
realtimeTranscoding = dto.realtimeTranscoding;
smartSearch = dto.smartSearch;
@override
bool operator ==(covariant ServerFeatures other) {
@@ -64,8 +59,7 @@ class ServerFeatures {
other.oauthEnabled == oauthEnabled &&
other.passwordLogin == passwordLogin &&
other.ocr == ocr &&
other.smartSearch == smartSearch &&
other.realtimeTranscoding == realtimeTranscoding;
other.smartSearch == smartSearch;
}
@override
@@ -75,7 +69,6 @@ class ServerFeatures {
oauthEnabled.hashCode ^
passwordLogin.hashCode ^
ocr.hashCode ^
smartSearch.hashCode ^
realtimeTranscoding.hashCode;
smartSearch.hashCode;
}
}
@@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@@ -49,7 +50,7 @@ class DriftMemoryPage extends HookConsumerWidget {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
};
});
@@ -328,7 +329,7 @@ class DriftMemoryPage extends HookConsumerWidget {
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
restoreEdgeToEdge();
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -19,6 +19,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -76,7 +77,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -255,7 +256,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _onTapUp() async {
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge());
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -377,7 +378,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
child: NativeVideoViewer(
asset: asset,
isCurrent: isCurrent,
imageProvider: imageProvider,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
@@ -388,7 +388,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
asset: asset,
localFilePath: localFilePath,
isCurrent: isCurrent,
imageProvider: imageProvider,
image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center),
),
);
}
@@ -23,6 +23,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
@@ -128,7 +129,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
unawaited(restoreEdgeToEdge());
super.dispose();
}
@@ -251,10 +252,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
final immersive = !controls || (CurrentPlatform.isIOS && details);
unawaited(immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge());
}
@override
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -15,31 +14,22 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
final _hlsVideoSessionIdRegex = RegExp(
r'/video/stream/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/',
);
// For BC if we add an audio endpoint
final _hlsAudioSessionIdRegex = RegExp(
r'/audio/stream/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/',
);
class NativeVideoViewer extends ConsumerStatefulWidget {
final BaseAsset asset;
final String? localFilePath;
final bool isCurrent;
final bool showControls;
final ImageProvider imageProvider;
final Widget image;
const NativeVideoViewer({
super.key,
required this.asset,
this.localFilePath,
required this.imageProvider,
required this.image,
this.isCurrent = false,
this.showControls = true,
});
@@ -56,7 +46,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
Timer? _loadTimer;
bool _isVideoReady = false;
bool _shouldPlayOnForeground = true;
String? _remoteAssetId;
VideoPlayerNotifier get _notifier => ref.read(videoPlayerProvider(widget.asset.heroTag).notifier);
@@ -78,16 +67,11 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
if (!widget.isCurrent) {
_loadTimer?.cancel();
_notifier.pause();
_notifier.endHlsSession();
return;
}
if (ref.read(serverInfoProvider).serverFeatures.realtimeTranscoding) {
_loadVideo();
} else {
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
// Prevent unnecessary loading when swiping between assets.
_loadTimer = Timer(const Duration(milliseconds: 200), _loadVideo);
}
@override
@@ -157,22 +141,14 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
);
}
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final realtimeTranscoding = ref.read(serverInfoProvider).serverFeatures.realtimeTranscoding;
// Motion photo clips are short, so spinning up a transcoding session for them is wasteful
final useHls = !isOriginalVideo && videoAsset.livePhotoVideoId == null && realtimeTranscoding;
final remoteId = (videoAsset as RemoteAsset).id;
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl;
if (useHls) {
videoUrl = '$serverEndpoint/assets/$remoteId/video/stream/main.m3u8';
} else {
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
}
_remoteAssetId = remoteId;
final isOriginalVideo = ref.read(appConfigProvider).viewer.loadOriginalVideo;
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = videoAsset.livePhotoVideoId != null
? '$serverEndpoint/assets/${videoAsset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/$remoteId/$postfixUrl';
return VideoSource.init(path: videoUrl, type: VideoSourceType.network, headers: ApiService.getRequestHeaders());
} catch (error) {
@@ -233,17 +209,11 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
_notifier.onNativeStatusChanged();
}
void _onSourceResolved() {
final url = _controller?.onPlaybackSourceResolved.value;
_notifier.updateHlsSession(assetId: _remoteAssetId, sessionId: url == null ? null : _extractHlsSessionId(url));
}
void _removeListeners() {
_controller?.onPlaybackPositionChanged.removeListener(_onPlaybackPositionChanged);
_controller?.onPlaybackStatusChanged.removeListener(_onPlaybackStatusChanged);
_controller?.onPlaybackReady.removeListener(_onPlaybackReady);
_controller?.onPlaybackEnded.removeListener(_onPlaybackEnded);
_controller?.onPlaybackSourceResolved.removeListener(_onSourceResolved);
}
void _loadVideo() async {
@@ -274,7 +244,6 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
nc.onPlaybackStatusChanged.addListener(_onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(_onPlaybackReady);
nc.onPlaybackEnded.addListener(_onPlaybackEnded);
nc.onPlaybackSourceResolved.addListener(_onSourceResolved);
_controller = nc;
@@ -283,85 +252,30 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
}
}
/// Extracts the HLS session id from a resolved playlist or segment URL,
/// e.g. `https://host/api/assets/{id}/video/stream/{sessionId}/0/playlist.m3u8`.
String? _extractHlsSessionId(String url) =>
_hlsVideoSessionIdRegex.firstMatch(url)?.group(1) ?? _hlsAudioSessionIdRegex.firstMatch(url)?.group(1);
@override
Widget build(BuildContext context) {
final image = Image(image: widget.imageProvider, fit: BoxFit.contain, alignment: Alignment.center);
if (ref.watch(castProvider.select((c) => c.isCasting))) {
return IgnorePointer(child: Center(child: image));
}
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final status = ref.watch(videoPlayerProvider(widget.asset.heroTag).select((v) => v.status));
return IgnorePointer(
child: Stack(
clipBehavior: Clip.none,
children: [
// The engine snaps the video platform view to the device-pixel grid; snap the
// placeholder the same way so it doesn't show a hairline past the video's edge.
_DevicePixelSnap(devicePixelRatio: MediaQuery.devicePixelRatioOf(context), child: image),
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
if (status == VideoPlaybackStatus.buffering) const Center(child: CircularProgressIndicator()),
Center(child: widget.image),
if (!isCasting) ...[
Visibility.maintain(
visible: _isVideoReady,
child: NativeVideoPlayerView(onViewReady: _initController),
),
Center(
child: AnimatedOpacity(
opacity: status == VideoPlaybackStatus.buffering ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: const CircularProgressIndicator(),
),
),
],
],
),
);
}
}
class _DevicePixelSnap extends SingleChildRenderObjectWidget {
final double devicePixelRatio;
const _DevicePixelSnap({required this.devicePixelRatio, required Widget super.child});
@override
_RenderDevicePixelSnap createRenderObject(BuildContext context) => _RenderDevicePixelSnap(devicePixelRatio);
@override
void updateRenderObject(BuildContext context, _RenderDevicePixelSnap renderObject) {
renderObject.devicePixelRatio = devicePixelRatio;
}
}
class _RenderDevicePixelSnap extends RenderShiftedBox {
_RenderDevicePixelSnap(this._devicePixelRatio) : super(null);
double _devicePixelRatio;
set devicePixelRatio(double value) {
if (_devicePixelRatio == value) {
return;
}
_devicePixelRatio = value;
markNeedsLayout();
}
/// The largest device-pixel-aligned extent that still tucks under the platform view.
double _snap(double extent) {
final scaled = extent * _devicePixelRatio;
final floored = scaled.floorToDouble();
if (floored == scaled) {
return extent;
}
final pixels = floored - 1;
return pixels <= 0 ? extent : pixels / _devicePixelRatio;
}
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
@override
void performLayout() {
size = constraints.biggest;
final child = this.child;
if (child == null) {
return;
}
child.layout(BoxConstraints.tight(Size(_snap(size.width), _snap(size.height))));
(child.parentData! as BoxParentData).offset = Offset.zero;
}
}
@@ -66,7 +66,7 @@ class DriftMemoryCard extends StatelessWidget {
asset: asset,
isCurrent: isCurrent,
showControls: false,
imageProvider: getFullImageProvider(asset, size: context.sizeData),
image: FullImage(asset, size: context.sizeData, fit: BoxFit.contain),
),
),
);
@@ -1,10 +1,8 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:openapi/api.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
enum VideoPlaybackStatus { paused, playing, buffering, completed }
@@ -35,25 +33,21 @@ final videoPlayerProvider = StateNotifierProvider.autoDispose.family<VideoPlayer
ref,
name,
) {
return VideoPlayerNotifier(ref.read(apiServiceProvider).assetsApi);
return VideoPlayerNotifier();
});
class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
static final _log = Logger('VideoPlayerNotifier');
VideoPlayerNotifier(this._assetsApi) : super(_defaultState);
VideoPlayerNotifier() : super(_defaultState);
final AssetsApi _assetsApi;
NativeVideoPlayerController? _controller;
Timer? _bufferingTimer;
Timer? _seekTimer;
VideoPlaybackStatus? _holdStatus;
String? _hlsAssetId;
String? _hlsSessionId;
@override
void dispose() {
endHlsSession();
_bufferingTimer?.cancel();
_seekTimer?.cancel();
WakelockPlus.disable();
@@ -66,29 +60,6 @@ class VideoPlayerNotifier extends StateNotifier<VideoPlayerState> {
_controller = controller;
}
void updateHlsSession({required String? assetId, required String? sessionId}) {
if (sessionId == null || sessionId == _hlsSessionId) {
return;
}
endHlsSession();
_hlsAssetId = assetId;
_hlsSessionId = sessionId;
}
void endHlsSession() {
final assetId = _hlsAssetId;
final sessionId = _hlsSessionId;
_hlsSessionId = null;
if (assetId == null || sessionId == null) {
return;
}
unawaited(
_assetsApi.endSession(assetId, sessionId).onError((error, stackTrace) {
_log.warning('Failed to end HLS session $sessionId for asset $assetId', error, stackTrace);
}),
);
}
Future<void> load(VideoSource source) async {
_startBufferingTimer();
try {
+14
View File
@@ -0,0 +1,14 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// Restore the system bars and return to edge-to-edge layout.
///
/// On Android 15+/API 36 edge-to-edge is enforced, so calling
/// setEnabledSystemUIMode(edgeToEdge) does NOT re-show bars that an immersive
/// mode (immersive / immersiveSticky) previously hid. Explicitly request all
/// overlays first, then return to edge-to-edge layout.
Future<void> restoreEdgeToEdge() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
+2 -2
View File
@@ -1128,8 +1128,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "7b07eb0af23de588a8c937c203bcf157397b2c5c"
resolved-ref: "7b07eb0af23de588a8c937c203bcf157397b2c5c"
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
+1 -1
View File
@@ -48,7 +48,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: '8b8184d71f2d1ccc32fe153152b2615c0c718b3e'
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
network_info_plus: ^6.1.4
octo_image: ^2.1.0
openapi:
@@ -102,7 +102,7 @@
const stackSelectedThumbnailSize = 65;
let previewStackedAsset: AssetResponseDto | undefined = $state();
let stack: StackResponseDto | null = $state(null);
let stack: StackResponseDto | undefined = $state();
const asset = $derived(previewStackedAsset ?? cursor.current);
const nextAsset = $derived(cursor.nextAsset);
@@ -127,7 +127,7 @@
}
if (!stack?.assets.some(({ id }) => id === asset.id)) {
stack = null;
stack = undefined;
}
};
@@ -149,6 +149,22 @@
}
};
const onStackCreate = (createdStack: StackResponseDto) => {
if (createdStack.assets.map((a) => a.id).includes(asset.id)) {
stack = createdStack;
}
};
const onStackUpdate = (updatedStack: StackResponseDto) => {
if (stack?.id === updatedStack.id) {
stack = updatedStack;
if (!stack.assets.map((a) => a.id).includes(asset.id)) {
// current asset was removed from stack, go to primary
cursor.current = stack.assets[0];
}
}
};
onMount(() => {
syncAssetViewerOpenClass(true);
const slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
@@ -319,18 +335,6 @@
eventManager.emit('AssetsDelete', [asset.id]);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
stack = action.stack;
if (stack) {
cursor.current = stack.assets[0];
}
break;
}
case AssetAction.STACK:
case AssetAction.SET_STACK_PRIMARY_ASSET: {
stack = action.stack;
break;
}
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
const assetInfo = await getAssetInfo({ id: asset.id });
cursor.current = { ...asset, people: assetInfo.people };
@@ -347,10 +351,6 @@
};
break;
}
case AssetAction.UNSTACK: {
closeViewer();
break;
}
// no default
}
@@ -475,7 +475,7 @@
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag, TagPeople]} />
<OnEvents {onAssetUpdate} />
<OnEvents {onAssetUpdate} {onStackCreate} onStackDelete={() => closeViewer()} {onStackUpdate} />
<svelte:document
bind:fullscreenElement
@@ -1,17 +1,12 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
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 SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
@@ -21,6 +16,7 @@
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getStackActions } from '$lib/services/stack.service';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -40,7 +36,7 @@
asset: AssetResponseDto;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
stack?: StackResponseDto;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
@@ -54,7 +50,7 @@
asset,
album = null,
person = null,
stack = null,
stack,
preAction,
onAction,
onUndoDelete = undefined,
@@ -86,6 +82,7 @@
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
const StackActions = $derived(getStackActions($t, stack, asset));
const sharedLink = getSharedLink();
</script>
@@ -150,19 +147,12 @@
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{/if}
{/if}
<ActionMenuItem action={StackActions.AddUploads} />
<ActionMenuItem action={StackActions.Unstack} />
<ActionMenuItem action={StackActions.KeepThisDeleteOthers} />
<ActionMenuItem action={StackActions.SetPrimaryAsset} />
<ActionMenuItem action={StackActions.RemoveAsset} />
{#if album}
{@const { SetCover } = getAlbumAssetActions($t, album, asset)}
<ActionMenuItem action={SetCover} />
@@ -1,37 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { createStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiUploadMultiple } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto | null;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleAddUploadToStack = async () => {
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stacks primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
const newStack = await createStack({
stackCreateDto: {
assetIds,
},
});
onAction({ type: AssetAction.STACK, stack: newStack });
};
</script>
<MenuOption icon={mdiUploadMultiple} onClick={handleAddUploadToStack} text={$t('add_upload_to_stack')} />
@@ -1,38 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { AssetResponseDto, StackResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiPinOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
asset: AssetResponseDto;
onAction: OnAction;
}
let { stack, asset, onAction }: Props = $props();
const handleKeepThisDeleteOthers = async () => {
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),
});
if (!isConfirmed) {
return;
}
const keptAsset = await keepThisDeleteOthers(asset, stack);
if (keptAsset) {
onAction({ type: AssetAction.UNSTACK, assets: [toTimelineAsset(keptAsset)] });
}
};
</script>
<MenuOption icon={mdiPinOutline} onClick={handleKeepThisDeleteOthers} text={$t('keep_this_delete_others')} />
@@ -1,31 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { removeAssetFromStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiImageMinusOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
asset: AssetResponseDto;
stack: StackResponseDto;
onAction: OnAction;
}
let { asset, stack, onAction }: Props = $props();
const handleRemoveFromStack = async () => {
await removeAssetFromStack({
id: stack.id,
assetId: asset.id,
});
const updatedStack = {
...stack,
assets: stack.assets.filter((a) => a.id !== asset.id),
};
onAction({ type: AssetAction.REMOVE_ASSET_FROM_STACK, stack: updatedStack, asset });
};
</script>
<MenuOption icon={mdiImageMinusOutline} onClick={handleRemoveFromStack} text={$t('viewer_remove_from_stack')} />
@@ -1,26 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { updateStack, type AssetResponseDto, type StackResponseDto } from '@immich/sdk';
import { mdiImageCheckOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
asset: AssetResponseDto;
onAction: OnAction;
}
let { stack, asset, onAction }: Props = $props();
const handleSetPrimaryAsset = async () => {
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
if (updatedStack) {
onAction({ type: AssetAction.SET_STACK_PRIMARY_ASSET, stack: updatedStack });
}
};
</script>
<MenuOption icon={mdiImageCheckOutline} onClick={handleSetPrimaryAsset} text={$t('set_stack_primary_asset')} />
@@ -1,26 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { AssetAction } from '$lib/constants';
import { deleteStack } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import type { StackResponseDto } from '@immich/sdk';
import { mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { OnAction } from './action';
interface Props {
stack: StackResponseDto;
onAction: OnAction;
}
let { stack, onAction }: Props = $props();
const handleUnstack = async () => {
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((asset) => toTimelineAsset(asset)) });
}
};
</script>
<MenuOption icon={mdiImageOffOutline} onClick={handleUnstack} text={$t('unstack')} />
@@ -1,4 +1,4 @@
import type { AssetResponseDto, PersonResponseDto, StackResponseDto } from '@immich/sdk';
import type { AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import type { AssetAction } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -8,10 +8,6 @@ type ActionMap = {
[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 };
[AssetAction.REMOVE_ASSET_FROM_STACK]: { stack: StackResponseDto | null; asset: AssetResponseDto };
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
@@ -9,7 +9,6 @@
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';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleErrorAsync } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
@@ -150,52 +149,6 @@
timelineManager.upsertAssets([action.asset]);
break;
}
case AssetAction.STACK: {
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
// no default
}
};
@@ -1,45 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import type { OnStack, OnUnstack } from '$lib/utils/actions';
import { deleteStack, stackAssets } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { mdiImageMultipleOutline, mdiImageOffOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
unstack?: boolean;
onStack: OnStack | undefined;
onUnstack: OnUnstack | undefined;
}
let { unstack = false, onStack, onUnstack }: Props = $props();
const handleStack = async () => {
const result = await stackAssets(assetMultiSelectManager.ownedAssets);
onStack?.(result);
assetMultiSelectManager.clear();
};
const handleUnstack = async () => {
const selectedAssets = assetMultiSelectManager.ownedAssets;
if (selectedAssets.length !== 1) {
return;
}
const { stack } = selectedAssets[0];
if (!stack) {
return;
}
const unstackedAssets = await deleteStack([stack.id]);
if (unstackedAssets) {
onUnstack?.(unstackedAssets.map((a) => toTimelineAsset(a)));
}
assetMultiSelectManager.clear();
};
</script>
{#if unstack}
<MenuOption text={$t('unstack')} icon={mdiImageOffOutline} onClick={handleUnstack} />
{:else}
<MenuOption text={$t('stack')} icon={mdiImageMultipleOutline} onClick={handleStack} />
{/if}
@@ -15,12 +15,13 @@
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import { handleStack } from '$lib/services/stack.service';
import { keyboardManager } from '$lib/stores/keyboard-manager.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, selectAllAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { isModalOpen, modalManager } from '@immich/ui';
@@ -59,10 +60,7 @@
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.assets);
updateStackedAssetInTimeline(timelineManager, result);
await handleStack(assetInteraction.assets.map((asset) => asset.id));
onEscape?.();
};
-4
View File
@@ -6,10 +6,6 @@ export enum AssetAction {
TRASH = 'trash',
DELETE = 'delete',
RESTORE = 'restore',
STACK = 'stack',
UNSTACK = 'unstack',
SET_STACK_PRIMARY_ASSET = 'set-stack-primary-asset',
REMOVE_ASSET_FROM_STACK = 'remove-asset-from-stack',
SET_VISIBILITY_LOCKED = 'set-visibility-locked',
SET_VISIBILITY_TIMELINE = 'set-visibility-timeline',
SET_PERSON_FEATURED_PHOTO = 'set-person-featured-photo',
@@ -11,6 +11,7 @@ import type {
QueueResponseDto,
ReleaseEventV1,
SharedLinkResponseDto,
StackResponseDto,
SystemConfigDto,
TagResponseDto,
UserAdminResponseDto,
@@ -61,6 +62,11 @@ export type Events = {
SharedLinkUpdate: [SharedLinkResponseDto];
SharedLinkDelete: [SharedLinkResponseDto];
StackCreate: [StackResponseDto];
/** Unstacked, with assets to handle */
StackDelete: [{ id: string; assets: AssetResponseDto[] }];
StackUpdate: [StackResponseDto];
TagCreate: [TagResponseDto];
TagUpdate: [TagResponseDto];
TagDelete: [TreeNode];
@@ -17,6 +17,7 @@ import {
retrieveRange as retrieveRangeUtil,
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
import { updateStackedAssetInTimeline } from '$lib/utils/actions';
import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import {
@@ -115,6 +116,23 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.#updateAssets([toTimelineAsset(asset)]),
StackCreate: (stack) => updateStackedAssetInTimeline(this, stack),
StackDelete: ({ assets }) => {
this.update(
assets.map((asset) => asset.id),
(asset) => (asset.stack = null),
);
this.upsertAssets(assets.map((asset) => toTimelineAsset(asset)));
},
StackUpdate: (stack) => {
// unstack and re-stack
this.update(
stack.assets.map((asset) => asset.id),
(asset) => (asset.stack = null),
);
this.upsertAssets(stack.assets.map((asset) => toTimelineAsset(asset)));
updateStackedAssetInTimeline(this, stack);
},
}),
);
}
+216
View File
@@ -0,0 +1,216 @@
import {
createStack,
deleteAssets,
deleteStacks,
getStack,
removeAssetFromStack,
updateStack,
type AssetResponseDto,
type AssetStackResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiImageCheckOutline,
mdiImageMinusOutline,
mdiImageMultipleOutline,
mdiImageOffOutline,
mdiPinOutline,
mdiUploadMultiple,
} from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
export const getStackBulkActions = ($t: MessageFormatter) => {
const Stack: ActionItem = {
title: $t('stack'),
icon: mdiImageMultipleOutline,
$if: () => assetMultiSelectManager.ownedAssets.length > 1,
onAction: () => handleStack(assetMultiSelectManager.ownedAssets.map((asset) => asset.id)),
};
const Unstack: ActionItem = {
title: $t('unstack'),
icon: mdiImageOffOutline,
$if: () => assetMultiSelectManager.ownedAssets.every((asset) => !!asset.stack),
onAction: () => handleDeleteStacks(assetMultiSelectManager.ownedAssets.map(({ stack }) => stack!)),
};
return { Stack, Unstack };
};
export const getStackActions = ($t: MessageFormatter, stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
const authUser = authManager.authenticated ? authManager.user : undefined;
const isAssetOwner = !!(authUser && authUser.id === asset.ownerId);
const validStack = !!stack && isAssetOwner;
const AddUploads: ActionItem = {
title: $t('add_upload_to_stack', { values: { isStack: !!stack } }),
icon: mdiUploadMultiple,
$if: () => isAssetOwner,
onAction: () => handleAddUploadToStack(stack, asset),
};
const KeepThisDeleteOthers: ActionItem = {
title: $t('keep_this_delete_others'),
icon: mdiPinOutline,
$if: () => validStack,
onAction: () => handleKeepThisDeleteOthers(stack!, asset),
};
const RemoveAsset: ActionItem = {
title: $t('viewer_remove_from_stack'),
icon: mdiImageMinusOutline,
$if: () => validStack && stack?.primaryAssetId !== asset.id && stack?.assets?.length > 2,
onAction: () => handleRemoveFromStack(stack!, asset),
};
const SetPrimaryAsset: ActionItem = {
title: $t('set_stack_primary_asset'),
icon: mdiImageCheckOutline,
$if: () => validStack && stack!.primaryAssetId !== asset.id,
onAction: () => handleSetPrimaryAsset(stack!, asset),
};
const Unstack: ActionItem = {
title: $t('unstack'),
icon: mdiImageOffOutline,
$if: () => validStack,
onAction: () => handleDeleteStack(stack!),
};
return { AddUploads, KeepThisDeleteOthers, RemoveAsset, SetPrimaryAsset, Unstack };
};
const handleAddUploadToStack = async (stack: StackResponseDto | undefined, asset: AssetResponseDto) => {
const $t = await getFormatter();
const newAssetIds = await openFileUploadDialog({ multiple: true });
// Including the old stack's primary asset ID ensures that all assets of the
// old stack are automatically included in the new stack.
const primaryAssetId = stack?.primaryAssetId ?? asset.id;
// First asset in the list will become the new primary asset.
const assetIds = [primaryAssetId, ...newAssetIds];
try {
const stack = await createStack({ stackCreateDto: { assetIds } });
toastManager.primary($t('stack_created'));
eventManager.emit('StackCreate', stack);
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
}
};
const handleKeepThisDeleteOthers = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
const isConfirmed = await modalManager.showDialog({
title: $t('keep_this_delete_others'),
prompt: $t('confirm_keep_this_delete_others'),
confirmText: $t('delete_others'),
});
if (!isConfirmed) {
return;
}
try {
const assetsToDeleteIds = stack.assets.filter((a) => a.id !== asset.id).map((asset) => asset.id);
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
eventManager.emit('StackDelete', { id: stack.id, assets: [asset] });
eventManager.emit('AssetUpdate', { ...asset, stack: null });
} catch (error) {
handleError(error, $t('errors.failed_to_keep_this_delete_others'));
}
};
const handleRemoveFromStack = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
await removeAssetFromStack({ id: stack.id, assetId: asset.id });
const updatedStack = {
...stack,
assets: stack.assets.filter((a) => a.id !== asset.id),
};
toastManager.primary($t('removed_from_stack'));
eventManager.emit('AssetUpdate', asset); // todo: check if this re-inserts into timeline
eventManager.emit('StackUpdate', updatedStack);
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
const handleSetPrimaryAsset = async (stack: StackResponseDto, asset: AssetResponseDto) => {
const $t = await getFormatter();
try {
const updatedStack = await updateStack({ id: stack.id, stackUpdateDto: { primaryAssetId: asset.id } });
// todo: toast?
eventManager.emit('StackUpdate', updatedStack);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
export const handleStack = async (assetIds: string[]) => {
const $t = await getFormatter();
try {
const stack = await createStack({ stackCreateDto: { assetIds } });
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
eventManager.emit('StackCreate', stack);
assetMultiSelectManager.clear();
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
}
};
export const handleDeleteStack = async (stack: StackResponseDto) => {
const $t = await getFormatter();
try {
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
const assetIds = stack.assets.map((asset) => asset.id);
toastManager.primary($t('unstacked_assets_count', { values: { count: assetIds.length } }));
eventManager.emit('StackDelete', stack);
return assetIds;
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
const handleDeleteStacks = async (assetStacks: AssetStackResponseDto[]) => {
const $t = await getFormatter();
try {
const stacks = await Promise.all(assetStacks.map((stack) => getStack(stack)));
await Promise.all(stacks.map((stack) => handleDeleteStack(stack)));
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
assetMultiSelectManager.clear();
};
+12 -40
View File
@@ -1,10 +1,9 @@
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets } from '@immich/sdk';
import { AssetVisibility, deleteAssets as deleteBulk, restoreAssets, type StackResponseDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { StackResponse } from '$lib/utils/asset-utils';
import { handleError } from './handle-error';
export type OnDelete = (assetIds: string[]) => void;
@@ -15,8 +14,6 @@ export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset })
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
export type OnFavorite = (ids: string[], favorite: boolean) => void;
export type OnStack = (result: StackResponse) => void;
export type OnUnstack = (assets: TimelineAsset[]) => void;
export type OnSetVisibility = (ids: string[]) => void;
export const deleteAssets = async (
@@ -62,43 +59,18 @@ const undoDeleteAssets = async (onUndoDelete: OnUndoDelete, assets: TimelineAsse
/**
* Update the asset stack state in the asset store based on the provided stack response.
* This function updates the stack information so that the icon is shown for the primary asset
* and removes any assets from the timeline that are marked for deletion.
*
* @param {TimelineManager} timelineManager - The timeline manager to update.
* @param {StackResponse} stackResponse - The stack response containing the stack and assets to delete.
* and removes the non-primary assets from the timeline.
*/
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, { stack, toDeleteIds }: StackResponse) {
if (stack != undefined) {
timelineManager.update(
[stack.primaryAssetId],
(asset) =>
(asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
}),
);
export function updateStackedAssetInTimeline(timelineManager: TimelineManager, stack: StackResponseDto) {
timelineManager.update([stack.primaryAssetId], (asset) => {
asset.stack = {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assetCount: stack.assets.length,
};
});
timelineManager.removeAssets(toDeleteIds);
}
}
/**
* Update the timeline manager to reflect the unstacked state of assets.
* This function updates the stack property of each asset to undefined, effectively unstacking them.
* It also adds the unstacked assets back to the timeline manager.
*
* @param timelineManager - The timeline manager to update.
* @param assets - The array of asset response DTOs to update in the timeline manager.
*/
export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager, assets: TimelineAsset[]) {
timelineManager.update(
assets.map((asset) => asset.id),
(asset) => {
asset.stack = null;
return { remove: false };
},
timelineManager.removeAssets(
stack.assets.filter((asset) => asset.id !== stack.primaryAssetId).map((asset) => asset.id),
);
timelineManager.upsertAssets(assets);
}
-83
View File
@@ -1,12 +1,8 @@
import {
AssetVisibility,
bulkTagAssets,
createStack,
deleteAssets,
deleteStacks,
getBaseUrl,
getDownloadInfo,
getStack,
untagAssets,
updateAsset,
updateAssets,
@@ -14,7 +10,6 @@ import {
type AssetTypeEnum,
type DownloadInfoDto,
type ExifResponseDto,
type StackResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
@@ -281,84 +276,6 @@ export const getOwnedAssetsWithWarning = (assets: TimelineAsset[], user: UserRes
return ids;
};
export type StackResponse = {
stack?: StackResponseDto;
toDeleteIds: string[];
};
export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
if (assets.length < 2) {
return { stack: undefined, toDeleteIds: [] };
}
const $t = get(t);
try {
const stack = await createStack({ stackCreateDto: { assetIds: assets.map(({ id }) => id) } });
if (showNotification) {
toastManager.primary({
description: $t('stacked_assets_count', { values: { count: stack.assets.length } }),
button: {
label: $t('view_stack'),
onclick: () => navigate({ targetRoute: 'current', assetId: stack.primaryAssetId }),
},
});
}
return {
stack,
toDeleteIds: assets.slice(1).map((asset) => asset.id),
};
} catch (error) {
handleError(error, $t('errors.failed_to_stack_assets'));
return { stack: undefined, toDeleteIds: [] };
}
};
export const deleteStack = async (stackIds: string[]) => {
const ids = [...new Set(stackIds)];
if (ids.length === 0) {
return;
}
const $t = get(t);
try {
const stacks = await Promise.all(ids.map((id) => getStack({ id })));
const count = stacks.reduce((sum, stack) => sum + stack.assets.length, 0);
await deleteStacks({ bulkIdsDto: { ids: [...ids] } });
toastManager.primary($t('unstacked_assets_count', { values: { count } }));
const assets = stacks.flatMap((stack) => stack.assets);
for (const asset of assets) {
asset.stack = null;
}
return assets;
} catch (error) {
handleError(error, $t('errors.failed_to_unstack_assets'));
}
};
export const keepThisDeleteOthers = async (keepAsset: AssetResponseDto, stack: StackResponseDto) => {
const $t = get(t);
try {
const assetsToDeleteIds = stack.assets.filter((asset) => asset.id !== keepAsset.id).map((asset) => asset.id);
await deleteAssets({ assetBulkDeleteDto: { ids: assetsToDeleteIds } });
await deleteStacks({ bulkIdsDto: { ids: [stack.id] } });
toastManager.primary($t('kept_this_deleted_others', { values: { count: assetsToDeleteIds.length } }));
keepAsset.stack = null;
return keepAsset;
} catch (error) {
handleError(error, $t('errors.failed_to_keep_this_delete_others'));
}
};
export const selectAllAssets = async (timelineManager: TimelineManager, assetInteraction: AssetMultiSelectManager) => {
if (assetInteraction.selectAll) {
// Selection is already ongoing
@@ -13,7 +13,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -22,13 +21,9 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service';
import { mapSettings } from '$lib/stores/preferences.store';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CloseButton, CommandPaletteDefaultProvider, Icon } from '@immich/ui';
import { mdiDotsVertical, mdiImageMultiple } from '@mdi/js';
@@ -46,7 +41,6 @@
let timelineManager = $state<TimelineManager>() as TimelineManager;
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -85,6 +79,7 @@
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
isFavorite: $mapSettings.onlyFavorites || undefined,
withPartners: $mapSettings.withPartners || undefined,
withStacked: true,
assetFilter: selectedClusterIds,
});
@@ -113,12 +108,14 @@
onEscape={handleEscape}
assetInteraction={assetMultiSelectManager}
showArchiveIcon
withStacked
/>
</div>
</aside>
{#if assetMultiSelectManager.selectionActive}
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<Portal target="body">
@@ -135,13 +132,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
@@ -481,7 +481,10 @@
<ArchiveAction
menuItem
unarchive={assetMultiSelectManager.isAllArchived}
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
onArchive={(ids, visibility) =>
timelineManager.update(ids, (asset) => {
asset.visibility = visibility;
})}
/>
{#if authManager.preferences.tags.enabled && assetMultiSelectManager.isAllUserOwned}
<TagAction menuItem />
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -26,13 +25,9 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { getStackBulkActions } from '$lib/services/stack.service';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
@@ -45,7 +40,6 @@
const options = { visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true };
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -114,6 +108,7 @@
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
@@ -128,13 +123,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
@@ -14,7 +14,6 @@
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
@@ -24,18 +23,14 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { type OnLink, type OnUnlink } from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, AssetOrderBy } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { getStackBulkActions } from '$lib/services/stack.service';
type Props = {
data: PageData;
@@ -52,7 +47,6 @@
};
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
@@ -108,6 +102,7 @@
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
{@const StackActions = getStackBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
@@ -122,13 +117,8 @@
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
<ActionMenuItem action={StackActions.Stack} />
<ActionMenuItem action={StackActions.Unstack} />
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem