From 9cc88ed2a6df8dbd3cda2f6d38c2a03524761023 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Sat, 8 Nov 2025 23:46:43 +0100 Subject: [PATCH] feat: make memories slideshow duration configurable (#22783) --- i18n/en.json | 1 + .../openapi/lib/model/memories_response.dart | 10 +++++++++- mobile/openapi/lib/model/memories_update.dart | 20 ++++++++++++++++++- open-api/immich-openapi-specs.json | 9 +++++++++ open-api/typescript-sdk/src/fetch-client.ts | 2 ++ server/src/dtos/user-preferences.dto.ts | 9 +++++++++ server/src/types.ts | 1 + server/src/utils/preferences.ts | 1 + .../memory-page/memory-viewer.svelte | 2 +- .../feature-settings.svelte | 13 +++++++++++- .../factories/preferences-factory.ts | 1 + 11 files changed, 65 insertions(+), 4 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 30c8949aef..735e942dda 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2027,6 +2027,7 @@ "third_party_resources": "Third-Party Resources", "time": "Time", "time_based_memories": "Time-based memories", + "time_based_memories_duration": "Number of seconds to display each image.", "timeline": "Timeline", "timezone": "Timezone", "to_archive": "Archive", diff --git a/mobile/openapi/lib/model/memories_response.dart b/mobile/openapi/lib/model/memories_response.dart index b9f8b5d8b1..cb42f596a6 100644 --- a/mobile/openapi/lib/model/memories_response.dart +++ b/mobile/openapi/lib/model/memories_response.dart @@ -13,25 +13,31 @@ part of openapi.api; class MemoriesResponse { /// Returns a new [MemoriesResponse] instance. MemoriesResponse({ + this.duration = 5, this.enabled = true, }); + int duration; + bool enabled; @override bool operator ==(Object other) => identical(this, other) || other is MemoriesResponse && + other.duration == duration && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (duration.hashCode) + (enabled.hashCode); @override - String toString() => 'MemoriesResponse[enabled=$enabled]'; + String toString() => 'MemoriesResponse[duration=$duration, enabled=$enabled]'; Map toJson() { final json = {}; + json[r'duration'] = this.duration; json[r'enabled'] = this.enabled; return json; } @@ -45,6 +51,7 @@ class MemoriesResponse { final json = value.cast(); return MemoriesResponse( + duration: mapValueOfType(json, r'duration')!, enabled: mapValueOfType(json, r'enabled')!, ); } @@ -93,6 +100,7 @@ class MemoriesResponse { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'duration', 'enabled', }; } diff --git a/mobile/openapi/lib/model/memories_update.dart b/mobile/openapi/lib/model/memories_update.dart index 71efd71ae7..39c46ffd2f 100644 --- a/mobile/openapi/lib/model/memories_update.dart +++ b/mobile/openapi/lib/model/memories_update.dart @@ -13,9 +13,19 @@ part of openapi.api; class MemoriesUpdate { /// Returns a new [MemoriesUpdate] instance. MemoriesUpdate({ + this.duration, this.enabled, }); + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? duration; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -26,18 +36,25 @@ class MemoriesUpdate { @override bool operator ==(Object other) => identical(this, other) || other is MemoriesUpdate && + other.duration == duration && other.enabled == enabled; @override int get hashCode => // ignore: unnecessary_parenthesis + (duration == null ? 0 : duration!.hashCode) + (enabled == null ? 0 : enabled!.hashCode); @override - String toString() => 'MemoriesUpdate[enabled=$enabled]'; + String toString() => 'MemoriesUpdate[duration=$duration, enabled=$enabled]'; Map toJson() { final json = {}; + if (this.duration != null) { + json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } if (this.enabled != null) { json[r'enabled'] = this.enabled; } else { @@ -55,6 +72,7 @@ class MemoriesUpdate { final json = value.cast(); return MemoriesUpdate( + duration: mapValueOfType(json, r'duration'), enabled: mapValueOfType(json, r'enabled'), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d16e4c4e10..6076b43bfd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -12646,18 +12646,27 @@ }, "MemoriesResponse": { "properties": { + "duration": { + "default": 5, + "type": "integer" + }, "enabled": { "default": true, "type": "boolean" } }, "required": [ + "duration", "enabled" ], "type": "object" }, "MemoriesUpdate": { "properties": { + "duration": { + "minimum": 1, + "type": "integer" + }, "enabled": { "type": "boolean" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 435e10046a..1ed65e4f25 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -152,6 +152,7 @@ export type FoldersResponse = { sidebarWeb: boolean; }; export type MemoriesResponse = { + duration: number; enabled: boolean; }; export type PeopleResponse = { @@ -209,6 +210,7 @@ export type FoldersUpdate = { sidebarWeb?: boolean; }; export type MemoriesUpdate = { + duration?: number; enabled?: boolean; }; export type PeopleUpdate = { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index b258158ae2..452384b423 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -13,6 +13,12 @@ class AvatarUpdate { class MemoriesUpdate { @ValidateBoolean({ optional: true }) enabled?: boolean; + + @Optional() + @IsInt() + @IsPositive() + @ApiProperty({ type: 'integer' }) + duration?: number; } class RatingsUpdate { @@ -166,6 +172,9 @@ class RatingsResponse { class MemoriesResponse { enabled: boolean = true; + + @ApiProperty({ type: 'integer' }) + duration: number = 5; } class FoldersResponse { diff --git a/server/src/types.ts b/server/src/types.ts index 66045521d0..0a2dd46d7f 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -497,6 +497,7 @@ export interface UserPreferences { }; memories: { enabled: boolean; + duration: number; }; people: { enabled: boolean; diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 121bf2826d..b25369670a 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -16,6 +16,7 @@ const getDefaultPreferences = (): UserPreferences => { }, memories: { enabled: true, + duration: 5, }, people: { enabled: true, diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index fe3569f916..cfe11e1026 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -102,7 +102,7 @@ }); } else { progressBarController = new Tween(0, { - duration: (from: number, to: number) => (to ? 5000 * (to - from) : 0), + duration: (from: number, to: number) => (to ? $preferences.memories.duration * 1000 * (to - from) : 0), }); } }; diff --git a/web/src/lib/components/user-settings-page/feature-settings.svelte b/web/src/lib/components/user-settings-page/feature-settings.svelte index 31a29be975..82a40161b5 100644 --- a/web/src/lib/components/user-settings-page/feature-settings.svelte +++ b/web/src/lib/components/user-settings-page/feature-settings.svelte @@ -1,7 +1,9 @@