mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 22:05:19 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19fbc11c33 | |||
| 932302c5ab | |||
| 1bb7517da0 | |||
| 814c2e32e4 |
@@ -1,30 +1,30 @@
|
||||
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
|
||||
|
||||
[[tools."aqua:flutter/flutter"]]
|
||||
version = "3.44.0"
|
||||
version = "3.44.1"
|
||||
backend = "aqua:flutter/flutter"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.0-stable.tar.xz"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
|
||||
checksum = "blake3:fb03aa5d9790205c948922ec3f0751c16e4575b09d6ae9dd4fbeb664a69f0e00"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.0-stable.zip"
|
||||
checksum = "blake3:15069c982a30ca0189a83edb5627b69d91485ad94fb74d2de8585b43364e9e8e"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.0-stable.zip"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
|
||||
|
||||
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.0-stable.zip"
|
||||
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
|
||||
|
||||
[[tools.flutter]]
|
||||
version = "3.41.9-stable"
|
||||
|
||||
@@ -16,7 +16,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
"aqua:flutter/flutter" = "3.44.0"
|
||||
"aqua:flutter/flutter" = "3.44.1"
|
||||
pnpm = "10.33.4"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
|
||||
enum SettingsKey<T extends Object> {
|
||||
// Theme
|
||||
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
|
||||
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
themeDynamic<bool>(),
|
||||
themeColorfulInterface<bool>(),
|
||||
|
||||
@@ -28,11 +27,11 @@ enum SettingsKey<T extends Object> {
|
||||
networkAutoEndpointSwitching<bool>(),
|
||||
networkPreferredWifiName<String>(),
|
||||
networkLocalEndpoint<String>(),
|
||||
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
|
||||
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
|
||||
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
|
||||
|
||||
// Album
|
||||
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
|
||||
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
|
||||
albumIsReverse<bool>(),
|
||||
albumIsGrid<bool>(),
|
||||
|
||||
@@ -46,23 +45,23 @@ enum SettingsKey<T extends Object> {
|
||||
|
||||
// Timeline
|
||||
timelineTilesPerRow<int>(),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
|
||||
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
|
||||
timelineStorageIndicator<bool>(),
|
||||
|
||||
// Log
|
||||
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
|
||||
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
|
||||
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
|
||||
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
// Cleanup
|
||||
cleanupKeepFavorites<bool>(),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
|
||||
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
|
||||
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
|
||||
cleanupCutoffDaysAgo<int>(),
|
||||
cleanupDefaultsInitialized<bool>(),
|
||||
|
||||
@@ -70,148 +69,16 @@ enum SettingsKey<T extends Object> {
|
||||
slideshowTransition<bool>(),
|
||||
slideshowRepeat<bool>(),
|
||||
slideshowDuration<int>(),
|
||||
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
|
||||
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
|
||||
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values));
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
final ValueCodec<T>? _codecOverride;
|
||||
|
||||
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
|
||||
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
|
||||
|
||||
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
|
||||
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
|
||||
|
||||
String encode(T value) => _codec.encode(value);
|
||||
|
||||
T decode(String raw) => _codec.decode(raw);
|
||||
}
|
||||
|
||||
sealed class _SettingsCodec<T extends Object> {
|
||||
const _SettingsCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
|
||||
static const Map<Type, _SettingsCodec<Object>> _primitives = {
|
||||
int: _PrimitiveCodec.integer,
|
||||
double: _PrimitiveCodec.real,
|
||||
bool: _PrimitiveCodec.boolean,
|
||||
String: _PrimitiveCodec.string,
|
||||
DateTime: _DateTimeCodec(),
|
||||
};
|
||||
|
||||
static _SettingsCodec<T> forType<T extends Object>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
|
||||
}
|
||||
return codec as _SettingsCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const _EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
const _DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
const _MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return {};
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
|
||||
final _SettingsCodec<T> _elementCodec;
|
||||
|
||||
const _ListCodec(this._elementCodec);
|
||||
|
||||
@override
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
|
||||
const _PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = _PrimitiveCodec<int>._(int.parse);
|
||||
static const real = _PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = _PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:convert';
|
||||
|
||||
sealed class ValueCodec<T extends Object> {
|
||||
const ValueCodec();
|
||||
|
||||
String encode(T value);
|
||||
T decode(String raw);
|
||||
|
||||
static const Map<Type, ValueCodec<Object>> _primitives = {
|
||||
int: PrimitiveCodec.integer,
|
||||
double: PrimitiveCodec.real,
|
||||
bool: PrimitiveCodec.boolean,
|
||||
String: PrimitiveCodec.string,
|
||||
DateTime: DateTimeCodec(),
|
||||
};
|
||||
|
||||
static ValueCodec<T> forType<T extends Object>(Type runtimeType) {
|
||||
final codec = _primitives[runtimeType];
|
||||
if (codec == null) {
|
||||
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
|
||||
}
|
||||
return codec as ValueCodec<T>;
|
||||
}
|
||||
}
|
||||
|
||||
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
|
||||
final List<T> values;
|
||||
|
||||
const EnumCodec(this.values);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.name;
|
||||
|
||||
@override
|
||||
T decode(String raw) => values.firstWhere((v) => v.name == raw);
|
||||
}
|
||||
|
||||
final class DateTimeCodec extends ValueCodec<DateTime> {
|
||||
const DateTimeCodec();
|
||||
|
||||
@override
|
||||
String encode(DateTime value) => value.toIso8601String();
|
||||
|
||||
@override
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
|
||||
final ValueCodec<K> _keyCodec;
|
||||
final ValueCodec<V> _valueCodec;
|
||||
|
||||
const MapCodec(this._keyCodec, this._valueCodec);
|
||||
|
||||
@override
|
||||
String encode(Map<K, V> value) {
|
||||
final entries = <String, String>{};
|
||||
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<K, V> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) {
|
||||
return {};
|
||||
}
|
||||
final result = <K, V>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final rawKey = entry.key;
|
||||
final rawValue = entry.value;
|
||||
if (rawKey is! String || rawValue is! String) {
|
||||
return {};
|
||||
}
|
||||
final k = _keyCodec.decode(rawKey);
|
||||
final v = _valueCodec.decode(rawValue);
|
||||
result[k] = v;
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
|
||||
final ValueCodec<T> _elementCodec;
|
||||
|
||||
const ListCodec(this._elementCodec);
|
||||
|
||||
@override
|
||||
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
|
||||
|
||||
@override
|
||||
List<T> decode(String raw) {
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) {
|
||||
return [];
|
||||
}
|
||||
final result = <T>[];
|
||||
for (final item in decoded) {
|
||||
if (item is! String) {
|
||||
return [];
|
||||
}
|
||||
final element = _elementCodec.decode(item);
|
||||
result.add(element);
|
||||
}
|
||||
return result;
|
||||
} on FormatException {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
|
||||
final T Function(String) _parse;
|
||||
|
||||
const PrimitiveCodec._(this._parse);
|
||||
|
||||
@override
|
||||
String encode(T value) => value.toString();
|
||||
|
||||
@override
|
||||
T decode(String raw) => _parse(raw);
|
||||
|
||||
static const integer = PrimitiveCodec<int>._(int.parse);
|
||||
static const real = PrimitiveCodec<double>._(double.parse);
|
||||
static const boolean = PrimitiveCodec<bool>._(bool.parse);
|
||||
static const string = PrimitiveCodec<String>._(_identity);
|
||||
|
||||
static String _identity(String s) => s;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
abstract class CachedKeyValueRepository<K extends Enum, S> {
|
||||
CachedKeyValueRepository(this._snapshot);
|
||||
|
||||
S _snapshot;
|
||||
S get snapshot => _snapshot;
|
||||
@protected
|
||||
set snapshot(S value) => _snapshot = value;
|
||||
|
||||
List<K> get keys;
|
||||
|
||||
Object decodeValue(K key, String raw);
|
||||
|
||||
S buildSnapshot(Map<K, Object> overrides);
|
||||
|
||||
Selectable<({String key, String value})> selectable();
|
||||
|
||||
Future<void> refresh() async => _snapshot = _build(await selectable().get());
|
||||
|
||||
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
|
||||
|
||||
S _build(List<({String key, String value})> rows) {
|
||||
final overrides = <K, Object>{};
|
||||
for (final row in rows) {
|
||||
final key = keys.firstWhereOrNull((k) => k.name == row.key);
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
overrides[key] = decodeValue(key, row.value);
|
||||
}
|
||||
return buildSnapshot(overrides);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:immich_mobile/domain/models/config/app_config.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
|
||||
class SettingsRepository extends DriftDatabaseRepository {
|
||||
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
|
||||
final Drift _db;
|
||||
|
||||
SettingsRepository._(this._db) : super(_db);
|
||||
SettingsRepository._(this._db) : super(const .new());
|
||||
|
||||
static SettingsRepository? _instance;
|
||||
|
||||
@@ -20,9 +20,6 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return instance;
|
||||
}
|
||||
|
||||
AppConfig _appConfig = const .new();
|
||||
AppConfig get appConfig => _appConfig;
|
||||
|
||||
static Future<SettingsRepository> ensureInitialized(Drift db) async {
|
||||
if (_instance == null) {
|
||||
final instance = SettingsRepository._(db);
|
||||
@@ -32,7 +29,20 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
|
||||
@override
|
||||
List<SettingsKey> get keys => SettingsKey.values;
|
||||
|
||||
@override
|
||||
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
|
||||
|
||||
@override
|
||||
AppConfig buildSnapshot(Map<SettingsKey, Object> overrides) => AppConfig.fromEntries(overrides);
|
||||
|
||||
@override
|
||||
Selectable<({String key, String value})> selectable() =>
|
||||
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
|
||||
|
||||
AppConfig get appConfig => snapshot;
|
||||
|
||||
Future<void> clear(Iterable<SettingsKey> keys) async {
|
||||
if (keys.isEmpty) {
|
||||
@@ -42,13 +52,15 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
final names = keys.map((key) => key.name).toList();
|
||||
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
|
||||
|
||||
var config = snapshot;
|
||||
for (final key in keys) {
|
||||
_appConfig = _appConfig.write(key, defaultConfig.read(key));
|
||||
config = config.write(key, defaultConfig.read(key));
|
||||
}
|
||||
snapshot = config;
|
||||
}
|
||||
|
||||
Future<void> write<T extends Object, U extends T>(SettingsKey<T> key, U value) async {
|
||||
if (value == _appConfig.read(key)) {
|
||||
if (value == snapshot.read(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,24 +73,8 @@ class SettingsRepository extends DriftDatabaseRepository {
|
||||
.insertOnConflictUpdate(
|
||||
SettingsEntityCompanion.insert(key: key.name, value: key.encode(value), updatedAt: Value(DateTime.now())),
|
||||
);
|
||||
_appConfig = _appConfig.write(key, value);
|
||||
snapshot = snapshot.write(key, value);
|
||||
}
|
||||
|
||||
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
|
||||
_applyOverrides(rows);
|
||||
return _appConfig;
|
||||
});
|
||||
|
||||
void _applyOverrides(List<SettingsEntityData> rows) {
|
||||
_appConfig = AppConfig.fromEntries(
|
||||
rows.fold({}, (overrides, row) {
|
||||
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
|
||||
if (metadataKey == null) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
return {...overrides, metadataKey: metadataKey.decode(row.value)};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Stream<AppConfig> watchConfig() => watchSnapshot();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||
if (value is Map) {
|
||||
addDefault(value, 'mapLightStyleUrl', 'https://tiles.immich.cloud/v1/style/light.json');
|
||||
addDefault(value, 'mapDarkStyleUrl', 'https://tiles.immich.cloud/v1/style/dark.json');
|
||||
addDefault(value, 'minFaces', 3);
|
||||
}
|
||||
case 'UserResponseDto':
|
||||
if (value is Map) {
|
||||
@@ -54,6 +55,7 @@ dynamic upgradeDto(dynamic value, String targetType) {
|
||||
case 'ServerFeaturesDto':
|
||||
if (value is Map) {
|
||||
addDefault(value, 'ocr', false);
|
||||
addDefault(value, 'realtimeTranscoding', false);
|
||||
}
|
||||
break;
|
||||
case 'MemoriesResponse':
|
||||
|
||||
+1
-1
@@ -1997,4 +1997,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: "3.44.0"
|
||||
flutter: "3.44.1"
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ version: 3.0.0+3047
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
flutter: 3.44.0
|
||||
flutter: 3.44.1
|
||||
|
||||
dependencies:
|
||||
async: ^2.13.1
|
||||
|
||||
@@ -286,11 +286,7 @@
|
||||
{/snippet}
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef && asset.width && asset.height}
|
||||
<FaceEditor
|
||||
imageSize={{ width: asset.width, height: asset.height }}
|
||||
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -308,31 +308,9 @@
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!assetViewerManager.isFaceEditMode || !videoPlayer) {
|
||||
return;
|
||||
if (assetViewerManager.isFaceEditMode) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
videoPlayer.pause();
|
||||
|
||||
const { videoWidth, videoHeight } = videoPlayer;
|
||||
if (videoWidth === 0 || videoHeight === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = videoWidth;
|
||||
canvas.height = videoHeight;
|
||||
canvas.getContext('2d')?.drawImage(videoPlayer, 0, 0);
|
||||
|
||||
const img = new Image();
|
||||
const onImageLoad = () => (assetViewerManager.imgRef = img);
|
||||
img.addEventListener('load', onImageLoad);
|
||||
img.src = canvas.toDataURL('image/png');
|
||||
|
||||
return () => {
|
||||
img.removeEventListener('load', onImageLoad);
|
||||
img.src = '';
|
||||
assetViewerManager.imgRef = undefined;
|
||||
};
|
||||
});
|
||||
|
||||
// The time is only refreshed on HLS fragment decode by default,
|
||||
@@ -476,12 +454,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode}
|
||||
<FaceEditor
|
||||
imageSize={{ width: asset.width ?? 0, height: asset.height ?? 0 }}
|
||||
containerSize={{ width: containerWidth, height: containerHeight }}
|
||||
{assetId}
|
||||
/>
|
||||
{#if assetViewerManager.isFaceEditMode && videoPlayer}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import FaceCreateTagModal from '$lib/modals/CreateFaceModal.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { computeContentMetrics, mapContentRectToNatural, type Size } from '$lib/utils/container-utils';
|
||||
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input, modalManager, toastManager } from '@immich/ui';
|
||||
@@ -14,12 +14,13 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
imageSize: Size;
|
||||
containerSize: Size;
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
};
|
||||
|
||||
let { imageSize, containerSize, assetId }: Props = $props();
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
@@ -53,7 +54,7 @@
|
||||
};
|
||||
|
||||
const setupCanvas = () => {
|
||||
if (!canvasEl) {
|
||||
if (!canvasEl || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,7 +86,17 @@
|
||||
searchInputEl?.focus();
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived(computeContentMetrics(imageSize, containerSize));
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
const container = { width: containerWidth, height: containerHeight };
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, container);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerWidth - contentWidth) / 2,
|
||||
offsetY: (containerHeight - contentHeight) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
const setDefaultFaceRectanglePosition = (faceRect: Rect) => {
|
||||
const { offsetX, offsetY } = imageContentMetrics;
|
||||
@@ -105,8 +116,8 @@
|
||||
}
|
||||
|
||||
canvas.setDimensions({
|
||||
width: containerSize.width,
|
||||
height: containerSize.height,
|
||||
width: containerWidth,
|
||||
height: containerHeight,
|
||||
});
|
||||
|
||||
if (!faceRect) {
|
||||
@@ -164,11 +175,11 @@
|
||||
};
|
||||
const selectorWidth = faceSelectorEl.offsetWidth;
|
||||
const chromeHeight = faceSelectorEl.offsetHeight - scrollableListEl.offsetHeight;
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerSize.height - gap * 2 - chromeHeight);
|
||||
const listHeight = Math.min(MAX_LIST_HEIGHT, containerHeight - gap * 2 - chromeHeight);
|
||||
const selectorHeight = listHeight + chromeHeight;
|
||||
|
||||
const clampTop = (top: number) => clamp(top, gap, containerSize.height - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerSize.width - selectorWidth - gap);
|
||||
const clampTop = (top: number) => clamp(top, gap, containerHeight - selectorHeight - gap);
|
||||
const clampLeft = (left: number) => clamp(left, gap, containerWidth - selectorWidth - gap);
|
||||
|
||||
const overlapArea = (position: { top: number; left: number }) => {
|
||||
const selectorRight = position.left + selectorWidth;
|
||||
@@ -227,42 +238,61 @@
|
||||
});
|
||||
|
||||
const getFaceCroppedCoordinates = () => {
|
||||
if (!faceRect || imageContentMetrics.contentWidth === 0) {
|
||||
if (!faceRect || !htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageRect = mapContentRectToNatural(faceRect.getBoundingRect(), imageContentMetrics, imageSize);
|
||||
const { left, top, width, height } = faceRect.getBoundingRect();
|
||||
const { offsetX, offsetY, contentWidth, contentHeight } = imageContentMetrics;
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
|
||||
const scaleX = natural.width / contentWidth;
|
||||
const scaleY = natural.height / contentHeight;
|
||||
const imageX = (left - offsetX) * scaleX;
|
||||
const imageY = (top - offsetY) * scaleY;
|
||||
|
||||
return {
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
x: Math.floor(imageRect.left),
|
||||
y: Math.floor(imageRect.top),
|
||||
width: Math.floor(imageRect.width),
|
||||
height: Math.floor(imageRect.height),
|
||||
imageWidth: natural.width,
|
||||
imageHeight: natural.height,
|
||||
x: Math.floor(imageX),
|
||||
y: Math.floor(imageY),
|
||||
width: Math.floor(width * scaleX),
|
||||
height: Math.floor(height * scaleY),
|
||||
};
|
||||
};
|
||||
|
||||
type FaceCoordinates = NonNullable<ReturnType<typeof getFaceCroppedCoordinates>>;
|
||||
|
||||
const getFacePreviewUrl = (data: FaceCoordinates) => {
|
||||
const imgRef = assetViewerManager.imgRef;
|
||||
if (!imgRef || imageContentMetrics.contentWidth === 0) {
|
||||
if (!htmlElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scaleX = imgRef.naturalWidth / imageSize.width;
|
||||
const scaleY = imgRef.naturalHeight / imageSize.height;
|
||||
const x = clamp(Math.floor(data.x * scaleX), 0, imgRef.naturalWidth - 1);
|
||||
const y = clamp(Math.floor(data.y * scaleY), 0, imgRef.naturalHeight - 1);
|
||||
const width = clamp(Math.floor(data.width * scaleX), 1, imgRef.naturalWidth - x);
|
||||
const height = clamp(Math.floor(data.height * scaleY), 1, imgRef.naturalHeight - y);
|
||||
const natural = getNaturalSize(htmlElement);
|
||||
if (natural.width <= 0 || natural.height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const x = clamp(data.x, 0, natural.width - 1);
|
||||
const y = clamp(data.y, 0, natural.height - 1);
|
||||
const width = clamp(data.width, 1, natural.width - x);
|
||||
const height = clamp(data.height, 1, natural.height - y);
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
canvas.getContext('2d')?.drawImage(imgRef, x, y, width, height, 0, 0, width, height);
|
||||
context.drawImage(htmlElement, x, y, width, height, 0, 0, width, height);
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch {
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import {
|
||||
computeContentMetrics,
|
||||
getContentMetrics,
|
||||
getNaturalSize,
|
||||
mapContentRectToNatural,
|
||||
mapNormalizedRectToContent,
|
||||
mapNormalizedToContent,
|
||||
scaleToCover,
|
||||
scaleToFit,
|
||||
} from '$lib/utils/container-utils';
|
||||
|
||||
const mockImage = (props: { naturalWidth: number; naturalHeight: number }): HTMLImageElement =>
|
||||
props as unknown as HTMLImageElement;
|
||||
const mockImage = (props: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}): HTMLImageElement => props as unknown as HTMLImageElement;
|
||||
|
||||
const mockVideo = (props: {
|
||||
videoWidth: number;
|
||||
@@ -46,85 +49,48 @@ describe('scaleToFit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeContentMetrics', () => {
|
||||
it('should return zero metrics for zero-width content', () => {
|
||||
expect(computeContentMetrics({ width: 0, height: 1080 }, { width: 800, height: 600 })).toEqual({
|
||||
contentWidth: 0,
|
||||
contentHeight: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return zero metrics for zero-height content', () => {
|
||||
expect(computeContentMetrics({ width: 1920, height: 0 }, { width: 800, height: 600 })).toEqual({
|
||||
contentWidth: 0,
|
||||
contentHeight: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should center wide content vertically', () => {
|
||||
expect(computeContentMetrics({ width: 2000, height: 1000 }, { width: 800, height: 600 })).toEqual({
|
||||
contentWidth: 800,
|
||||
contentHeight: 400,
|
||||
offsetX: 0,
|
||||
offsetY: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should center tall content horizontally', () => {
|
||||
expect(computeContentMetrics({ width: 1000, height: 2000 }, { width: 800, height: 600 })).toEqual({
|
||||
contentWidth: 300,
|
||||
contentHeight: 600,
|
||||
offsetX: 250,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should produce zero offsets when aspect ratios match', () => {
|
||||
expect(computeContentMetrics({ width: 1600, height: 900 }, { width: 800, height: 450 })).toEqual({
|
||||
describe('getContentMetrics', () => {
|
||||
it('should compute zero offsets when aspect ratios match', () => {
|
||||
const img = mockImage({ naturalWidth: 1600, naturalHeight: 900, width: 800, height: 450 });
|
||||
expect(getContentMetrics(img)).toEqual({
|
||||
contentWidth: 800,
|
||||
contentHeight: 450,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapContentRectToNatural', () => {
|
||||
it('should map a full-content rect back to natural size', () => {
|
||||
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||
const rect = mapContentRectToNatural({ left: 0, top: 100, width: 800, height: 400 }, metrics, {
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
});
|
||||
expect(rect).toEqual({ left: 0, top: 0, width: 2000, height: 1000 });
|
||||
it('should compute horizontal letterbox offsets for tall image', () => {
|
||||
const img = mockImage({ naturalWidth: 1000, naturalHeight: 2000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(300);
|
||||
expect(metrics.contentHeight).toBe(600);
|
||||
expect(metrics.offsetX).toBe(250);
|
||||
expect(metrics.offsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('should map a centered sub-rect to natural coordinates', () => {
|
||||
const metrics = { contentWidth: 800, contentHeight: 400, offsetX: 0, offsetY: 100 };
|
||||
const rect = mapContentRectToNatural({ left: 200, top: 200, width: 400, height: 200 }, metrics, {
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
});
|
||||
expect(rect).toEqual({ left: 500, top: 250, width: 1000, height: 500 });
|
||||
it('should compute vertical letterbox offsets for wide image', () => {
|
||||
const img = mockImage({ naturalWidth: 2000, naturalHeight: 1000, width: 800, height: 600 });
|
||||
const metrics = getContentMetrics(img);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(400);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(100);
|
||||
});
|
||||
|
||||
it('should handle letterboxed content with horizontal offset', () => {
|
||||
const metrics = { contentWidth: 300, contentHeight: 600, offsetX: 250, offsetY: 0 };
|
||||
const rect = mapContentRectToNatural({ left: 250, top: 0, width: 300, height: 600 }, metrics, {
|
||||
width: 1000,
|
||||
height: 2000,
|
||||
});
|
||||
expect(rect).toEqual({ left: 0, top: 0, width: 1000, height: 2000 });
|
||||
it('should use clientWidth/clientHeight for video elements', () => {
|
||||
const video = mockVideo({ videoWidth: 1920, videoHeight: 1080, clientWidth: 800, clientHeight: 600 });
|
||||
const metrics = getContentMetrics(video);
|
||||
expect(metrics.contentWidth).toBe(800);
|
||||
expect(metrics.contentHeight).toBe(450);
|
||||
expect(metrics.offsetX).toBe(0);
|
||||
expect(metrics.offsetY).toBe(75);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNaturalSize', () => {
|
||||
it('should return naturalWidth/naturalHeight for images', () => {
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000 });
|
||||
const img = mockImage({ naturalWidth: 4000, naturalHeight: 3000, width: 800, height: 600 });
|
||||
expect(getNaturalSize(img)).toEqual({ width: 4000, height: 3000 });
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@ export const scaleToFit = (dimensions: Size, container: Size): Size => {
|
||||
};
|
||||
};
|
||||
|
||||
const getElementSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.clientWidth, height: element.clientHeight };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Size => {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
return { width: element.videoWidth, height: element.videoHeight };
|
||||
@@ -56,18 +63,17 @@ export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): Si
|
||||
return { width: element.naturalWidth, height: element.naturalHeight };
|
||||
};
|
||||
|
||||
export function computeContentMetrics(imageSize: Size, containerSize: Size): ContentMetrics {
|
||||
if (imageSize.width === 0 || imageSize.height === 0) {
|
||||
return { contentWidth: 0, contentHeight: 0, offsetX: 0, offsetY: 0 };
|
||||
}
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(imageSize, containerSize);
|
||||
export const getContentMetrics = (element: HTMLImageElement | HTMLVideoElement): ContentMetrics => {
|
||||
const natural = getNaturalSize(element);
|
||||
const client = getElementSize(element);
|
||||
const { width: contentWidth, height: contentHeight } = scaleToFit(natural, client);
|
||||
return {
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
offsetX: (containerSize.width - contentWidth) / 2,
|
||||
offsetY: (containerSize.height - contentHeight) / 2,
|
||||
offsetX: (client.width - contentWidth) / 2,
|
||||
offsetY: (client.height - contentHeight) / 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function mapNormalizedToContent(point: Point, sizeOrMetrics: Size | ContentMetrics): Point {
|
||||
if ('contentWidth' in sizeOrMetrics) {
|
||||
@@ -103,25 +109,3 @@ export function mapNormalizedRectToContent(
|
||||
height: br.y - tl.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapContentToNatural(point: Point, metrics: ContentMetrics, naturalSize: Size): Point {
|
||||
return {
|
||||
x: ((point.x - metrics.offsetX) / metrics.contentWidth) * naturalSize.width,
|
||||
y: ((point.y - metrics.offsetY) / metrics.contentHeight) * naturalSize.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapContentRectToNatural(rect: Rect, metrics: ContentMetrics, naturalSize: Size): Rect {
|
||||
const topLeft = mapContentToNatural({ x: rect.left, y: rect.top }, metrics, naturalSize);
|
||||
const bottomRight = mapContentToNatural(
|
||||
{ x: rect.left + rect.width, y: rect.top + rect.height },
|
||||
metrics,
|
||||
naturalSize,
|
||||
);
|
||||
return {
|
||||
top: topLeft.y,
|
||||
left: topLeft.x,
|
||||
width: bottomRight.x - topLeft.x,
|
||||
height: bottomRight.y - topLeft.y,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user