From 37cc0288688d17125253c61f2e4a2468bb7d2aa0 Mon Sep 17 00:00:00 2001 From: Marius Date: Thu, 14 May 2026 13:57:19 +0200 Subject: [PATCH 01/46] fix(mobile): use correct delete action (#26575) fix(mobile): use correct delete for trashed assets When viewing a trashed asset, the viewer bottom bar now shows the permanent delete button instead of the trash button, which had no effect on already-trashed assets. --- .../models/asset/remote_asset.model.dart | 15 +++++++- .../entities/remote_asset.entity.dart | 1 + ...delete_permanent_action_button.widget.dart | 11 +++++- .../asset_viewer/bottom_bar.widget.dart | 10 +++-- mobile/lib/utils/action_button.utils.dart | 6 +-- .../action_button_utils_test.dart | 38 +++++++++++++++++++ 6 files changed, 71 insertions(+), 10 deletions(-) diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index a810877dcc..b370825fdd 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -11,6 +11,7 @@ class RemoteAsset extends BaseAsset { final String ownerId; final String? stackId; final DateTime? uploadedAt; + final DateTime? deletedAt; const RemoteAsset({ required this.id, @@ -31,6 +32,7 @@ class RemoteAsset extends BaseAsset { super.livePhotoVideoId, this.stackId, required super.isEdited, + this.deletedAt, }) : localAssetId = localId; @override @@ -48,6 +50,8 @@ class RemoteAsset extends BaseAsset { @override bool get isEditable => isImage && !isMotionPhoto && !isAnimatedImage; + bool get isTrashed => deletedAt != null; + @override String toString() { return '''Asset { @@ -86,7 +90,8 @@ class RemoteAsset extends BaseAsset { thumbHash == other.thumbHash && visibility == other.visibility && stackId == other.stackId && - uploadedAt == other.uploadedAt; + uploadedAt == other.uploadedAt && + deletedAt == other.deletedAt; } @override @@ -98,7 +103,8 @@ class RemoteAsset extends BaseAsset { thumbHash.hashCode ^ visibility.hashCode ^ stackId.hashCode ^ - uploadedAt.hashCode; + uploadedAt.hashCode ^ + deletedAt.hashCode; RemoteAsset copyWith({ String? id, @@ -119,6 +125,7 @@ class RemoteAsset extends BaseAsset { String? livePhotoVideoId, String? stackId, bool? isEdited, + DateTime? deletedAt, }) { return RemoteAsset( id: id ?? this.id, @@ -139,6 +146,7 @@ class RemoteAsset extends BaseAsset { livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, stackId: stackId ?? this.stackId, isEdited: isEdited ?? this.isEdited, + deletedAt: deletedAt ?? this.deletedAt, ); } } @@ -156,6 +164,7 @@ class RemoteAssetExif extends RemoteAsset { required super.createdAt, required super.updatedAt, super.uploadedAt, + super.deletedAt, super.width, super.height, super.durationMs, @@ -193,6 +202,7 @@ class RemoteAssetExif extends RemoteAsset { DateTime? createdAt, DateTime? updatedAt, DateTime? uploadedAt, + DateTime? deletedAt, int? width, int? height, int? durationMs, @@ -214,6 +224,7 @@ class RemoteAssetExif extends RemoteAsset { createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, uploadedAt: uploadedAt ?? this.uploadedAt, + deletedAt: deletedAt ?? this.deletedAt, width: width ?? this.width, height: height ?? this.height, durationMs: durationMs ?? this.durationMs, diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 8644667168..ad1cec5641 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -74,5 +74,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { localId: localId, stackId: stackId, isEdited: isEdited, + deletedAt: deletedAt, ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart index d2df013369..267a9f55e6 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart @@ -18,8 +18,15 @@ class DeletePermanentActionButton extends ConsumerWidget { final ActionSource source; final bool iconOnly; final bool menuItem; + final bool useShortLabel; - const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false}); + const DeletePermanentActionButton({ + super.key, + required this.source, + this.iconOnly = false, + this.menuItem = false, + this.useShortLabel = false, + }); void _onTap(BuildContext context, WidgetRef ref) async { if (!context.mounted) { @@ -64,7 +71,7 @@ class DeletePermanentActionButton extends ConsumerWidget { return BaseActionButton( maxWidth: 110.0, iconData: Icons.delete_forever, - label: "delete_permanently".t(context: context), + label: useShortLabel ? "delete".t(context: context) : "delete_permanently".t(context: context), iconOnly: iconOnly, menuItem: menuItem, onPressed: () => _onTap(context, ref), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 21401f37e5..e317c598f5 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -7,6 +7,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/restore_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; @@ -55,9 +56,12 @@ class ViewerBottomBar extends ConsumerWidget { if (asset.hasRemote) AddActionButton(originalTheme: originalTheme), ], if (isOwner) ...[ - asset.isLocalOnly - ? const DeleteLocalActionButton(source: ActionSource.viewer) - : const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), + if (asset.isLocalOnly) + const DeleteLocalActionButton(source: ActionSource.viewer) + else if (asset.isTrashed) + const DeletePermanentActionButton(source: ActionSource.viewer, useShortLabel: true) + else + const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true), ], ], ]; diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index c38c536136..87588efe65 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -123,9 +123,8 @@ enum ActionButtonType { context.timelineOrigin == TimelineOrigin.trash, ActionButtonType.deletePermanent => context.isOwner && // - context.asset.hasRemote && // - !context.isTrashEnabled || - context.isInLockedView, + context.asset.hasRemote && // + (!context.isTrashEnabled || context.timelineOrigin == TimelineOrigin.trash || context.isInLockedView), ActionButtonType.delete => context.isOwner && // !context.isInLockedView && // @@ -324,6 +323,7 @@ class ActionButtonBuilder { ActionButtonType.archive, ActionButtonType.unarchive, ActionButtonType.restoreTrash, + ActionButtonType.deletePermanent, }; static List build(ActionButtonContext context) { diff --git a/mobile/test/utils_legacy/action_button_utils_test.dart b/mobile/test/utils_legacy/action_button_utils_test.dart index 8bd078a433..0a6020762a 100644 --- a/mobile/test/utils_legacy/action_button_utils_test.dart +++ b/mobile/test/utils_legacy/action_button_utils_test.dart @@ -38,6 +38,7 @@ RemoteAsset createRemoteAsset({ DateTime? updatedAt, DateTime? uploadedAt, bool isFavorite = false, + DateTime? deletedAt, }) { return RemoteAsset( id: 'remote-id', @@ -51,6 +52,7 @@ RemoteAsset createRemoteAsset({ uploadedAt: uploadedAt ?? DateTime.now(), isFavorite: isFavorite, isEdited: false, + deletedAt: deletedAt, ); } @@ -459,6 +461,24 @@ void main() { expect(ActionButtonType.trash.shouldShow(context), isFalse); }); + + test('should not show when asset is already trashed', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024)); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.trash.shouldShow(context), isFalse); + }); }); group('restoreTrash button', () { @@ -533,6 +553,24 @@ void main() { expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse); }); + + test('should show when asset is trashed even with trash enabled', () { + final remoteAsset = createRemoteAsset(deletedAt: DateTime(2024)); + final context = ActionButtonContext( + asset: remoteAsset, + isOwner: true, + isArchived: false, + isTrashEnabled: true, + isInLockedView: false, + currentAlbum: null, + advancedTroubleshooting: false, + isStacked: false, + source: ActionSource.viewer, + timelineOrigin: TimelineOrigin.trash, + ); + + expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue); + }); }); group('delete button', () { From b0c9743d9ad03c08edc76f0ec2371cdc3e9e9d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nojus=20Gudinavi=C4=8Dius?= <46261165+gnojus@users.noreply.github.com> Date: Thu, 14 May 2026 15:46:31 +0300 Subject: [PATCH 02/46] feat(server): allow subpaths for machine learning URL (#28427) This allows to use a machine learning server URL under a subpath, such as "http://example.com/ml-server/". --- server/src/repositories/machine-learning.repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/repositories/machine-learning.repository.ts b/server/src/repositories/machine-learning.repository.ts index 49778b5193..6792e8ecf4 100644 --- a/server/src/repositories/machine-learning.repository.ts +++ b/server/src/repositories/machine-learning.repository.ts @@ -132,7 +132,7 @@ export class MachineLearningRepository { private async check(url: string) { let healthy = false; try { - const response = await fetch(new URL('/ping', url), { + const response = await fetch(new URL('ping', url), { signal: AbortSignal.timeout(this.config.availabilityChecks.timeout), }); if (response.ok) { @@ -170,7 +170,7 @@ export class MachineLearningRepository { ...this.config.urls.filter((url) => !this.isHealthy(url)), ]) { try { - const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); + const response = await fetch(new URL('predict', url), { method: 'POST', body: formData }); if (response.ok) { this.setHealthy(url, true); return response.json(); From 06729ee5a59920a31009d0371f3768d726aa7566 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 15 May 2026 02:51:06 +0530 Subject: [PATCH 03/46] chore: cleanup unused store keys (#28415) cleanup unused store keys Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> From 43687cd8b4bf9139c1e2a5f6ffce26a497cf9017 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 14 May 2026 17:23:50 -0500 Subject: [PATCH 04/46] fix: kebab menu icon colors and actions (#28433) --- .../action_buttons/base_action_button.widget.dart | 12 +++++++----- .../asset_viewer/viewer_kebab_menu.widget.dart | 2 +- mobile/lib/utils/action_button.utils.dart | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 1ca875e483..6599ff0ffd 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -35,10 +35,11 @@ class BaseActionButton extends ConsumerWidget { final miniWidth = minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; - final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final textColor = context.themeData.textTheme.labelLarge?.color; if (iconOnly) { + final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; + return IconButton( onPressed: onPressed, icon: Icon(iconData, size: iconSize, color: iconColor), @@ -46,17 +47,18 @@ class BaseActionButton extends ConsumerWidget { } if (menuItem) { - final theme = context.themeData; - final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant; + final iconColor = this.iconColor; return MenuItemButton( style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), - leadingIcon: Icon(iconData, color: effectiveIconColor), + leadingIcon: Icon(iconData, color: iconColor), onPressed: onPressed, - child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16, color: iconColor)), + child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)), ); } + final iconColor = this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; + return ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), child: MaterialButton( diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart index 308f6a72a3..5a79485daf 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart @@ -50,7 +50,7 @@ class ViewerKebabMenu extends ConsumerWidget { timelineOrigin: timelineOrigin, ); - final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context); + final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref); return MenuAnchor( consumeOutsideTap: true, diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index 87588efe65..a048e245cb 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -330,7 +330,7 @@ class ActionButtonBuilder { return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList(); } - static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext) { + static List buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) { final visibleButtons = defaultViewerKebabMenuOrder .where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context)) .toList(); @@ -346,7 +346,7 @@ class ActionButtonBuilder { if (lastGroup != null && type.kebabMenuGroup != lastGroup) { result.add(const Divider(height: 1)); } - result.add(type.buildButton(context, buildContext, false, true)); + result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref)); lastGroup = type.kebabMenuGroup; } From e91c017dd02d9eff2ab8d005f7e6a7210dd6d1c5 Mon Sep 17 00:00:00 2001 From: Robert Deaton Date: Thu, 14 May 2026 17:59:15 -0700 Subject: [PATCH 05/46] fix(server): dedupe database backup jobs (#28341) * fix(server): dedupe database backup jobs via jobId #27268 shows backup jobs piling up in the queue across upgrades; one pending backup is always enough. * fix(tests): Avoid stale backup files from previous test runs being erroneously returned from createBackup * fix(jobs): Use bullmq's deduplication over jobId to avoid failed jobs from blocking future executions. --------- Co-authored-by: Robert Deaton --- .../maintenance/server/database-backups.e2e-spec.ts | 5 ++++- e2e/src/utils.ts | 2 ++ server/src/repositories/job.repository.ts | 11 +++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts index b69bd099ed..e3bd98db28 100644 --- a/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts +++ b/e2e/src/specs/maintenance/server/database-backups.e2e-spec.ts @@ -2,7 +2,7 @@ import { LoginResponseDto, ManualJobName } from '@immich/sdk'; import { errorDto } from 'src/responses'; import { app, utils } from 'src/utils'; import request from 'supertest'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; describe('/admin/database-backups', () => { let cookie: string | undefined; @@ -13,6 +13,9 @@ describe('/admin/database-backups', () => { admin = await utils.adminSetup({ onboarding: false, }); + }); + + beforeEach(async () => { await utils.resetBackups(admin.accessToken); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 74c2832c3e..7e51b40f63 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -568,6 +568,8 @@ export const utils = { name: ManualJobName.BackupDatabase, }); + await utils.waitForQueueFinish(accessToken, 'backupDatabase'); + return utils.poll( () => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`), ({ status, body }) => status === 200 && body.backups.length === 1, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index a94e5aa9f6..5bb5276db7 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -171,8 +171,8 @@ export class JobRepository { options: this.getJobOptions(item) || undefined, } as JobItem & { data: any; options: JobsOptions | undefined }; - if (job.options?.jobId) { - // need to use add() instead of addBulk() for jobId deduplication + if (job.options?.jobId || job.options?.deduplication) { + // need to use add() instead of addBulk() for jobId/deduplication to take effect promises.push(this.getQueue(queueName).add(item.name, item.data, job.options)); } else { itemsByQueue[queueName] = itemsByQueue[queueName] || []; @@ -230,10 +230,13 @@ export class JobRepository { return { priority: 1 }; } case JobName.FacialRecognitionQueueAll: { - return { jobId: JobName.FacialRecognitionQueueAll }; + return { deduplication: { id: JobName.FacialRecognitionQueueAll } }; } case JobName.VersionCheck: { - return { jobId: JobName.VersionCheck }; + return { deduplication: { id: JobName.VersionCheck } }; + } + case JobName.DatabaseBackup: { + return { deduplication: { id: JobName.DatabaseBackup } }; } default: { return null; From 21d6755f3975ac319ef3ae2578bde5082fd6857f Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Thu, 14 May 2026 20:22:23 -0700 Subject: [PATCH 06/46] fix(web): recently added ux (#28435) --- web/src/routes/(user)/explore/+page.svelte | 37 +++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index df4c9a9eb5..fa688d7e8b 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -4,15 +4,18 @@ import OnEvents from '$lib/components/OnEvents.svelte'; import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte'; import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte'; + import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { Route } from '$lib/route'; import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; - import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; + import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { Icon } from '@immich/ui'; import { mdiHeart } from '@mdi/js'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { getAltText } from '$lib/utils/thumbnail-util'; + import Portal from '$lib/elements/Portal.svelte'; interface Props { data: PageData; @@ -40,6 +43,15 @@ } } }; + + const onViewAsset = async (id: string) => { + const asset = await getAssetInfo({ ...authManager.params, id }); + assetViewerManager.setAsset(asset); + }; + + const assetCursor = $derived({ + current: assetViewerManager.asset!, + }); @@ -122,15 +134,20 @@ draggable="false">{$t('view_all')} -
+
{#each recents as item (item.data.id)} - + {/each}
@@ -140,3 +157,15 @@ {/if} + +{#if assetViewerManager.isViewing} + {#await import('$lib/components/asset-viewer/AssetViewer.svelte') then { default: AssetViewer }} + + assetViewerManager.showAssetViewer(false)} + /> + + {/await} +{/if} From 01d6a244d86be4ccb5835d2edce66de4e10684b5 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 00:48:23 +0600 Subject: [PATCH 07/46] fix(mobile): cronet buffer overflow on compressed thumbnails (#28439) CronetImageFetcher sized the response buffer from Content-Length, which is the compressed wire size. Cronet auto-decompresses gzip/br responses and writes decompressed bytes into the buffer, exceeding it and throwing IllegalArgumentException: ByteBuffer is already full on the next read. Use the growable path; Content-Length becomes an initial alloc hint only, capped at 128 MB so an untrusted server can't overflow Int.MAX_VALUE or OOM us upfront. Reuse Cronet's ByteBuffer between reads when no grow is needed. --- .../immich/images/RemoteImagesImpl.kt | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 9255eff44b..d1651b7960 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -23,6 +23,8 @@ import java.io.IOException import java.nio.ByteBuffer import java.util.concurrent.ConcurrentHashMap +private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024 + private class RemoteRequest(val cancellationSignal: CancellationSignal) class RemoteImagesImpl(context: Context) : RemoteImageApi { @@ -228,7 +230,6 @@ private class CronetImageFetcher : ImageFetcher { private val onComplete: () -> Unit, ) : UrlRequest.Callback() { private var buffer: NativeByteBuffer? = null - private var wrapped: ByteBuffer? = null private var error: Exception? = null override fun onRedirectReceived(request: UrlRequest, info: UrlResponseInfo, newUrl: String) { @@ -242,15 +243,16 @@ private class CronetImageFetcher : ImageFetcher { } try { + // Content-Length is a size hint only. With Content-Encoding (gzip/br/...), + // Cronet auto-decompresses and writes decompressed bytes to our buffer, which + // may exceed the wire/compressed Content-Length. Always use the growable + // buffer path so we can't overflow. val contentLength = info.allHeaders["content-length"]?.firstOrNull()?.toIntOrNull() ?: 0 - if (contentLength > 0) { - buffer = NativeByteBuffer(contentLength + 1) - wrapped = NativeBuffer.wrap(buffer!!.pointer, contentLength + 1) - request.read(wrapped) - } else { - buffer = NativeByteBuffer(INITIAL_BUFFER_SIZE) - request.read(buffer!!.wrapRemaining()) - } + // Cap the up-front alloc: Content-Length is untrusted and can be huge or near + // Int.MAX_VALUE (overflowing `+1`). For larger responses the grow path takes over. + val initialSize = if (contentLength in 1..MAX_PREALLOC_BYTES) contentLength + 1 else INITIAL_BUFFER_SIZE + buffer = NativeByteBuffer(initialSize) + request.read(buffer!!.wrapRemaining()) } catch (e: Exception) { error = e return request.cancel() @@ -263,14 +265,18 @@ private class CronetImageFetcher : ImageFetcher { byteBuffer: ByteBuffer ) { try { - val buf = if (wrapped == null) { - buffer!!.run { - advance(byteBuffer.position()) - ensureHeadroom() - wrapRemaining() - } + val b = buffer!! + b.advance(byteBuffer.position()) + // Reuse the caller-supplied ByteBuffer as long as we don't need to grow. + // It already points at our native memory with position advanced past the + // written bytes — Cronet can keep writing into the remaining tail. + // Only when the buffer is full do we grow (which may realloc + move the + // native pointer) and need a fresh wrap. + val buf = if (b.offset == b.capacity) { + b.ensureHeadroom() + b.wrapRemaining() } else { - wrapped + byteBuffer } request.read(buf) } catch (e: Exception) { @@ -280,7 +286,6 @@ private class CronetImageFetcher : ImageFetcher { } override fun onSucceeded(request: UrlRequest, info: UrlResponseInfo) { - wrapped?.let { buffer!!.advance(it.position()) } onSuccess(buffer!!) onComplete() } From 17779c1e7412f400ffa64f68e405724b06e3fd09 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 03:25:31 +0600 Subject: [PATCH 08/46] fix(mobile): cronet thumbnail buffer overflow regression from #28439 (#28450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hybrid added in onReadCompleted reuses Cronet's ByteBuffer between reads to save a JNI wrap call when no grow is needed. That reuse breaks advance() — Cronet's position() is cumulative across reads, so the same K bytes get counted on every subsequent iteration. b.offset overshoots b.capacity, the reuse branch keeps firing on a now-empty buffer, and request.read() throws the original IllegalArgumentException again. Always pass a fresh wrap from wrapRemaining() so byteBuffer.position() reflects only this iteration's bytes. Same shape as the original PR had before the broken optimization was layered on top. --- .../immich/images/RemoteImagesImpl.kt | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index d1651b7960..f7ebc349f6 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -265,18 +265,14 @@ private class CronetImageFetcher : ImageFetcher { byteBuffer: ByteBuffer ) { try { - val b = buffer!! - b.advance(byteBuffer.position()) - // Reuse the caller-supplied ByteBuffer as long as we don't need to grow. - // It already points at our native memory with position advanced past the - // written bytes — Cronet can keep writing into the remaining tail. - // Only when the buffer is full do we grow (which may realloc + move the - // native pointer) and need a fresh wrap. - val buf = if (b.offset == b.capacity) { - b.ensureHeadroom() - b.wrapRemaining() - } else { - byteBuffer + // Always pass a fresh wrap so byteBuffer.position() represents only the + // bytes Cronet wrote in this iteration. Reusing the caller-supplied + // ByteBuffer breaks advance(): Cronet's position keeps accumulating + // across reads, which would double-count previous iterations' bytes. + val buf = buffer!!.run { + advance(byteBuffer.position()) + ensureHeadroom() + wrapRemaining() } request.read(buf) } catch (e: Exception) { From df016f92282eba58e48b2d4bf9c6f1c6282aaeb9 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 03:41:04 +0600 Subject: [PATCH 09/46] fix(mobile): mounted check in ThumbnailTile hero flight listener (#28451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user pops back from the asset viewer mid-flight, the hero animation can fire its status listener after _ThumbnailTileState has been disposed. setState then throws a null check on State._element. Guard the listener with `if (!mounted) return;` — same pattern as #28300 in the album sync action. --- .../lib/presentation/widgets/images/thumbnail_tile.widget.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 8720cc4253..286e874e1b 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -120,6 +120,9 @@ class _ThumbnailTileState extends ConsumerState { }, flightShuttleBuilder: (context, animation, direction, from, to) { void animationStatusListener(AnimationStatus status) { + if (!mounted) { + return; + } final heroInFlight = status == AnimationStatus.forward || status == AnimationStatus.reverse; if (_hideIndicators != heroInFlight) { setState(() => _hideIndicators = heroInFlight); From 0ef04d9baa6917a56dfedff7e45e452226fb0b63 Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Fri, 15 May 2026 16:12:04 -0700 Subject: [PATCH 10/46] feat(mobile): slideshow view (#28421) * feat(mobile): slideshow view * move slideshow settings to metadata store * remove watch in initState * wrap progress bar in safearea * show slideshow button on remote albums * fix crash on unknown assets * always show slideshow option * add zoom effect * add padding to slideshow settings * chore: styling tweak --------- Co-authored-by: Alex --- mobile/lib/constants/enums.dart | 4 + .../lib/domain/models/config/app_config.dart | 12 +- .../models/config/slideshow_config.dart | 48 +++ mobile/lib/domain/models/metadata_key.dart | 14 +- mobile/lib/domain/models/store.model.dart | 3 + .../repositories/metadata.repository.dart | 7 + .../pages/drift_slideshow.page.dart | 350 ++++++++++++++++++ .../base_action_button.widget.dart | 9 +- .../slideshow_action_button.widget.dart | 34 ++ mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 47 +++ mobile/lib/utils/action_button.utils.dart | 4 + .../common/remote_album_sliver_app_bar.dart | 5 + .../asset_viewer_settings.dart | 2 + .../slideshow_settings.dart | 123 ++++++ 15 files changed, 657 insertions(+), 7 deletions(-) create mode 100644 mobile/lib/domain/models/config/slideshow_config.dart create mode 100644 mobile/lib/presentation/pages/drift_slideshow.page.dart create mode 100644 mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart create mode 100644 mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 877145c322..473bd52b03 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -18,3 +18,7 @@ enum CleanupStep { selectDate, scan, delete } enum AssetKeepType { none, photosOnly, videosOnly } enum AssetDateAggregation { start, end } + +enum SlideshowLook { contain, cover, blurredBackground } + +enum SlideshowDirection { forward, backward, shuffle } diff --git a/mobile/lib/domain/models/config/app_config.dart b/mobile/lib/domain/models/config/app_config.dart index beca1c21e7..e639b7b7e4 100644 --- a/mobile/lib/domain/models/config/app_config.dart +++ b/mobile/lib/domain/models/config/app_config.dart @@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/config/map_config.dart'; import 'package:immich_mobile/domain/models/config/theme_config.dart'; import 'package:immich_mobile/domain/models/config/timeline_config.dart'; import 'package:immich_mobile/domain/models/config/viewer_config.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; class AppConfig { final ThemeConfig theme; @@ -12,6 +13,7 @@ class AppConfig { final TimelineConfig timeline; final ImageConfig image; final ViewerConfig viewer; + final SlideshowConfig slideshow; const AppConfig({ this.theme = const .new(), @@ -20,6 +22,7 @@ class AppConfig { this.timeline = const .new(), this.image = const .new(), this.viewer = const .new(), + this.slideshow = const .new(), }); AppConfig copyWith({ @@ -29,6 +32,7 @@ class AppConfig { TimelineConfig? timeline, ImageConfig? image, ViewerConfig? viewer, + SlideshowConfig? slideshow, }) => .new( theme: theme ?? this.theme, cleanup: cleanup ?? this.cleanup, @@ -36,6 +40,7 @@ class AppConfig { timeline: timeline ?? this.timeline, image: image ?? this.image, viewer: viewer ?? this.viewer, + slideshow: slideshow ?? this.slideshow, ); @override @@ -47,12 +52,13 @@ class AppConfig { other.map == map && other.timeline == timeline && other.image == image && - other.viewer == viewer); + other.viewer == viewer && + other.slideshow == slideshow); @override - int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer); + int get hashCode => Object.hash(theme, cleanup, map, timeline, image, viewer, slideshow); @override String toString() => - 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer)'; + 'AppConfig(theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow)'; } diff --git a/mobile/lib/domain/models/config/slideshow_config.dart b/mobile/lib/domain/models/config/slideshow_config.dart new file mode 100644 index 0000000000..74c0ac9d38 --- /dev/null +++ b/mobile/lib/domain/models/config/slideshow_config.dart @@ -0,0 +1,48 @@ +import 'package:immich_mobile/constants/enums.dart'; + +class SlideshowConfig { + final bool transition; + final bool repeat; + final int duration; + final SlideshowLook look; + final SlideshowDirection direction; + + const SlideshowConfig({ + this.transition = true, + this.repeat = true, + this.duration = 5, + this.look = SlideshowLook.contain, + this.direction = SlideshowDirection.forward, + }); + + SlideshowConfig copyWith({ + bool? transition, + bool? repeat, + int? duration, + SlideshowLook? look, + SlideshowDirection? direction, + }) => SlideshowConfig( + transition: transition ?? this.transition, + repeat: repeat ?? this.repeat, + duration: duration ?? this.duration, + look: look ?? this.look, + direction: direction ?? this.direction, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SlideshowConfig && + other.transition == transition && + other.repeat == repeat && + other.duration == duration && + other.look == look && + other.direction == direction); + + @override + int get hashCode => Object.hash(transition, repeat, duration, look, direction); + + @override + String toString() => + 'SlideshowConfig(transition: $transition, repeat: $repeat, duration: $duration, look: $look, direction: $direction)'; +} diff --git a/mobile/lib/domain/models/metadata_key.dart b/mobile/lib/domain/models/metadata_key.dart index 61a3cebc8a..04ef506f89 100644 --- a/mobile/lib/domain/models/metadata_key.dart +++ b/mobile/lib/domain/models/metadata_key.dart @@ -64,7 +64,19 @@ enum MetadataKey { ), cleanupKeepAlbumIds>(.appConfig, 'cleanup.keepAlbumIds', [], _ListCodec(_PrimitiveCodec.string)), cleanupCutoffDaysAgo(.appConfig, 'cleanup.cutoffDaysAgo', -1), - cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false); + cleanupDefaultsInitialized(.appConfig, 'cleanup.defaultsInitialized', false), + + // Slideshow + slideshowTransition(.appConfig, 'slideshow.transition', true), + slideshowRepeat(.appConfig, 'slideshow.repeat', true), + slideshowDuration(.appConfig, 'slideshow.duration', 5), + slideshowLook(.appConfig, 'slideshow.look', SlideshowLook.contain, _EnumCodec(SlideshowLook.values)), + slideshowDirection( + .appConfig, + 'slideshow.direction', + SlideshowDirection.forward, + _EnumCodec(SlideshowDirection.values), + ); final MetadataDomain domain; final String name; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 63281e49da..f2a3fcc2c0 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -29,6 +29,9 @@ enum StoreKey { readonlyModeEnabled._(138), albumGridView._(140), + // Image viewer navigation settings + tapToNavigate._(141), + // Experimental stuff enableBackup._(1003), useWifiForUploadVideos._(1004), diff --git a/mobile/lib/infrastructure/repositories/metadata.repository.dart b/mobile/lib/infrastructure/repositories/metadata.repository.dart index d8c8f55898..b5801b9b9c 100644 --- a/mobile/lib/infrastructure/repositories/metadata.repository.dart +++ b/mobile/lib/infrastructure/repositories/metadata.repository.dart @@ -139,6 +139,13 @@ extension on MetadataDomain { autoPlayVideo: repo._read(.viewerAutoPlayVideo), tapToNavigate: repo._read(.viewerTapToNavigate), ), + slideshow: .new( + transition: repo._read(.slideshowTransition), + repeat: repo._read(.slideshowRepeat), + duration: repo._read(.slideshowDuration), + look: repo._read(.slideshowLook), + direction: repo._read(.slideshowDirection), + ), ); case .systemConfig: repo._systemConfig = .new(logLevel: repo._read(.logLevel)); diff --git a/mobile/lib/presentation/pages/drift_slideshow.page.dart b/mobile/lib/presentation/pages/drift_slideshow.page.dart new file mode 100644 index 0000000000..693a4d201f --- /dev/null +++ b/mobile/lib/presentation/pages/drift_slideshow.page.dart @@ -0,0 +1,350 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/config/slideshow_config.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/settings.page.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +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/metadata.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; + +@RoutePage() +class DriftSlideshowPage extends ConsumerStatefulWidget { + final TimelineService timeline; + + const DriftSlideshowPage({super.key, required this.timeline}); + + @override + ConsumerState createState() => _DriftSlideshowPageState(); +} + +class _DriftSlideshowPageState extends ConsumerState { + late final SlideshowConfig _config; + late final PageController _pageController; + late final Stopwatch _stopwatch; + late Timer _timer; + late int _index; + late int _nextIndex; + bool _paused = false; + bool _showAppBar = false; + + @override + initState() { + super.initState(); + _config = ref.read(appConfigProvider.select((s) => s.slideshow)); + final asset = ref.read(assetViewerProvider).currentAsset; + _index = asset == null ? 0 : widget.timeline.getIndex(asset.heroTag) ?? 0; + _pageController = PageController(initialPage: _index); + _stopwatch = Stopwatch(); + _createTimer(); + _updateNextIndex(); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + } + + @override + dispose() { + _timer.cancel(); + _stopwatch.stop(); + _pageController.dispose(); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + super.dispose(); + } + + void _play() { + final asset = widget.timeline.getAssetSafe(_index)!; + + if (asset.isImage) { + _createTimer(); + } else if (ref.read(videoPlayerProvider(asset.heroTag)).status == VideoPlaybackStatus.paused) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).play(); + } else { + _nextPage(); + } + + _updateNextIndex(); + + setState(() { + _paused = false; + }); + } + + void _pause() { + _timer.cancel(); + _stopwatch.stop(); + + final asset = widget.timeline.getAssetSafe(_index)!; + + if (!asset.isImage) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).pause(); + } + + setState(() { + _paused = true; + }); + } + + void _updateNextIndex() { + _nextIndex = switch (_config.direction) { + SlideshowDirection.forward => _index + 1, + SlideshowDirection.backward => _index - 1, + SlideshowDirection.shuffle => widget.timeline.getIndex(widget.timeline.getRandomAsset().heroTag)!, + }; + + if (!widget.timeline.hasRange(_nextIndex, 1)) { + widget.timeline.preloadAssets(_nextIndex); + } + } + + void _nextPage() async { + if (_nextIndex < 0 || _nextIndex >= widget.timeline.totalAssets) { + if (_config.repeat) { + final wrapped = _config.direction == SlideshowDirection.forward ? 0 : widget.timeline.totalAssets - 1; + await widget.timeline.preloadAssets(wrapped); + _pageController.jumpToPage(wrapped); + } + return; + } + + if (!widget.timeline.hasRange(_nextIndex, 1)) { + await widget.timeline.preloadAssets(_nextIndex); + } + + if (_config.direction == SlideshowDirection.shuffle || !_config.transition) { + _pageController.jumpToPage(_nextIndex); + } else { + unawaited(_pageController.animateToPage(_nextIndex, duration: Durations.long2, curve: Curves.easeIn)); + } + } + + void _createTimer() { + _timer = Timer(Duration(milliseconds: _config.duration * 1000 - _stopwatch.elapsedMilliseconds), () { + _stopwatch.stop(); + _stopwatch.reset(); + _nextPage(); + }); + + _stopwatch.start(); + } + + void _pageChanged(int page) { + final asset = widget.timeline.getAssetSafe(page)!; + + setState(() { + _index = page; + + if (!asset.isImage) { + _paused = false; + } + }); + + _timer.cancel(); + _stopwatch.stop(); + _stopwatch.reset(); + + if (!_paused && asset.isImage) { + _createTimer(); + } + + _updateNextIndex(); + } + + void _onTapUp() async { + await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge); + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _showAppBar = !_showAppBar; + }); + }); + } + + Widget _getProgressBar(BuildContext context) { + final asset = widget.timeline.getAssetSafe(_index); + + if (asset == null) { + return Container(); + } + + if (asset.isImage) { + final elapsed = _stopwatch.elapsedMilliseconds; + final duration = _config.duration * 1000; + + return TweenAnimationBuilder( + key: Key(_index.toString()), + tween: Tween(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0), + duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), + builder: (context, value, _) => LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: value, + ), + ); + } else { + return LinearProgressIndicator( + color: context.colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.zero), + minHeight: 5, + value: + ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.position)).inMilliseconds / + asset.duration.inMilliseconds, + ); + } + } + + Widget _getBlur(BuildContext context, int index) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return Container(); + } + + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: getFullImageProvider(asset, size: Size(context.width, context.height)), + fit: BoxFit.cover, + ), + ), + child: Container(color: Colors.black.withValues(alpha: 0.2)), + ), + ); + } + + Widget _getPhotoView(BuildContext context, int index) { + final asset = widget.timeline.getAssetSafe(index); + + if (asset == null) { + return const Center(child: ImmichLoadingIndicator()); + } + + final scale = _config.look == SlideshowLook.cover + ? PhotoViewComputedScale.covered + : PhotoViewComputedScale.contained; + final isCurrent = _index == index; + final imageProvider = getFullImageProvider(asset, size: context.sizeData); + + if (asset.isImage) { + final zoomOut = index % 2 == 1; + final elapsed = _stopwatch.elapsedMilliseconds; + final duration = _config.duration * 1000; + final progress = zoomOut ? 1.0 - elapsed / duration.toDouble() : elapsed / duration.toDouble(); + + return TweenAnimationBuilder( + tween: Tween( + begin: progress, + end: _paused + ? progress + : zoomOut + ? 0.0 + : 1.0, + ), + duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), + builder: (context, value, _) => PhotoView( + imageProvider: imageProvider, + index: index, + disableScaleGestures: true, + gaplessPlayback: true, + filterQuality: FilterQuality.high, + initialScale: scale * (1.0 + value / 10.0), + controller: PhotoViewController(), + onTapUp: (_, _, _) => _onTapUp(), + ), + ); + } else { + final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); + final position = ref.read(videoPlayerProvider(asset.heroTag)).position; + + if (status == VideoPlaybackStatus.completed && isCurrent && position.inMicroseconds > 0) { + _nextPage(); + } else if (status == VideoPlaybackStatus.playing) { + ref.read(videoPlayerProvider(asset.heroTag).notifier).setLoop(false); + } + + return PhotoView.customChild( + onTapUp: (_, _, _) => _onTapUp(), + disableScaleGestures: true, + filterQuality: FilterQuality.high, + initialScale: scale, + child: NativeVideoViewer( + asset: asset, + isCurrent: isCurrent, + image: Image(image: imageProvider, fit: BoxFit.contain, alignment: Alignment.center), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PreferredSize( + preferredSize: Size(AppBar().preferredSize.width, AppBar().preferredSize.height + 5), + child: IgnorePointer( + ignoring: !_showAppBar, + child: AnimatedOpacity( + opacity: _showAppBar ? 1.0 : 0.0, + duration: Durations.short2, + child: Column( + children: [ + AppBar( + backgroundColor: context.scaffoldBackgroundColor, + title: Text("slideshow".t(context: context)), + actions: [ + IconButton( + onPressed: _paused ? _play : _pause, + icon: Icon(_paused ? Icons.play_arrow : Icons.pause), + ), + IconButton( + onPressed: () { + _pause(); + context.pushRoute(SettingsSubRoute(section: SettingSection.assetViewer)); + }, + icon: const Icon(Icons.settings), + ), + ], + ), + _getProgressBar(context), + ], + ), + ), + ), + ), + extendBody: true, + extendBodyBehindAppBar: true, + backgroundColor: Colors.black, + body: PhotoViewGestureDetectorScope( + axis: Axis.horizontal, + child: PageView.builder( + controller: _pageController, + physics: const FastClampingScrollPhysics(), + itemCount: widget.timeline.totalAssets, + onPageChanged: _pageChanged, + itemBuilder: (context, index) => Stack( + children: [ + if (_config.look == SlideshowLook.blurredBackground) _getBlur(context, index), + _getPhotoView(context, index), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 6599ff0ffd..5ed61c3bbe 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -50,10 +50,13 @@ class BaseActionButton extends ConsumerWidget { final iconColor = this.iconColor; return MenuItemButton( - style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)), - leadingIcon: Icon(iconData, color: iconColor), + style: MenuItemButton.styleFrom( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + leadingIcon: Icon(iconData, color: iconColor, size: 20), onPressed: onPressed, - child: Text(label, style: TextStyle(fontSize: 16, color: iconColor)), + child: Text(label, style: TextStyle(fontSize: 15, color: iconColor)), ); } diff --git a/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart new file mode 100644 index 0000000000..479cf2dfe9 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/slideshow_action_button.widget.dart @@ -0,0 +1,34 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class SlideshowActionButton extends ConsumerWidget { + final bool iconOnly; + final bool menuItem; + + const SlideshowActionButton({super.key, this.iconOnly = false, this.menuItem = false}); + + void _onTap(BuildContext context, WidgetRef ref) { + if (!context.mounted) { + return; + } + + context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.slideshow, + label: "slideshow".t(context: context), + iconOnly: iconOnly, + menuItem: menuItem, + onPressed: () => _onTap(context, ref), + maxWidth: 100, + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 1cc5faa733..b39a568e26 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -60,6 +60,7 @@ import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_slideshow.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; @@ -189,6 +190,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: CleanupPreviewRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftSlideshowRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 72054cf194..a4b538d789 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1095,6 +1095,53 @@ class DriftSearchRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftSlideshowPage] +class DriftSlideshowRoute extends PageRouteInfo { + DriftSlideshowRoute({ + Key? key, + required TimelineService timeline, + List? children, + }) : super( + DriftSlideshowRoute.name, + args: DriftSlideshowRouteArgs(key: key, timeline: timeline), + initialChildren: children, + ); + + static const String name = 'DriftSlideshowRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftSlideshowPage(key: args.key, timeline: args.timeline); + }, + ); +} + +class DriftSlideshowRouteArgs { + const DriftSlideshowRouteArgs({this.key, required this.timeline}); + + final Key? key; + + final TimelineService timeline; + + @override + String toString() { + return 'DriftSlideshowRouteArgs{key: $key, timeline: $timeline}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! DriftSlideshowRouteArgs) return false; + return key == other.key && timeline == other.timeline; + } + + @override + int get hashCode => key.hashCode ^ timeline.hashCode; +} + /// generated route for /// [DriftTrashPage] class DriftTrashRoute extends PageRouteInfo { diff --git a/mobile/lib/utils/action_button.utils.dart b/mobile/lib/utils/action_button.utils.dart index a048e245cb..b9cff613fd 100644 --- a/mobile/lib/utils/action_button.utils.dart +++ b/mobile/lib/utils/action_button.utils.dart @@ -27,6 +27,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/set_profile_pi import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/similar_photos_action_button.widget.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/slideshow_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart'; @@ -73,6 +74,7 @@ enum ActionButtonType { similarPhotos, setProfilePicture, viewInTimeline, + slideshow, download, upload, openInBrowser, @@ -179,6 +181,7 @@ enum ActionButtonType { context.timelineOrigin != TimelineOrigin.localAlbum && context.isOwner, ActionButtonType.cast => context.isCasting || context.asset.hasRemote, + ActionButtonType.slideshow => true, }; } @@ -200,6 +203,7 @@ enum ActionButtonType { iconOnly: iconOnly, menuItem: menuItem, ), + ActionButtonType.slideshow => SlideshowActionButton(iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem), ActionButtonType.unarchive => UnArchiveActionButton( source: context.source, diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 50746f5cbd..2fc136302d 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; 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:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { @@ -89,6 +90,10 @@ class _MesmerizingSliverAppBarState extends ConsumerState context.maybePop(), ), actions: [ + IconButton( + onPressed: () => context.pushRoute(DriftSlideshowRoute(timeline: ref.read(timelineServiceProvider))), + icon: Icon(Icons.slideshow_outlined, color: actionIconColor, shadows: actionIconShadows), + ), if (currentAlbum.isActivityEnabled && currentAlbum.isShared) IconButton( icon: Icon(Icons.chat_outlined, color: actionIconColor, shadows: actionIconShadows), diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart index a2bca2745f..f3b9039b2b 100644 --- a/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart +++ b/mobile/lib/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_quality_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/image_viewer_tap_to_navigate_setting.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/video_viewer_settings.dart'; +import 'package:immich_mobile/widgets/settings/asset_viewer_settings/slideshow_settings.dart'; import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart'; class AssetViewerSettings extends StatelessWidget { @@ -13,6 +14,7 @@ class AssetViewerSettings extends StatelessWidget { const ImageViewerQualitySetting(), const ImageViewerTapToNavigateSetting(), const VideoViewerSettings(), + const SlideshowSettings(), ]; return SettingsSubPageScaffold(settings: assetViewerSetting, showDivider: true); diff --git a/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart new file mode 100644 index 0000000000..4e566e6065 --- /dev/null +++ b/mobile/lib/widgets/settings/asset_viewer_settings/slideshow_settings.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart'; +import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_sub_title.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +class SlideshowSettings extends HookConsumerWidget { + const SlideshowSettings({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final slideshow = ref.read(appConfigProvider).slideshow; + final useTransition = useState(slideshow.transition); + final useRepeat = useState(slideshow.repeat); + final useDuration = useState(slideshow.duration); + final useLook = useState(slideshow.look); + final useDirection = useState(slideshow.direction); + + useValueChanged(useTransition.value, (_, __) { + ref.read(metadataProvider).write(.slideshowTransition, useTransition.value); + }); + useValueChanged(useRepeat.value, (_, __) { + ref.read(metadataProvider).write(.slideshowRepeat, useRepeat.value); + }); + useValueChanged(useDuration.value, (_, __) { + ref.read(metadataProvider).write(.slideshowDuration, useDuration.value); + }); + useValueChanged(useLook.value, (_, __) { + ref.read(metadataProvider).write(.slideshowLook, useLook.value); + }); + useValueChanged(useDirection.value, (_, __) { + ref.read(metadataProvider).write(.slideshowDirection, useDirection.value); + }); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingGroupTitle( + title: 'slideshow'.t(context: context), + icon: Icons.slideshow_outlined, + ), + SettingsSwitchListTile( + valueNotifier: useTransition, + title: "show_slideshow_transition".t(context: context), + enabled: useDirection.value != SlideshowDirection.shuffle, + ), + SettingsSwitchListTile( + valueNotifier: useRepeat, + title: "slideshow_repeat".t(context: context), + subtitle: "slideshow_repeat_description".t(context: context), + ), + SettingsSliderListTile( + valueNotifier: useDuration, + text: "duration".t(context: context), + minValue: 5, + noDivisons: 5, + maxValue: 30, + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: SettingsSubTitle(title: 'look'.t(context: context)), + ), + SettingsRadioListTile( + groups: [ + SettingsRadioGroup( + title: 'contain'.t(context: context), + value: SlideshowLook.contain, + ), + SettingsRadioGroup( + title: 'cover'.t(context: context), + value: SlideshowLook.cover, + ), + SettingsRadioGroup( + title: 'blurred_background'.t(context: context), + value: SlideshowLook.blurredBackground, + ), + ], + groupBy: useLook.value, + onRadioChanged: (value) { + if (value != null) { + useLook.value = value; + } + }, + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: SettingsSubTitle(title: 'direction'.t(context: context)), + ), + Padding( + padding: const EdgeInsets.only(bottom: 32), + child: SettingsRadioListTile( + groups: [ + SettingsRadioGroup( + title: 'forward'.t(context: context), + value: SlideshowDirection.forward, + ), + SettingsRadioGroup( + title: 'backward'.t(context: context), + value: SlideshowDirection.backward, + ), + SettingsRadioGroup( + title: 'shuffle'.t(context: context), + value: SlideshowDirection.shuffle, + ), + ], + groupBy: useDirection.value, + onRadioChanged: (value) { + if (value != null) { + useDirection.value = value; + } + }, + ), + ), + ], + ); + } +} From 3ab3d5cf43b983c32b64a1245d653973fc345900 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Sat, 16 May 2026 05:12:28 +0600 Subject: [PATCH 11/46] fix(mobile): don't force-unwrap nil localizedTitle in ios getAlbums (#28452) crashes on ios 26 when a PHAssetCollection returns nil for localizedTitle. fall back to localIdentifier. ref #28428 --- mobile/ios/Runner/Sync/MessagesImpl.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index ec96729d8f..40b71bd6c2 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -110,7 +110,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin { var domainAlbum = PlatformAlbum( id: album.localIdentifier, - name: album.localizedTitle!, + name: album.localizedTitle ?? album.localIdentifier, updatedAt: nil, isCloud: isCloud, assetCount: Int64(assets.count) From 02581e81a7f01cfda6ed0a7b6f0f9ac64286eb43 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 15 May 2026 22:15:24 -0400 Subject: [PATCH 12/46] fix(web): work around Chrome HDR image seam lines during zoom (#27715) Change-Id: Ic5a5b1a476c2af93b465ef23dabc601a6a6a6964 Co-authored-by: Alex --- web/src/lib/components/AdaptiveImage.svelte | 91 ++++++++++++++++++--- web/src/lib/utils/tunables.ts | 3 + 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/web/src/lib/components/AdaptiveImage.svelte b/web/src/lib/components/AdaptiveImage.svelte index 39bc4516b7..4f3522887c 100644 --- a/web/src/lib/components/AdaptiveImage.svelte +++ b/web/src/lib/components/AdaptiveImage.svelte @@ -1,3 +1,54 @@ + + {#if action.$if?.() ?? true} +{/snippet} + +
+
+
+ + {#if array} +
+ {#each albumIds as albumId, i (i)} + { + albumIds.splice(i, 1); + albumIds = [...albumIds]; + }} + /> + {/each} + {@render button()} +
+ {:else} + {@const albumId = albumIds[0]} + {#if albumId} + (albumIds = [])} /> + {:else} + {@render button()} + {/if} + {/if} +
diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte new file mode 100644 index 0000000000..e48f46a402 --- /dev/null +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -0,0 +1,99 @@ + + + +{#if Object.keys(schema).length === 0} + + +{:else if schema.type === 'object'} + {#if !root} +
+ + {#if description} + {description} + {/if} +
+ {/if} +
+ {#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)} + + {/each} +
+{:else if schema.uiHint === 'albumId'} + +{:else if schema.enum && schema.array} + + + +{:else if schema.enum} + + getValue(), setValue} /> + +{:else} + Unknown schema + +{/if} diff --git a/web/src/lib/components/album-page/AlbumCard.svelte b/web/src/lib/components/album-page/AlbumCard.svelte index cdb7e9e27c..e0151deda6 100644 --- a/web/src/lib/components/album-page/AlbumCard.svelte +++ b/web/src/lib/components/album-page/AlbumCard.svelte @@ -23,7 +23,7 @@ showDateRange = false, showItemCount = false, preload = false, - onShowContextMenu = undefined, + onShowContextMenu, }: Props = $props(); const showAlbumContextMenu = (e: MouseEvent) => { diff --git a/web/src/lib/components/album-page/AlbumThumbnail.svelte b/web/src/lib/components/album-page/AlbumThumbnail.svelte new file mode 100644 index 0000000000..037bb78ab9 --- /dev/null +++ b/web/src/lib/components/album-page/AlbumThumbnail.svelte @@ -0,0 +1,50 @@ + + +
+ {#await getAlbumInfo({ ...authManager.params, id: albumId })} + + {:then album} +
+ +

+ {album.albumName} +

+ {#if album.description} +

+ {album.description} +

+ {/if} +
+ +
+
+ {/await} +
diff --git a/web/src/lib/managers/plugin-manager.svelte.ts b/web/src/lib/managers/plugin-manager.svelte.ts new file mode 100644 index 0000000000..57ca8fded5 --- /dev/null +++ b/web/src/lib/managers/plugin-manager.svelte.ts @@ -0,0 +1,84 @@ +import { + getWorkflowTriggers, + searchPluginMethods, + WorkflowTrigger, + type PluginMethodResponseDto, + type WorkflowTriggerResponseDto, +} from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { SvelteMap } from 'svelte/reactivity'; +import { get } from 'svelte/store'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; + +class PluginManager { + #loading: Promise | undefined; + #methodMap = new SvelteMap(); + #methods = $state([]); + #triggers = $state([]); + + constructor() { + eventManager.on({ + AuthLogout: () => this.clearCache(), + AuthUserLoaded: () => this.initialize(), + }); + + // loaded event might have already happened + if (authManager.authenticated) { + void this.initialize(); + } + } + + get triggers() { + return this.#triggers; + } + + ready() { + return this.initialize(); + } + + getMethod(key: string) { + return this.#methodMap.get(key); + } + + getMethodLabel(key: string) { + const method = this.getMethod(key); + return method?.title ?? get(t)('unknown'); + } + + getTrigger(trigger: WorkflowTrigger) { + const result = this.#triggers.find((t) => t.trigger === trigger); + + if (!result) { + throw new Error(`Unknown trigger type: ${trigger}`); + } + + return result; + } + + private clearCache() { + this.#loading = undefined; + this.#methodMap = new SvelteMap(); + } + + private initialize() { + if (!this.#loading) { + this.#loading = this.load(); + } + + return this.#loading; + } + + private async load() { + const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]); + + this.#methods = methods; + for (const method of this.#methods) { + this.#methodMap.set(method.key, method); + } + + this.#triggers = triggers; + } +} + +export const pluginManager = new PluginManager(); diff --git a/web/src/lib/modals/AddWorkflowStepModal.svelte b/web/src/lib/modals/AddWorkflowStepModal.svelte deleted file mode 100644 index 980a7fcaae..0000000000 --- a/web/src/lib/modals/AddWorkflowStepModal.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -{#snippet stepButton(title: string, description?: string, onclick?: () => void)} - -{/snippet} - - onClose()}> - -
- - {#if filters.length > 0 && (!type || type === 'filter')} -
- {#each filters as filter (filter.id)} - {@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))} - {/each} -
- {/if} - - - {#if actions.length > 0 && (!type || type === 'action')} -
- {#each actions as action (action.id)} - {@render stepButton(action.title, action.description, () => handleSelect('action', action))} - {/each} -
- {/if} -
-
-
diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 5d0d5692da..43ed575eb2 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -21,9 +21,9 @@ let search = $state(''); let selectedRowIndex: number = $state(-1); - interface Props { + type Props = { onClose: (albums?: AlbumResponseDto[]) => void; - } + }; let { onClose }: Props = $props(); diff --git a/web/src/lib/modals/PluginMethodPicker.svelte b/web/src/lib/modals/PluginMethodPicker.svelte new file mode 100644 index 0000000000..7000fed2cc --- /dev/null +++ b/web/src/lib/modals/PluginMethodPicker.svelte @@ -0,0 +1,41 @@ + + + + {#await searchPluginMethods({ trigger })} +
+ +
+ {:then methods} + + {#each methods as method (method.key)} + onClose(method)}> +
+ {method.title} + {#if method.uiHints.includes('filter')} + {$t('plugin_method_filter_type')} + {/if} + + {#if method.description} + {method.description} + {/if} +
+
+ {/each} +
+ {/await} +
diff --git a/web/src/lib/modals/WorkflowAddStepModal.svelte b/web/src/lib/modals/WorkflowAddStepModal.svelte new file mode 100644 index 0000000000..889e175de0 --- /dev/null +++ b/web/src/lib/modals/WorkflowAddStepModal.svelte @@ -0,0 +1,75 @@ + + +{#if method} + +
+
+ {method.title} + {#if method.description} + {method.description} + {/if} +
+ +
+ + {#if method.schema} +
+
+ {$t('configuration')} + + + +
+ {/if} + + {#if debug} +
+ {$t('preview')} +