mirror of
https://github.com/immich-app/immich.git
synced 2026-07-24 05:44:37 +03:00
Compare commits
14 Commits
fix/androi
...
fcast
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22ab0b3a90 | ||
|
|
19e48f538d | ||
|
|
acb5d53ab9 | ||
|
|
d9178e2036 | ||
|
|
22624cc134 | ||
|
|
29605f710e | ||
|
|
792d88961a | ||
|
|
20ae07e228 | ||
|
|
a0a0aa3f3c | ||
|
|
ceef4037f1 | ||
|
|
cbb565b7b7 | ||
|
|
3bd580e37d | ||
|
|
f24a128319 | ||
|
|
36dfc98527 |
14
.github/workflows/build-mobile.yml
vendored
14
.github/workflows/build-mobile.yml
vendored
@@ -127,6 +127,15 @@ jobs:
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
- name: Install Rust and protoc for cargokit
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.cargo/bin/rustup" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y protobuf-compiler
|
||||
echo "PROTOC=$(command -v protoc)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Get Packages
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
@@ -226,6 +235,11 @@ jobs:
|
||||
github_token: ${{ steps.token.outputs.token }}
|
||||
working_directory: ./mobile
|
||||
|
||||
- name: Install protoc
|
||||
run: |
|
||||
brew install protobuf
|
||||
echo "PROTOC=$(brew --prefix)/bin/protoc" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Flutter dependencies
|
||||
working-directory: ./mobile
|
||||
run: flutter pub get
|
||||
|
||||
@@ -409,7 +409,7 @@ To decrease Redis logs, you can add the following line to the `redis:` section o
|
||||
|
||||
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
|
||||
|
||||
[Example docker-compose.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
|
||||
[Example docker-compose.rootless.yml file](https://github.com/immich-app/immich/blob/main/docker/docker-compose.rootless.yml)
|
||||
|
||||
You may need to add mount points or docker volumes for the following internal container paths:
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
# Chromecast support
|
||||
# Casting support
|
||||
|
||||
Immich supports the Google's Cast protocol so that photos and videos can be cast to devices such as a Chromecast and a Nest Hub. This feature is considered experimental and has several important limitations listed below. Currently, this feature is only supported by the web client, support on Android and iOS is planned for the future.
|
||||
Immich supports both the FCast and Google Cast protocols, meaning photos and videos in Immich can be cast to devices such as a Chromecast, Nest Hub, or an FCast receiver. This feature is considered experimental and has several important limitations listed below. This feature is supported by the web client, as well as the mobile app via FCast.
|
||||
|
||||
## Enable Google Cast Support
|
||||
## Mobile FCast support
|
||||
|
||||
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retrieve them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
|
||||
The mobile app also supports FCast for casting media to both FCast receivers and Google Cast devices such as a Google TV or Nest Hub. Devices are automatically discovered over your local network.
|
||||
|
||||
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`
|
||||
## Enable web support
|
||||
|
||||
Casting from the web client is currently only possible via Google Cast, so enabling web support means enabling Google Cast. Google Cast support is disabled by default, since it requires the web UI to load Google-provided scripts from Google's servers when the page loads. This is a privacy concern for some and is thus opt-in.
|
||||
|
||||
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`.
|
||||
|
||||
<img src={require('./img/gcast-enable.webp').default} width="70%" title='Enable Google Cast Support' />
|
||||
|
||||
## Limitations
|
||||
|
||||
To use casting with Immich, there are a few prerequisites:
|
||||
To use Google Cast with Immich, there are a few prerequisites:
|
||||
|
||||
1. Your instance must be accessed via an HTTPS connection in order for the casting menu to show.
|
||||
2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`)
|
||||
3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media)
|
||||
2. Your instance must be publicly accessible via HTTPS and a DNS record for the server must be accessible via Google's DNS servers (`8.8.8.8` and `8.8.4.4`).
|
||||
3. Videos must be in a format that is compatible with Google Cast. For more info, check out [Google's documentation](https://developers.google.com/cast/docs/media).
|
||||
|
||||
@@ -1459,10 +1459,8 @@
|
||||
"move_to": "Move to",
|
||||
"move_to_device_trash": "Move to device trash",
|
||||
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
|
||||
"move_to_lock_folder_partial_prompt": "{count} added to the locked folder, {kept} local copies were kept",
|
||||
"move_to_locked_folder": "Move to locked folder",
|
||||
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
|
||||
"move_to_locked_folder_local_ios": "These items will be deleted from Photos, but will still be available on the Immich server. They will be in Recently Deleted for 30 days.",
|
||||
"moved_to_trash": "Moved to trash",
|
||||
"mute_memories": "Mute Memories",
|
||||
"my_albums": "My albums",
|
||||
@@ -1526,6 +1524,7 @@
|
||||
"not_available": "N/A",
|
||||
"not_in_any_album": "Not in any album",
|
||||
"not_selected": "Not selected",
|
||||
"not_set": "Not set",
|
||||
"notes": "Notes",
|
||||
"nothing_here_yet": "Nothing here yet",
|
||||
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
|
||||
|
||||
@@ -27,12 +27,30 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
def ensure_fcast_sender_sdk_podspec
|
||||
plugin_ios_dir = File.expand_path(File.join('.symlinks', 'plugins', 'fcast_sender_sdk', 'ios'), __dir__)
|
||||
original_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk_flutter_plugin.podspec')
|
||||
expected_podspec = File.join(plugin_ios_dir, 'fcast_sender_sdk.podspec')
|
||||
|
||||
return unless File.exist?(original_podspec)
|
||||
|
||||
contents = File.read(original_podspec)
|
||||
fixed_contents = contents
|
||||
.sub("s.name = 'fcast_sender_sdk_flutter_plugin'", "s.name = 'fcast_sender_sdk'")
|
||||
.gsub('libfcast_sender_sdk_flutter_plugin.a', 'libfcast_sender_sdk.a')
|
||||
|
||||
return if File.exist?(expected_podspec) && File.read(expected_podspec) == fixed_contents
|
||||
|
||||
File.write(expected_podspec, fixed_contents)
|
||||
end
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
|
||||
ensure_fcast_sender_sdk_podspec
|
||||
|
||||
# share_handler addition start
|
||||
target 'ShareExtension' do
|
||||
inherit! :search_paths
|
||||
@@ -49,7 +67,7 @@ post_install do |installer|
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
PODS:
|
||||
- bonsoir_darwin (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- fcast_sender_sdk (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
@@ -24,8 +23,8 @@ PODS:
|
||||
- share_handler_ios_models (0.0.9)
|
||||
|
||||
DEPENDENCIES:
|
||||
- bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- fcast_sender_sdk (from `.symlinks/plugins/fcast_sender_sdk/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
@@ -35,10 +34,10 @@ DEPENDENCIES:
|
||||
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
bonsoir_darwin:
|
||||
:path: ".symlinks/plugins/bonsoir_darwin/darwin"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
fcast_sender_sdk:
|
||||
:path: ".symlinks/plugins/fcast_sender_sdk/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_local_notifications:
|
||||
@@ -55,8 +54,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/share_handler_ios/ios/Models"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
fcast_sender_sdk: 8bf227a5cbcedaea2e580a633ff0f706f1e90328
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
@@ -65,6 +64,6 @@ SPEC CHECKSUMS:
|
||||
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
|
||||
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
|
||||
|
||||
PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c
|
||||
PODFILE CHECKSUM: 44895462563291c3e4328a856c6482a25165b507
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
COCOAPODS: 1.17.0
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
|
||||
"state" : {
|
||||
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
|
||||
"version" : "6.14.0"
|
||||
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
|
||||
"version" : "6.27.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
<array>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
<string>_fcast._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
|
||||
@@ -153,6 +153,8 @@ class AppConfig {
|
||||
.timelineStorageIndicator => timeline.storageIndicator,
|
||||
.mapShowFavoriteOnly => map.favoritesOnly,
|
||||
.mapRelativeDate => map.relativeDays,
|
||||
.mapCustomFrom => map.customFrom,
|
||||
.mapCustomTo => map.customTo,
|
||||
.mapIncludeArchived => map.includeArchived,
|
||||
.mapThemeMode => map.themeMode,
|
||||
.mapWithPartners => map.withPartners,
|
||||
@@ -207,6 +209,8 @@ class AppConfig {
|
||||
.timelineStorageIndicator => copyWith(timeline: timeline.copyWith(storageIndicator: value as bool)),
|
||||
.mapShowFavoriteOnly => copyWith(map: map.copyWith(favoritesOnly: value as bool)),
|
||||
.mapRelativeDate => copyWith(map: map.copyWith(relativeDays: value as int)),
|
||||
.mapCustomFrom => copyWith(map: map.copyWith(customFrom: .fromNullable(value as DateTime?))),
|
||||
.mapCustomTo => copyWith(map: map.copyWith(customTo: .fromNullable(value as DateTime?))),
|
||||
.mapIncludeArchived => copyWith(map: map.copyWith(includeArchived: value as bool)),
|
||||
.mapThemeMode => copyWith(map: map.copyWith(themeMode: value as ThemeMode)),
|
||||
.mapWithPartners => copyWith(map: map.copyWith(withPartners: value as bool)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapConfig {
|
||||
final int relativeDays;
|
||||
@@ -6,13 +7,17 @@ class MapConfig {
|
||||
final bool includeArchived;
|
||||
final ThemeMode themeMode;
|
||||
final bool withPartners;
|
||||
final DateTime? customFrom;
|
||||
final DateTime? customTo;
|
||||
|
||||
const MapConfig({
|
||||
this.relativeDays = 0,
|
||||
this.favoritesOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.themeMode = ThemeMode.system,
|
||||
this.themeMode = .system,
|
||||
this.withPartners = false,
|
||||
this.customFrom,
|
||||
this.customTo,
|
||||
});
|
||||
|
||||
MapConfig copyWith({
|
||||
@@ -21,12 +26,16 @@ class MapConfig {
|
||||
bool? includeArchived,
|
||||
ThemeMode? themeMode,
|
||||
bool? withPartners,
|
||||
Option<DateTime>? customFrom,
|
||||
Option<DateTime>? customTo,
|
||||
}) => MapConfig(
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
favoritesOnly: favoritesOnly ?? this.favoritesOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
customFrom: customFrom.patch(this.customFrom),
|
||||
customTo: customTo.patch(this.customTo),
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -37,12 +46,15 @@ class MapConfig {
|
||||
other.favoritesOnly == favoritesOnly &&
|
||||
other.includeArchived == includeArchived &&
|
||||
other.themeMode == themeMode &&
|
||||
other.withPartners == withPartners);
|
||||
other.withPartners == withPartners &&
|
||||
other.customFrom == customFrom &&
|
||||
other.customTo == customTo);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners);
|
||||
int get hashCode =>
|
||||
Object.hash(relativeDays, favoritesOnly, includeArchived, themeMode, withPartners, customFrom, customTo);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners)';
|
||||
'MapConfig(relativeDays: $relativeDays, favoritesOnly: $favoritesOnly, includeArchived: $includeArchived, themeMode: $themeMode, withPartners: $withPartners, customFrom: $customFrom, customTo: $customTo)';
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ enum SettingsKey<T> {
|
||||
// Map
|
||||
mapShowFavoriteOnly<bool>(),
|
||||
mapRelativeDate<int>(),
|
||||
mapCustomFrom<DateTime?>(),
|
||||
mapCustomTo<DateTime?>(),
|
||||
mapIncludeArchived<bool>(),
|
||||
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
|
||||
mapWithPartners<bool>(),
|
||||
|
||||
15
mobile/lib/domain/models/time_range.model.dart
Normal file
15
mobile/lib/domain/models/time_range.model.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class TimeRange {
|
||||
final DateTime? from;
|
||||
final DateTime? to;
|
||||
|
||||
const TimeRange({this.from, this.to});
|
||||
|
||||
TimeRange copyWith({Option<DateTime>? from, Option<DateTime>? to}) {
|
||||
return TimeRange(from: from.patch(this.from), to: to.patch(this.to));
|
||||
}
|
||||
|
||||
TimeRange clearFrom() => TimeRange(to: to);
|
||||
TimeRange clearTo() => TimeRange(from: from);
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/services/hash.service.dart';
|
||||
import 'package:immich_mobile/domain/services/local_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/domain/services/sync_stream.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
@@ -14,11 +17,16 @@ import 'package:immich_mobile/infrastructure/repositories/logger_db.repository.d
|
||||
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_api.g.dart';
|
||||
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart' show nativeSyncApiProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/permission.repository.dart';
|
||||
import 'package:immich_mobile/services/auth.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/services/localization.service.dart';
|
||||
@@ -58,12 +66,43 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
final BackgroundWorkerBgHostApi _backgroundHostApi;
|
||||
final _cancellationToken = Completer<void>();
|
||||
final Logger _logger = Logger('BackgroundWorkerBgService');
|
||||
late LocalSyncService _localSyncService;
|
||||
late SyncStreamService _remoteSyncService;
|
||||
late HashService _hashService;
|
||||
|
||||
bool _isCleanedUp = false;
|
||||
|
||||
BackgroundWorkerBgService({required this._drift, required this._driftLogger})
|
||||
: _backgroundHostApi = BackgroundWorkerBgHostApi() {
|
||||
_ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
final ref = ProviderContainer(overrides: [driftProvider.overrideWith(driftOverride(_drift))]);
|
||||
_ref = ref;
|
||||
_localSyncService = LocalSyncService(
|
||||
localAlbumRepository: ref.read(localAlbumRepository),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.read(permissionRepositoryProvider),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_remoteSyncService = SyncStreamService(
|
||||
syncApiRepository: ref.read(syncApiRepositoryProvider),
|
||||
syncStreamRepository: ref.read(syncStreamRepositoryProvider),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
assetMediaRepository: ref.read(assetMediaRepositoryProvider),
|
||||
permissionRepository: ref.read(permissionRepositoryProvider),
|
||||
syncMigrationRepository: ref.read(syncMigrationRepositoryProvider),
|
||||
api: ref.read(apiServiceProvider),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
_hashService = HashService(
|
||||
localAlbumRepository: ref.read(localAlbumRepository),
|
||||
localAssetRepository: ref.read(localAssetRepository),
|
||||
nativeSyncApi: ref.read(nativeSyncApiProvider),
|
||||
trashedLocalAssetRepository: ref.read(trashedLocalAssetRepository),
|
||||
cancellation: _cancellationToken,
|
||||
);
|
||||
BackgroundWorkerFlutterApi.setUp(this);
|
||||
}
|
||||
|
||||
@@ -119,11 +158,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
try {
|
||||
final budget = maxSeconds != null ? Duration(seconds: maxSeconds - 1) : null;
|
||||
|
||||
final sync = _ref?.read(backgroundSyncProvider);
|
||||
if (sync == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only for Background Processing tasks
|
||||
if (maxSeconds == null) {
|
||||
await _optimizeDB();
|
||||
@@ -135,9 +169,23 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
// hash and handle_backup read drift state and tolerate stale reads
|
||||
// (server-side dedup catches the rare race). The single budget caps the
|
||||
// whole batch; no phase needs its own timeout.
|
||||
final all = Future.wait<dynamic>([sync.syncLocal(), sync.syncRemote(), sync.hashAssets(), _handleBackup()]);
|
||||
final all = Future.wait<dynamic>([
|
||||
_localSyncService.sync(),
|
||||
_remoteSyncService.sync(),
|
||||
_hashService.hashAssets(),
|
||||
_handleBackup(),
|
||||
]);
|
||||
if (budget != null) {
|
||||
await all.timeout(budget, onTimeout: () => <dynamic>[]);
|
||||
await all.timeout(
|
||||
budget,
|
||||
onTimeout: () {
|
||||
if (!_cancellationToken.isCompleted) {
|
||||
_logger.warning("iOS background upload timed out after ${budget.inSeconds}s, cancelling tasks");
|
||||
_cancellationToken.complete();
|
||||
}
|
||||
return <dynamic>[];
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await all;
|
||||
}
|
||||
@@ -221,7 +269,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
|
||||
try {
|
||||
_isCleanedUp = true;
|
||||
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
|
||||
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
|
||||
|
||||
_logger.info("Cleaning up background worker");
|
||||
@@ -230,10 +277,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
// Workers share one sqlite connection, so DB teardown must wait until every worker has stopped using it.
|
||||
await Future.wait([
|
||||
if (backgroundSyncManager != null) backgroundSyncManager.cancel(),
|
||||
if (nativeSyncApi != null) nativeSyncApi.cancelHashing(),
|
||||
]);
|
||||
await Future.wait([if (nativeSyncApi != null) nativeSyncApi.cancelHashing()]);
|
||||
await workerManagerPatch.dispose().catchError((_) async {});
|
||||
await Future.wait([LogService.I.dispose(), Store.dispose()]);
|
||||
await _drift.close();
|
||||
@@ -279,18 +323,18 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
||||
}
|
||||
|
||||
Future<bool> _syncAssets({Duration? hashTimeout}) async {
|
||||
await _ref?.read(backgroundSyncProvider).syncLocal();
|
||||
await _localSyncService.sync();
|
||||
if (_isCleanedUp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final isSuccess = await _ref?.read(backgroundSyncProvider).syncRemote() ?? false;
|
||||
final isSuccess = await _remoteSyncService.sync();
|
||||
if (_isCleanedUp) {
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
var hashFuture = _ref?.read(backgroundSyncProvider).hashAssets();
|
||||
if (hashTimeout != null && hashFuture != null) {
|
||||
var hashFuture = _hashService.hashAssets();
|
||||
if (hashTimeout != null) {
|
||||
hashFuture = hashFuture.timeout(
|
||||
hashTimeout,
|
||||
onTimeout: () {
|
||||
|
||||
@@ -27,7 +27,18 @@ class DriftMapRepository extends DriftDatabaseRepository {
|
||||
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
final hasCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
|
||||
if (hasCustomRange) {
|
||||
if (timeRange.from != null) {
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!);
|
||||
}
|
||||
|
||||
if (timeRange.to != null) {
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!);
|
||||
}
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
|
||||
@@ -20,6 +21,7 @@ class TimelineMapOptions {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const TimelineMapOptions({
|
||||
required this.bounds,
|
||||
@@ -27,6 +29,7 @@ class TimelineMapOptions {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -552,8 +555,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
|
||||
final hasCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
|
||||
if (hasCustomRange) {
|
||||
if (timeRange.from != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
|
||||
}
|
||||
|
||||
if (timeRange.to != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
|
||||
}
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
@@ -594,8 +610,21 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
|
||||
}
|
||||
|
||||
if (options.relativeDays != 0) {
|
||||
final timeRange = options.timeRange;
|
||||
|
||||
final hasCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
|
||||
if (hasCustomRange) {
|
||||
if (timeRange.from != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(timeRange.from!));
|
||||
}
|
||||
|
||||
if (timeRange.to != null) {
|
||||
query.where(_db.remoteAssetEntity.createdAt.isSmallerOrEqualValue(timeRange.to!));
|
||||
}
|
||||
} else if (options.relativeDays > 0) {
|
||||
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
|
||||
|
||||
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
enum CastDestinationType { googleCast }
|
||||
enum CastDestinationType { googleCast, fCast }
|
||||
|
||||
enum CastState { idle, playing, paused, buffering }
|
||||
|
||||
|
||||
@@ -16,23 +16,17 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = result.failedCount > 0
|
||||
? 'move_to_lock_folder_partial_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString(), 'kept': result.failedCount.toString()},
|
||||
)
|
||||
: 'move_to_lock_folder_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapState {
|
||||
@@ -15,6 +17,7 @@ class MapState {
|
||||
final bool includeArchived;
|
||||
final bool withPartners;
|
||||
final int relativeDays;
|
||||
final TimeRange timeRange;
|
||||
|
||||
const MapState({
|
||||
this.themeMode = ThemeMode.system,
|
||||
@@ -23,6 +26,7 @@ class MapState {
|
||||
this.includeArchived = false,
|
||||
this.withPartners = false,
|
||||
this.relativeDays = 0,
|
||||
this.timeRange = const TimeRange(),
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -40,6 +44,7 @@ class MapState {
|
||||
bool? includeArchived,
|
||||
bool? withPartners,
|
||||
int? relativeDays,
|
||||
TimeRange? timeRange,
|
||||
}) {
|
||||
return MapState(
|
||||
bounds: bounds ?? this.bounds,
|
||||
@@ -48,6 +53,7 @@ class MapState {
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
withPartners: withPartners ?? this.withPartners,
|
||||
relativeDays: relativeDays ?? this.relativeDays,
|
||||
timeRange: timeRange ?? this.timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +63,7 @@ class MapState {
|
||||
includeArchived: includeArchived,
|
||||
withPartners: withPartners,
|
||||
relativeDays: relativeDays,
|
||||
timeRange: timeRange,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,6 +110,24 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
void setCustomTimeRange(TimeRange range) {
|
||||
ref.read(settingsProvider).write(.mapCustomFrom, range.from);
|
||||
ref.read(settingsProvider).write(.mapCustomTo, range.to);
|
||||
state = state.copyWith(timeRange: range);
|
||||
EventStream.shared.emit(const MapMarkerReloadEvent());
|
||||
}
|
||||
|
||||
Option<DateTime> parseDateOption(String s) {
|
||||
try {
|
||||
if (s.trim().isEmpty) {
|
||||
return const Option.none();
|
||||
}
|
||||
return Option.some(DateTime.parse(s));
|
||||
} catch (_) {
|
||||
return const Option.none();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final mapConfig = ref.read(appConfigProvider.select((config) => config.map));
|
||||
@@ -113,6 +138,7 @@ class MapStateNotifier extends Notifier<MapState> {
|
||||
withPartners: mapConfig.withPartners,
|
||||
relativeDays: mapConfig.relativeDays,
|
||||
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
|
||||
timeRange: TimeRange(from: mapConfig.customFrom, to: mapConfig.customTo),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_custom_time_range.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
|
||||
|
||||
class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
class DriftMapSettingsSheet extends ConsumerStatefulWidget {
|
||||
const DriftMapSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<DriftMapSettingsSheet> createState() => _DriftMapSettingsSheetState();
|
||||
}
|
||||
|
||||
class _DriftMapSettingsSheetState extends ConsumerState<DriftMapSettingsSheet> {
|
||||
late bool useCustomRange;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final mapState = ref.read(mapStateProvider);
|
||||
final timeRange = mapState.timeRange;
|
||||
useCustomRange = timeRange.from != null || timeRange.to != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mapState = ref.watch(mapStateProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
initialChildSize: useCustomRange ? 0.7 : 0.6,
|
||||
builder: (ctx, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
@@ -47,10 +65,41 @@ class DriftMapSettingsSheet extends HookConsumerWidget {
|
||||
selected: mapState.withPartners,
|
||||
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
|
||||
),
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
if (useCustomRange) ...[
|
||||
MapTimeRange(
|
||||
timeRange: mapState.timeRange,
|
||||
onChanged: (range) {
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(range);
|
||||
},
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = false;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
|
||||
}),
|
||||
child: Text(context.t.remove_custom_date_range),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeDays,
|
||||
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() {
|
||||
useCustomRange = true;
|
||||
ref.read(mapStateProvider.notifier).setRelativeTime(0);
|
||||
ref.read(mapStateProvider.notifier).setCustomTimeRange(const TimeRange());
|
||||
}),
|
||||
child: Text(context.t.use_custom_date_range),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -65,6 +65,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: ScrollDatePicker(
|
||||
viewType: datePickerColumnOrder(DateFormat.yMd(context.locale.toLanguageTag()).pattern),
|
||||
options: DatePickerOptions(
|
||||
backgroundColor: context.colorScheme.surfaceContainerHigh,
|
||||
itemExtent: 50,
|
||||
@@ -118,3 +119,18 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<DatePickerViewType>? datePickerColumnOrder(String? pattern) {
|
||||
if (pattern == null) {
|
||||
return null;
|
||||
}
|
||||
final positions = {
|
||||
DatePickerViewType.year: pattern.indexOf('y'),
|
||||
DatePickerViewType.month: pattern.indexOf('M'),
|
||||
DatePickerViewType.day: pattern.indexOf('d'),
|
||||
};
|
||||
if (positions.values.any((position) => position < 0)) {
|
||||
return null;
|
||||
}
|
||||
return positions.keys.toList()..sort((a, b) => positions[a]!.compareTo(positions[b]!));
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
import 'package:immich_mobile/services/cast.service.dart';
|
||||
|
||||
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
||||
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
||||
(ref) => CastNotifier(ref.watch(castServiceProvider)),
|
||||
);
|
||||
|
||||
class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
// more cast providers can be added here (ie Fcast)
|
||||
final GCastService _gCastService;
|
||||
final CastService _castService;
|
||||
|
||||
List<(String, CastDestinationType, dynamic)> discovered = List.empty();
|
||||
|
||||
CastNotifier(this._gCastService)
|
||||
CastNotifier(this._castService)
|
||||
: super(
|
||||
const CastManagerState(
|
||||
isCasting: false,
|
||||
@@ -23,11 +21,11 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
castState: CastState.idle,
|
||||
),
|
||||
) {
|
||||
_gCastService.onConnectionState = _onConnectionState;
|
||||
_gCastService.onCurrentTime = _onCurrentTime;
|
||||
_gCastService.onDuration = _onDuration;
|
||||
_gCastService.onReceiverName = _onReceiverName;
|
||||
_gCastService.onCastState = _onCastState;
|
||||
_castService.onConnectionState = _onConnectionState;
|
||||
_castService.onCurrentTime = _onCurrentTime;
|
||||
_castService.onDuration = _onDuration;
|
||||
_castService.onReceiverName = _onReceiverName;
|
||||
_castService.onCastState = _onCastState;
|
||||
}
|
||||
|
||||
void _onConnectionState(bool isCasting) {
|
||||
@@ -51,23 +49,15 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) {
|
||||
_gCastService.loadMedia(asset, reload);
|
||||
_castService.loadMedia(asset, reload);
|
||||
}
|
||||
|
||||
Future<void> connect(CastDestinationType type, dynamic device) async {
|
||||
switch (type) {
|
||||
case CastDestinationType.googleCast:
|
||||
await _gCastService.connect(device);
|
||||
break;
|
||||
}
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _castService.connect(device);
|
||||
}
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
if (discovered.isEmpty) {
|
||||
discovered = await _gCastService.getDevices();
|
||||
}
|
||||
|
||||
return discovered;
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() {
|
||||
return _castService.getDevices();
|
||||
}
|
||||
|
||||
void toggle() {
|
||||
@@ -81,22 +71,22 @@ class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
}
|
||||
|
||||
void play() {
|
||||
_gCastService.play();
|
||||
_castService.play();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_gCastService.pause();
|
||||
_castService.pause();
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_gCastService.seekTo(position);
|
||||
_castService.seekTo(position);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_gCastService.stop();
|
||||
_castService.stop();
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _gCastService.disconnect();
|
||||
await _castService.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
@@ -25,7 +24,6 @@ import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -70,11 +68,14 @@ class ActionNotifier extends Notifier<void> {
|
||||
return _getAssets(source).whereType<RemoteAsset>().toIds().toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _getLocalIdsForSource(ActionSource source) {
|
||||
List<String> _getLocalIdsForSource(ActionSource source, {bool ignoreLocalOnly = false}) {
|
||||
final Set<BaseAsset> assets = _getAssets(source);
|
||||
final List<String> localIds = [];
|
||||
|
||||
for (final asset in assets) {
|
||||
if (ignoreLocalOnly && asset.storage != AssetState.merged) {
|
||||
continue;
|
||||
}
|
||||
if (asset is LocalAsset) {
|
||||
localIds.add(asset.id);
|
||||
} else if (asset is RemoteAsset && asset.localId != null) {
|
||||
@@ -180,28 +181,12 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> moveToLockFolder(ActionSource source, BuildContext context) async {
|
||||
final assets = _getOwnedRemoteAssetsForSource(source);
|
||||
final ids = assets.toIds().toList(growable: false);
|
||||
final localIds = assets.map((asset) => asset.localId).nonNulls.toList(growable: false);
|
||||
|
||||
if (localIds.isNotEmpty) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
title: "move_to_locked_folder",
|
||||
content: CurrentPlatform.isAndroid ? "delete_dialog_alert_local" : "move_to_locked_folder_local_ios",
|
||||
ok: "confirm",
|
||||
),
|
||||
);
|
||||
if (confirmed != true) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> moveToLockFolder(ActionSource source) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
final localIds = _getLocalIdsForSource(source, ignoreLocalOnly: true);
|
||||
try {
|
||||
final deletedCount = await _service.moveToLockFolder(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true, failedCount: localIds.length - deletedCount);
|
||||
await _service.moveToLockFolder(ids, localIds);
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to move assets to lock folder', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
|
||||
@@ -45,9 +45,9 @@ class AssetMediaRepository {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<List<String>> deleteAll(List<String> ids, {bool trash = true}) async {
|
||||
Future<List<String>> deleteAll(List<String> ids) async {
|
||||
if (CurrentPlatform.isAndroid) {
|
||||
if (trash && await _androidSupportsTrash()) {
|
||||
if (await _androidSupportsTrash()) {
|
||||
return PhotoManager.editor.android.moveToTrash(
|
||||
ids.map((e) => AssetEntity(id: e, width: 1, height: 1, typeInt: 0)).toList(),
|
||||
);
|
||||
|
||||
99
mobile/lib/repositories/cast.repository.dart
Normal file
99
mobile/lib/repositories/cast.repository.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:async';
|
||||
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final castRepositoryProvider = Provider((_) => CastRepository());
|
||||
|
||||
class CastRepository {
|
||||
CastContext? _castContext;
|
||||
CastingDevice? _device;
|
||||
|
||||
void Function(DeviceConnectionState)? onConnectionState;
|
||||
void Function(DeviceEvent)? onDeviceEvent;
|
||||
|
||||
final Map<(String, ProtocolType), (DeviceInfo, int?)> _discoveredDevices = {};
|
||||
int _currentDeviceGeneration = 0;
|
||||
Future<void>? _initialized;
|
||||
|
||||
Future<void> connect(DeviceInfo deviceInfo) async {
|
||||
await _ensureInitialized();
|
||||
|
||||
_device?.disconnect();
|
||||
final device = _castContext!.createDeviceFromInfo(info: deviceInfo);
|
||||
_device = device;
|
||||
|
||||
final thisDeviceGeneration = ++_currentDeviceGeneration;
|
||||
device.connect(
|
||||
eventHandler: DeviceEventHandler(
|
||||
onEvent: (event) {
|
||||
if (thisDeviceGeneration != _currentDeviceGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event is DeviceEvent_ConnectionStateChanged) {
|
||||
onConnectionState?.call(event.newState);
|
||||
}
|
||||
|
||||
onDeviceEvent?.call(event);
|
||||
},
|
||||
),
|
||||
reconnectIntervalMillis: 1000,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
final device = _device;
|
||||
if (device == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_device = null;
|
||||
_currentDeviceGeneration++;
|
||||
|
||||
if (device.isReady()) {
|
||||
device.stopPlayback();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
device.disconnect();
|
||||
onConnectionState?.call(const DeviceConnectionState.disconnected());
|
||||
}
|
||||
|
||||
void loadMedia(LoadRequest request) => _device?.load(request: request);
|
||||
void play() => _device?.resumePlayback();
|
||||
void pause() => _device?.pausePlayback();
|
||||
void stop() => _device?.stopPlayback();
|
||||
void seekTo(Duration position) => _device?.seek(timeSeconds: position.inMilliseconds / 1000);
|
||||
|
||||
Future<List<(DeviceInfo, int?)>> listDestinations() async {
|
||||
final isFirstScan = _initialized == null;
|
||||
await _ensureInitialized();
|
||||
|
||||
if (isFirstScan) {
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
}
|
||||
|
||||
return _discoveredDevices.values.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _ensureInitialized() => _initialized ??= _initialize();
|
||||
|
||||
Future<void> _initialize() async {
|
||||
await FCastSenderSdkLib.init();
|
||||
_castContext = CastContext();
|
||||
|
||||
final discoverer = DeviceDiscoverer();
|
||||
discoverer.eventStreamController.stream.listen((event) {
|
||||
switch (event) {
|
||||
case DiscoveryEventDeviceAdded(:final deviceInfo, :final gcastCaps) ||
|
||||
DiscoveryEventDeviceUpdated(:final deviceInfo, :final gcastCaps):
|
||||
_discoveredDevices[(deviceInfo.name, deviceInfo.protocol)] = (deviceInfo, gcastCaps);
|
||||
case DiscoveryEventDeviceRemoved():
|
||||
_discoveredDevices.removeWhere((key, _) => key.$1 == event.name);
|
||||
}
|
||||
});
|
||||
|
||||
await discoverer.init();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import 'package:cast/device.dart';
|
||||
import 'package:cast/session.dart';
|
||||
import 'package:cast/session_manager.dart';
|
||||
import 'package:cast/discovery_service.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
final gCastRepositoryProvider = Provider((_) {
|
||||
return GCastRepository();
|
||||
});
|
||||
|
||||
class GCastRepository {
|
||||
CastSession? _castSession;
|
||||
|
||||
void Function(CastSessionState)? onCastStatus;
|
||||
void Function(Map<String, dynamic>)? onCastMessage;
|
||||
|
||||
Map<String, dynamic>? _receiverStatus;
|
||||
|
||||
GCastRepository();
|
||||
|
||||
Future<void> connect(CastDevice device) async {
|
||||
_castSession = await CastSessionManager().startSession(device);
|
||||
|
||||
_castSession?.stateStream.listen((state) {
|
||||
onCastStatus?.call(state);
|
||||
});
|
||||
|
||||
_castSession?.messageStream.listen((message) {
|
||||
onCastMessage?.call(message);
|
||||
if (message['type'] == 'RECEIVER_STATUS') {
|
||||
_receiverStatus = message;
|
||||
}
|
||||
});
|
||||
|
||||
// open the default receiver
|
||||
sendMessage(CastSession.kNamespaceReceiver, {'type': 'LAUNCH', 'appId': 'CC1AD845'});
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
final sessionID = getSessionId();
|
||||
|
||||
sendMessage(CastSession.kNamespaceReceiver, {'type': "STOP", "sessionId": sessionID});
|
||||
|
||||
// wait 500ms to ensure the stop command is processed
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
await _castSession?.close();
|
||||
}
|
||||
|
||||
String? getSessionId() {
|
||||
if (_receiverStatus == null) {
|
||||
return null;
|
||||
}
|
||||
return _receiverStatus!['status']['applications'][0]['sessionId'];
|
||||
}
|
||||
|
||||
void sendMessage(String namespace, Map<String, dynamic> message) {
|
||||
if (_castSession == null) {
|
||||
throw Exception("Cast session is not established");
|
||||
}
|
||||
|
||||
_castSession!.sendMessage(namespace, message);
|
||||
}
|
||||
|
||||
Future<List<CastDevice>> listDestinations() async {
|
||||
return await CastDiscoveryService().search(timeout: const Duration(seconds: 3));
|
||||
}
|
||||
}
|
||||
@@ -88,15 +88,14 @@ class ActionService {
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.timeline);
|
||||
}
|
||||
|
||||
Future<int> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
Future<void> moveToLockFolder(List<String> remoteIds, List<String> localIds) async {
|
||||
await _assetApiRepository.updateVisibility(remoteIds, .locked);
|
||||
await _remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked);
|
||||
|
||||
// Locked assets stay on the server, so permanently delete the local copies instead of trashing them
|
||||
if (localIds.isEmpty) {
|
||||
return 0;
|
||||
// Ask user if they want to delete local copies
|
||||
if (localIds.isNotEmpty) {
|
||||
await _deleteLocalAssets(localIds);
|
||||
}
|
||||
return _deleteLocalAssets(localIds, trash: false);
|
||||
}
|
||||
|
||||
Future<void> removeFromLockFolder(List<String> remoteIds) async {
|
||||
@@ -314,12 +313,12 @@ class ActionService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> _deleteLocalAssets(List<String> localIds, {bool trash = true}) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds, trash: trash);
|
||||
Future<int> _deleteLocalAssets(List<String> localIds) async {
|
||||
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
|
||||
if (deletedIds.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
if (trash && CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
await _trashedLocalAssetRepository.applyTrashedAssets(deletedIds);
|
||||
} else {
|
||||
await _localAssetRepository.delete(deletedIds);
|
||||
|
||||
@@ -96,12 +96,12 @@ class ApiService {
|
||||
/// port - optional (default: based on schema)
|
||||
/// path - optional
|
||||
Future<String> resolveEndpoint(String serverUrl) async {
|
||||
String url = sanitizeUrl(serverUrl);
|
||||
String url = normalizeServerUrl(serverUrl);
|
||||
|
||||
// Check for /.well-known/immich
|
||||
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
|
||||
if (wellKnownEndpoint.isNotEmpty) {
|
||||
url = sanitizeUrl(wellKnownEndpoint);
|
||||
url = normalizeServerUrl(wellKnownEndpoint);
|
||||
}
|
||||
|
||||
if (!await _isEndpointAvailable(url)) {
|
||||
|
||||
205
mobile/lib/services/cast.service.dart
Normal file
205
mobile/lib/services/cast.service.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:fcast_sender_sdk/fcast_sender_sdk.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/cast.repository.dart';
|
||||
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final castServiceProvider = Provider(
|
||||
(ref) => CastService(
|
||||
ref.watch(castRepositoryProvider),
|
||||
ref.watch(sessionsAPIRepositoryProvider),
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class CastService {
|
||||
final CastRepository _castRepository;
|
||||
final SessionsAPIRepository _sessionsApiService;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
SessionCreateResponse? sessionKey;
|
||||
String? currentAssetId;
|
||||
bool isConnected = false;
|
||||
|
||||
void Function(bool)? onConnectionState;
|
||||
|
||||
void Function(Duration)? onCurrentTime;
|
||||
|
||||
void Function(Duration)? onDuration;
|
||||
|
||||
void Function(String)? onReceiverName;
|
||||
|
||||
void Function(CastState)? onCastState;
|
||||
|
||||
CastService(this._castRepository, this._sessionsApiService, this._assetApiRepository) {
|
||||
_castRepository.onConnectionState = _onCastStatusCallback;
|
||||
_castRepository.onDeviceEvent = _onDeviceEventCallback;
|
||||
}
|
||||
|
||||
void _onCastStatusCallback(DeviceConnectionState state) {
|
||||
if (state is DeviceConnectionState_Connected) {
|
||||
onConnectionState?.call(true);
|
||||
isConnected = true;
|
||||
} else if (state is DeviceConnectionState_Disconnected) {
|
||||
onConnectionState?.call(false);
|
||||
isConnected = false;
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onDeviceEventCallback(DeviceEvent event) {
|
||||
switch (event) {
|
||||
case DeviceEvent_PlaybackStateChanged():
|
||||
_handlePlaybackState(event.newPlaybackState);
|
||||
break;
|
||||
case DeviceEvent_TimeChanged():
|
||||
onCurrentTime?.call(Duration(milliseconds: (event.newTime * 1000).toInt()));
|
||||
break;
|
||||
case DeviceEvent_DurationChanged():
|
||||
onDuration?.call(Duration(milliseconds: (event.newDuration * 1000).toInt()));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handlePlaybackState(PlaybackState state) {
|
||||
switch (state) {
|
||||
case PlaybackState.playing:
|
||||
onCastState?.call(CastState.playing);
|
||||
break;
|
||||
case PlaybackState.paused:
|
||||
onCastState?.call(CastState.paused);
|
||||
break;
|
||||
case PlaybackState.buffering:
|
||||
onCastState?.call(CastState.buffering);
|
||||
break;
|
||||
case PlaybackState.idle:
|
||||
onCastState?.call(CastState.idle);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _castRepository.connect(device);
|
||||
|
||||
onReceiverName?.call(device.name);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
await _castRepository.disconnect();
|
||||
}
|
||||
|
||||
bool isSessionValid() {
|
||||
// check if we already have a session token
|
||||
// we should always have a expiration date
|
||||
if (sessionKey == null || sessionKey?.expiresAt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
|
||||
|
||||
// we want to make sure we have at least 10 seconds remaining in the session
|
||||
// this is to account for network latency and other delays when sending the request
|
||||
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
|
||||
|
||||
return bufferedExpiration.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) async {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
} else if (asset.id == currentAssetId && !reload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// create a session key
|
||||
if (!isSessionValid()) {
|
||||
sessionKey = await _sessionsApiService.createSession(
|
||||
"Cast",
|
||||
"Cast",
|
||||
duration: const Duration(minutes: 15).inSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
final unauthenticatedUrl = asset.isVideo
|
||||
? getPlaybackUrlForRemoteId(asset.id)
|
||||
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
|
||||
|
||||
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
|
||||
|
||||
// get image mime type
|
||||
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
|
||||
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final request = asset.isVideo
|
||||
? LoadRequest.video(contentType: mimeType, url: authenticatedURL, resumePosition: 0.0)
|
||||
: LoadRequest.image(contentType: mimeType, url: authenticatedURL);
|
||||
|
||||
_castRepository.loadMedia(request);
|
||||
|
||||
currentAssetId = asset.id;
|
||||
}
|
||||
|
||||
void play() {
|
||||
_castRepository.play();
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_castRepository.pause();
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_castRepository.seekTo(position);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_castRepository.stop();
|
||||
|
||||
currentAssetId = null;
|
||||
}
|
||||
|
||||
// 0x01 is display capability bitmask
|
||||
bool isDisplay(int ca) => (ca & 0x01) != 0;
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
final dests = await _castRepository.listDestinations();
|
||||
|
||||
final fCastNames = dests
|
||||
.where((dest) => dest.$1.protocol == ProtocolType.fCast)
|
||||
.map((dest) => dest.$1.name)
|
||||
.toSet();
|
||||
|
||||
return dests
|
||||
.where((dest) {
|
||||
final (device, gcastCaps) = dest;
|
||||
|
||||
if (device.protocol == ProtocolType.fCast) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isDisplay(gcastCaps ?? 0) && !fCastNames.contains(device.name);
|
||||
})
|
||||
.map((dest) {
|
||||
final device = dest.$1;
|
||||
final type = device.protocol == ProtocolType.fCast
|
||||
? CastDestinationType.fCast
|
||||
: CastDestinationType.googleCast;
|
||||
|
||||
return (device.name, type, device as dynamic);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cast/session.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/models/sessions/session_create_response.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/gcast.repository.dart';
|
||||
import 'package:immich_mobile/repositories/sessions_api.repository.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
// ignore: import_rule_openapi, we are only using the AssetMediaSize enum
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final gCastServiceProvider = Provider(
|
||||
(ref) => GCastService(
|
||||
ref.watch(gCastRepositoryProvider),
|
||||
ref.watch(sessionsAPIRepositoryProvider),
|
||||
ref.watch(assetApiRepositoryProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class GCastService {
|
||||
final GCastRepository _gCastRepository;
|
||||
final SessionsAPIRepository _sessionsApiService;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
SessionCreateResponse? sessionKey;
|
||||
String? currentAssetId;
|
||||
bool isConnected = false;
|
||||
int? _sessionId;
|
||||
Timer? _mediaStatusPollingTimer;
|
||||
|
||||
void Function(bool)? onConnectionState;
|
||||
|
||||
void Function(Duration)? onCurrentTime;
|
||||
|
||||
void Function(Duration)? onDuration;
|
||||
|
||||
void Function(String)? onReceiverName;
|
||||
|
||||
void Function(CastState)? onCastState;
|
||||
|
||||
GCastService(this._gCastRepository, this._sessionsApiService, this._assetApiRepository) {
|
||||
_gCastRepository.onCastStatus = _onCastStatusCallback;
|
||||
_gCastRepository.onCastMessage = _onCastMessageCallback;
|
||||
}
|
||||
|
||||
void _onCastStatusCallback(CastSessionState state) {
|
||||
if (state == CastSessionState.connected) {
|
||||
onConnectionState?.call(true);
|
||||
isConnected = true;
|
||||
} else if (state == CastSessionState.closed) {
|
||||
onConnectionState?.call(false);
|
||||
isConnected = false;
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onCastMessageCallback(Map<String, dynamic> message) {
|
||||
switch (message['type']) {
|
||||
case "MEDIA_STATUS":
|
||||
_handleMediaStatus(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleMediaStatus(Map<String, dynamic> message) {
|
||||
final statusList = (message['status'] as List).whereType<Map<String, dynamic>>().toList();
|
||||
|
||||
if (statusList.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final status = statusList[0];
|
||||
switch (status['playerState']) {
|
||||
case "PLAYING":
|
||||
onCastState?.call(CastState.playing);
|
||||
break;
|
||||
case "PAUSED":
|
||||
onCastState?.call(CastState.paused);
|
||||
break;
|
||||
case "BUFFERING":
|
||||
onCastState?.call(CastState.buffering);
|
||||
break;
|
||||
case "IDLE":
|
||||
onCastState?.call(CastState.idle);
|
||||
|
||||
// stop polling for media status if the video finished playing
|
||||
if (status["idleReason"] == "FINISHED") {
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (status["media"] != null && status["media"]["duration"] != null) {
|
||||
final duration = Duration(milliseconds: (status["media"]["duration"] * 1000 ?? 0).toInt());
|
||||
onDuration?.call(duration);
|
||||
}
|
||||
|
||||
if (status["mediaSessionId"] != null) {
|
||||
_sessionId = status["mediaSessionId"];
|
||||
}
|
||||
|
||||
if (status["currentTime"] != null) {
|
||||
final currentTime = Duration(milliseconds: (status["currentTime"] * 1000 ?? 0).toInt());
|
||||
onCurrentTime?.call(currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> connect(dynamic device) async {
|
||||
await _gCastRepository.connect(device);
|
||||
|
||||
onReceiverName?.call(device.extras["fn"] ?? "Google Cast");
|
||||
}
|
||||
|
||||
CastDestinationType getType() {
|
||||
return CastDestinationType.googleCast;
|
||||
}
|
||||
|
||||
Future<bool> initialize() async {
|
||||
// there is nothing blocking us from using Google Cast that we can check for
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
onReceiverName?.call("");
|
||||
currentAssetId = null;
|
||||
await _gCastRepository.disconnect();
|
||||
}
|
||||
|
||||
bool isSessionValid() {
|
||||
// check if we already have a session token
|
||||
// we should always have a expiration date
|
||||
if (sessionKey == null || sessionKey?.expiresAt == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final tokenExpiration = DateTime.parse(sessionKey!.expiresAt!);
|
||||
|
||||
// we want to make sure we have at least 10 seconds remaining in the session
|
||||
// this is to account for network latency and other delays when sending the request
|
||||
final bufferedExpiration = tokenExpiration.subtract(const Duration(seconds: 10));
|
||||
|
||||
return bufferedExpiration.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
void loadMedia(RemoteAsset asset, bool reload) async {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
} else if (asset.id == currentAssetId && !reload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// create a session key
|
||||
if (!isSessionValid()) {
|
||||
sessionKey = await _sessionsApiService.createSession(
|
||||
"Cast",
|
||||
"Google Cast",
|
||||
duration: const Duration(minutes: 15).inSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
final unauthenticatedUrl = asset.isVideo
|
||||
? getPlaybackUrlForRemoteId(asset.id)
|
||||
: getThumbnailUrlForRemoteId(asset.id, type: AssetMediaSize.fullsize);
|
||||
|
||||
final authenticatedURL = "$unauthenticatedUrl&sessionKey=${sessionKey?.token}";
|
||||
|
||||
// get image mime type
|
||||
final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id);
|
||||
|
||||
if (mimeType == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "LOAD",
|
||||
"media": {
|
||||
"contentId": authenticatedURL,
|
||||
"streamType": "BUFFERED",
|
||||
"contentType": mimeType,
|
||||
"contentUrl": authenticatedURL,
|
||||
},
|
||||
"autoplay": true,
|
||||
});
|
||||
|
||||
currentAssetId = asset.id;
|
||||
|
||||
// we need to poll for media status since the cast device does not
|
||||
// send a message when the media is loaded for whatever reason
|
||||
// only do this on videos
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
|
||||
if (asset.isVideo) {
|
||||
_mediaStatusPollingTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
|
||||
if (isConnected) {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "GET_STATUS",
|
||||
"mediaSessionId": _sessionId,
|
||||
});
|
||||
} else {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void play() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PLAY", "mediaSessionId": _sessionId});
|
||||
}
|
||||
|
||||
void pause() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "PAUSE", "mediaSessionId": _sessionId});
|
||||
}
|
||||
|
||||
void seekTo(Duration position) {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {
|
||||
"type": "SEEK",
|
||||
"mediaSessionId": _sessionId,
|
||||
"currentTime": position.inSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_gCastRepository.sendMessage(CastSession.kNamespaceMedia, {"type": "STOP", "mediaSessionId": _sessionId});
|
||||
_mediaStatusPollingTimer?.cancel();
|
||||
|
||||
currentAssetId = null;
|
||||
}
|
||||
|
||||
// 0x01 is display capability bitmask
|
||||
bool isDisplay(int ca) => (ca & 0x01) != 0;
|
||||
|
||||
Future<List<(String, CastDestinationType, dynamic)>> getDevices() async {
|
||||
final dests = await _gCastRepository.listDestinations();
|
||||
|
||||
return dests
|
||||
.map((device) => (device.extras["fn"] ?? "Google Cast", CastDestinationType.googleCast, device))
|
||||
.where((device) {
|
||||
final caString = device.$3.extras["ca"];
|
||||
final caNumber = int.tryParse(caString ?? "0") ?? 0;
|
||||
|
||||
return isDisplay(caNumber);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,17 @@ sealed class Option<T> {
|
||||
None() => onNone(),
|
||||
};
|
||||
|
||||
Option<U> flatMap<U>(Option<U> Function(T value) f) => switch (this) {
|
||||
Some(:final value) => f(value),
|
||||
None() => const Option.none(),
|
||||
};
|
||||
|
||||
void ifPresent(void Function(T value) f) {
|
||||
if (this case Some(:final value)) {
|
||||
f(value);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => switch (this) {
|
||||
Some(:final value) => 'Some($value)',
|
||||
|
||||
@@ -2,12 +2,31 @@ import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:punycode/punycode.dart';
|
||||
|
||||
String sanitizeUrl(String url) {
|
||||
/// Normalizes a server URL, guaranteeing that it has a schema and no trailing slashes
|
||||
String normalizeServerUrl(String url) {
|
||||
final trimmedUrl = url.trim();
|
||||
|
||||
// Add schema if none is set
|
||||
final urlWithSchema = url.trimLeft().startsWith(RegExp(r"https?://")) ? url : "https://$url";
|
||||
final urlWithSchema = trimmedUrl.contains('://') ? trimmedUrl : "https://$trimmedUrl";
|
||||
|
||||
// Remove trailing slash(es)
|
||||
return urlWithSchema.trimRight().replaceFirst(RegExp(r"/+$"), "");
|
||||
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
|
||||
}
|
||||
|
||||
/// Validates a user-entered server URL
|
||||
bool _validateServerUrl(String url) {
|
||||
final parsedUrl = Uri.tryParse(url);
|
||||
return parsedUrl != null && parsedUrl.scheme.startsWith(RegExp(r'^https?$')) && parsedUrl.host.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Normalizes and validates that a server URL is supported
|
||||
bool normalizeAndValidateServerUrl(String? url) {
|
||||
if (url == null || url.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final normalizedUrl = normalizeServerUrl(url);
|
||||
return _validateServerUrl(normalizedUrl);
|
||||
}
|
||||
|
||||
String? getServerUrl() {
|
||||
|
||||
@@ -69,7 +69,7 @@ class CastDialog extends ConsumerWidget {
|
||||
child: Text(item, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)).tr(),
|
||||
);
|
||||
} else {
|
||||
final (deviceName, type, deviceObj) = item as (String, CastDestinationType, dynamic);
|
||||
final (deviceName, _, deviceObj) = item as (String, CastDestinationType, dynamic);
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
@@ -77,7 +77,7 @@ class CastDialog extends ConsumerWidget {
|
||||
style: TextStyle(color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null),
|
||||
),
|
||||
leading: Icon(
|
||||
type == CastDestinationType.googleCast ? Icons.cast : Icons.cast_connected,
|
||||
isCurrentDevice(deviceName) ? Icons.cast_connected : Icons.cast,
|
||||
color: isCurrentDevice(deviceName) ? context.colorScheme.primary : null,
|
||||
),
|
||||
trailing: isCurrentDevice(deviceName)
|
||||
@@ -95,7 +95,7 @@ class CastDialog extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (!isCurrentDevice(deviceName)) {
|
||||
unawaited(ref.read(castProvider.notifier).connect(type, deviceObj));
|
||||
unawaited(ref.read(castProvider.notifier).connect(deviceObj));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -43,18 +43,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
final log = Logger('LoginForm');
|
||||
|
||||
String? _validateUrl(String? url) {
|
||||
if (url == null || url.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsedUrl = Uri.tryParse(url);
|
||||
if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) {
|
||||
return 'login_form_err_invalid_url'.tr();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
String? _validateUrl(String? url) => normalizeAndValidateServerUrl(url) ? null : 'login_form_err_invalid_url'.tr();
|
||||
|
||||
String? _validateEmail(String? email) {
|
||||
if (email == null || email == '') {
|
||||
@@ -101,7 +90,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
/// Fetch the server login credential and enables oAuth login if necessary
|
||||
/// Returns true if successful, false otherwise
|
||||
Future<void> getServerAuthSettings() async {
|
||||
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
|
||||
final sanitizeServerUrl = normalizeServerUrl(serverEndpointController.text);
|
||||
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
|
||||
|
||||
// Guard empty URL
|
||||
@@ -304,7 +293,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
|
||||
try {
|
||||
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
normalizeServerUrl(serverEndpointController.text),
|
||||
state,
|
||||
codeChallenge,
|
||||
);
|
||||
@@ -436,7 +425,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: ImmichSpacing.md),
|
||||
child: Text(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
normalizeServerUrl(serverEndpointController.text),
|
||||
style: context.textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/time_range.model.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
|
||||
class MapTimeRange extends StatelessWidget {
|
||||
const MapTimeRange({super.key, required this.timeRange, required this.onChanged});
|
||||
|
||||
final TimeRange timeRange;
|
||||
final Function(TimeRange) onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(context.t.date_after),
|
||||
subtitle: Text(
|
||||
timeRange.from != null
|
||||
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.from!)
|
||||
: context.t.not_set,
|
||||
),
|
||||
trailing: timeRange.from != null
|
||||
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearFrom()))
|
||||
: null,
|
||||
onTap: () async {
|
||||
final initial = timeRange.from ?? DateTime.now();
|
||||
final currentTo = timeRange.to;
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: currentTo != null && initial.isAfter(currentTo) ? currentTo : initial,
|
||||
firstDate: DateTime(1970),
|
||||
lastDate: currentTo ?? DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onChanged(timeRange.copyWith(from: Option.some(picked)));
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.t.date_before),
|
||||
subtitle: Text(
|
||||
timeRange.to != null
|
||||
? DateFormat.yMMMd(context.locale.toLanguageTag()).add_jm().format(timeRange.to!)
|
||||
: context.t.not_set,
|
||||
),
|
||||
trailing: timeRange.to != null
|
||||
? IconButton(icon: const Icon(Icons.close), onPressed: () => onChanged(timeRange.clearTo()))
|
||||
: null,
|
||||
onTap: () async {
|
||||
final initial = timeRange.to ?? DateTime.now();
|
||||
final currentFrom = timeRange.from;
|
||||
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: currentFrom != null && initial.isBefore(currentFrom) ? currentFrom : initial,
|
||||
firstDate: currentFrom ?? DateTime(1970),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
onChanged(timeRange.copyWith(to: Option.some(picked)));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -74,53 +74,53 @@ packages:
|
||||
source: hosted
|
||||
version: "9.5.5"
|
||||
bonsoir:
|
||||
dependency: "direct overridden"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir
|
||||
sha256: "2e2cf3be580deccad9a48dcaddddf90de092e74b7de2015ef58fb24e11d66496"
|
||||
sha256: "1b112a966302a739d253c8dbc7ea4c1cb3eca6d8110dc59e36d76f7cf0b6edf2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.11"
|
||||
version: "6.1.0"
|
||||
bonsoir_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_android
|
||||
sha256: "9a65b6e50c5718c3f1a7ed6ff57ab9ed8ae990ff9c36d2b1ab3d1b90f28f7d1b"
|
||||
sha256: bfa3ab7e2f65473cb369bce639dbdbdc75064848c01ac11b3c2bc3e16031ff3a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.6"
|
||||
version: "6.0.2"
|
||||
bonsoir_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_darwin
|
||||
sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4"
|
||||
sha256: d62fd62ed433aa09ec99f71f95dae53ffa0adf788c9051ff3d6b903463045d3c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.3"
|
||||
version: "6.1.0"
|
||||
bonsoir_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_linux
|
||||
sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2
|
||||
sha256: a49d5f328a197b27a3901b833f92c93366f2cd7085dcb495fa11ee6d9f2509a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.3"
|
||||
version: "6.0.3"
|
||||
bonsoir_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_platform_interface
|
||||
sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45"
|
||||
sha256: ba1cc30daaa172dfc76f88e4fee8d090674179439201997ba2b3bd9e1cca84c0
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.3"
|
||||
version: "6.1.0"
|
||||
bonsoir_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bonsoir_windows
|
||||
sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d
|
||||
sha256: "01aba2516b776eb1deb68845124dc0a41095da108276d4b307bf19c4c3e2d9b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.5"
|
||||
version: "6.0.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -137,6 +137,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
build_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_cli_annotations
|
||||
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -177,14 +185,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
cast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cast
|
||||
sha256: de1856e1a31aa60a6fed627f827921f7ec6539c67c60d0c899e89646dcbe773e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -418,6 +418,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
fcast_sender_sdk:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fcast_sender_sdk
|
||||
sha256: "4c4e0f51749a0930e26e42e6a3f456293c7f5f0ffed2ecd1e283c28de5e11b33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.3"
|
||||
ffi:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -569,6 +577,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_rust_bridge:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_rust_bridge
|
||||
sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -667,6 +683,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.2.14"
|
||||
freezed_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1398,14 +1422,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -12,7 +12,6 @@ dependencies:
|
||||
async: ^2.13.1
|
||||
auto_route: ^11.1.0
|
||||
background_downloader: ^9.5.4
|
||||
cast: ^2.1.0
|
||||
collection: ^1.19.1
|
||||
connectivity_plus: ^7.0.0
|
||||
crop_image: ^1.0.17
|
||||
@@ -91,6 +90,7 @@ dependencies:
|
||||
url: https://github.com/mertalev/http
|
||||
ref: '549c24b0a4d3881a9a44b70f4873450d43c1c4af' # https://github.com/dart-lang/http/pull/1877
|
||||
path: pkgs/ok_http/
|
||||
fcast_sender_sdk: ^0.0.3
|
||||
|
||||
dev_dependencies:
|
||||
auto_route_generator: ^10.5.0
|
||||
@@ -113,7 +113,11 @@ dev_dependencies:
|
||||
# cast 2.1.0 declares a loose bonsoir range but its code targets the 5.x API.
|
||||
# Pin bonsoir to 5.x until cast releases a version compatible with bonsoir 6.x.
|
||||
dependency_overrides:
|
||||
bonsoir: ^5.1.11
|
||||
bonsoir_darwin:
|
||||
git:
|
||||
url: https://github.com/Skyost/Bonsoir
|
||||
ref: ce155549130e # fix(darwin): add missing Foundation import; still v6.1.0
|
||||
path: packages/bonsoir_darwin
|
||||
objective_c:
|
||||
git:
|
||||
url: https://github.com/mertalev/native
|
||||
|
||||
@@ -77,6 +77,40 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('normalizeAndValidateServerUrl', () {
|
||||
test('should treat null as valid', () {
|
||||
expect(normalizeAndValidateServerUrl(null), isTrue);
|
||||
});
|
||||
|
||||
test('should treat empty string as valid', () {
|
||||
expect(normalizeAndValidateServerUrl(''), isTrue);
|
||||
});
|
||||
|
||||
test('should accept a bare host', () {
|
||||
expect(normalizeAndValidateServerUrl('demo.immich.app'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept a bare host with a port', () {
|
||||
expect(normalizeAndValidateServerUrl('192.168.1.1:2283'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept an http URL', () {
|
||||
expect(normalizeAndValidateServerUrl('http://demo.immich.app'), isTrue);
|
||||
});
|
||||
|
||||
test('should accept an https URL', () {
|
||||
expect(normalizeAndValidateServerUrl('https://demo.immich.app:2283/api'), isTrue);
|
||||
});
|
||||
|
||||
test('should reject a non-http scheme', () {
|
||||
expect(normalizeAndValidateServerUrl('ftp://demo.immich.app'), isFalse);
|
||||
});
|
||||
|
||||
test('should reject scheme only input', () {
|
||||
expect(normalizeAndValidateServerUrl('https://'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('punycodeDecodeUrl', () {
|
||||
test('should return null for null input', () {
|
||||
expect(punycodeDecodeUrl(null), isNull);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:scroll_date_picker/scroll_date_picker.dart';
|
||||
|
||||
void main() {
|
||||
group('datePickerColumnOrder', () {
|
||||
test('month first (en_US)', () {
|
||||
expect(
|
||||
datePickerColumnOrder('M/d/y'),
|
||||
orderedEquals([DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year]),
|
||||
);
|
||||
});
|
||||
|
||||
test('day first (pl)', () {
|
||||
expect(
|
||||
datePickerColumnOrder('dd.MM.y'),
|
||||
orderedEquals([DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year]),
|
||||
);
|
||||
});
|
||||
|
||||
test('year first (ko)', () {
|
||||
expect(
|
||||
datePickerColumnOrder('y. M. d.'),
|
||||
orderedEquals([DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day]),
|
||||
);
|
||||
});
|
||||
|
||||
test('null pattern falls back to package default', () {
|
||||
expect(datePickerColumnOrder(null), isNull);
|
||||
});
|
||||
|
||||
test('missing field falls back to package default', () {
|
||||
expect(datePickerColumnOrder('M/y'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('datePickerColumnOrder with real locale patterns', () {
|
||||
setUpAll(() async {
|
||||
await initializeDateFormatting();
|
||||
});
|
||||
|
||||
for (final (locales, order, name) in const [
|
||||
(
|
||||
['en', 'en-US', 'en-PH'],
|
||||
[DatePickerViewType.month, DatePickerViewType.day, DatePickerViewType.year],
|
||||
'month/day/year',
|
||||
),
|
||||
(
|
||||
['en-GB', 'fr', 'fr-FR', 'de', 'de-DE', 'pl'],
|
||||
[DatePickerViewType.day, DatePickerViewType.month, DatePickerViewType.year],
|
||||
'day/month/year',
|
||||
),
|
||||
(
|
||||
['ja', 'ja-JP', 'zh', 'zh-CN', 'ko', 'ko-KR'],
|
||||
[DatePickerViewType.year, DatePickerViewType.month, DatePickerViewType.day],
|
||||
'year/month/day',
|
||||
),
|
||||
(['ky'], [DatePickerViewType.year, DatePickerViewType.day, DatePickerViewType.month], 'year/day/month'),
|
||||
]) {
|
||||
for (final locale in locales) {
|
||||
test('$locale uses $name', () {
|
||||
expect(datePickerColumnOrder(DateFormat.yMd(locale).pattern), orderedEquals(order));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ class _FrozenBucketService implements TimelineService {
|
||||
}
|
||||
|
||||
class _EmptyBucketService implements TimelineService {
|
||||
const _EmptyBucketService();
|
||||
|
||||
@override
|
||||
Stream<List<Bucket>> Function() get watchBuckets =>
|
||||
() => Stream.value(const []);
|
||||
@@ -109,7 +111,7 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWithValue(_EmptyBucketService()),
|
||||
timelineServiceProvider.overrideWithValue(const _EmptyBucketService()),
|
||||
appConfigProvider.overrideWithValue(const AppConfig()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
@@ -10,14 +10,11 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
import '../../widget_tester_extensions.dart';
|
||||
|
||||
class MockActionService extends Mock implements ActionService {}
|
||||
|
||||
class MockAssetService extends Mock implements AssetService {}
|
||||
@@ -80,9 +77,7 @@ void main() {
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container
|
||||
.read(actionProvider.notifier)
|
||||
.editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
@@ -94,9 +89,7 @@ void main() {
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container
|
||||
.read(actionProvider.notifier)
|
||||
.editDateTime(ActionSource.timeline, FakeBuildContext());
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.timeline, FakeBuildContext());
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
@@ -109,78 +102,11 @@ void main() {
|
||||
container.listen(assetExifProvider(_asset), (_, __) {});
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
|
||||
final result = await container
|
||||
.read(actionProvider.notifier)
|
||||
.editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
final result = await container.read(actionProvider.notifier).editDateTime(ActionSource.viewer, FakeBuildContext());
|
||||
|
||||
expect(result, isNull);
|
||||
await container.read(assetExifProvider(_asset).future);
|
||||
verify(() => assetService.getExif(_asset)).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('moveToLockFolder', () {
|
||||
Future<ActionResult?> runAction(WidgetTester tester) async {
|
||||
late BuildContext context;
|
||||
await tester.pumpConsumerWidget(
|
||||
Builder(
|
||||
builder: (value) {
|
||||
context = value;
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final result = container.read(actionProvider.notifier).moveToLockFolder(ActionSource.timeline, context);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(TextButton).last);
|
||||
await tester.pumpAndSettle();
|
||||
return result;
|
||||
}
|
||||
|
||||
testWidgets('keeps success and reports kept local copies when the OS delete is declined', (tester) async {
|
||||
final asset = _asset.copyWith(localId: 'local-owned');
|
||||
container.read(multiSelectProvider.notifier).selectAsset(asset);
|
||||
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 0);
|
||||
|
||||
final result = await runAction(tester);
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
expect(result?.failedCount, 1);
|
||||
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
|
||||
});
|
||||
|
||||
testWidgets('reports kept local copies when only some are deleted', (tester) async {
|
||||
final first = _asset.copyWith(localId: 'local-1');
|
||||
final second = _asset.copyWith(id: 'asset-2', localId: 'local-2', checksum: 'checksum-2');
|
||||
container.read(multiSelectProvider.notifier)
|
||||
..selectAsset(first)
|
||||
..selectAsset(second);
|
||||
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
|
||||
|
||||
final result = await runAction(tester);
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
expect(result?.failedCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('deletes only owned local copies from a mixed selection', (tester) async {
|
||||
final owned = _asset.copyWith(localId: 'local-owned');
|
||||
final partner = _asset.copyWith(
|
||||
id: 'asset-2',
|
||||
localId: 'local-partner',
|
||||
ownerId: 'partner-1',
|
||||
checksum: 'checksum-2',
|
||||
);
|
||||
container.read(multiSelectProvider.notifier)
|
||||
..selectAsset(owned)
|
||||
..selectAsset(partner);
|
||||
when(() => actionService.moveToLockFolder(any(), any())).thenAnswer((_) async => 1);
|
||||
|
||||
final result = await runAction(tester);
|
||||
|
||||
expect(result?.success, isTrue);
|
||||
verify(() => actionService.moveToLockFolder(['asset-1'], ['local-owned'])).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -148,13 +147,13 @@ void main() {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['a', 'b'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => trashedLocalAssetRepository.applyTrashedAssets(ids)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
@@ -163,13 +162,13 @@ void main() {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, false);
|
||||
const ids = ['c'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => ids);
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => ids);
|
||||
when(() => localAssetRepository.delete(ids)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, ids.length);
|
||||
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verify(() => localAssetRepository.delete(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
@@ -178,73 +177,14 @@ void main() {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
const ids = ['x'];
|
||||
|
||||
when(() => assetMediaRepository.deleteAll(ids, trash: true)).thenAnswer((_) async => <String>[]);
|
||||
when(() => assetMediaRepository.deleteAll(ids)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.deleteLocal(ids);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(ids, trash: true)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(ids)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
});
|
||||
|
||||
group('ActionService.moveToLockFolder', () {
|
||||
const remoteIds = ['r1', 'r2'];
|
||||
const localIds = ['l1', 'l2'];
|
||||
|
||||
test('permanently deletes local copies without trashing, even when Android trash handling is on', () async {
|
||||
await Store.put(StoreKey.manageLocalMediaAndroid, true);
|
||||
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => localIds);
|
||||
when(() => localAssetRepository.delete(localIds)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, localIds);
|
||||
|
||||
expect(result, localIds.length);
|
||||
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
|
||||
verify(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
|
||||
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
|
||||
verify(() => localAssetRepository.delete(localIds)).called(1);
|
||||
verifyNever(() => trashedLocalAssetRepository.applyTrashedAssets(any()));
|
||||
});
|
||||
|
||||
test('locks remote assets without touching local media when there are no local copies', () async {
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, const []);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).called(1);
|
||||
verifyNever(() => assetMediaRepository.deleteAll(any(), trash: any(named: 'trash')));
|
||||
});
|
||||
|
||||
test('returns zero when local deletion is cancelled', () async {
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => <String>[]);
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, localIds);
|
||||
|
||||
expect(result, 0);
|
||||
verify(() => assetMediaRepository.deleteAll(localIds, trash: false)).called(1);
|
||||
verifyNever(() => localAssetRepository.delete(any()));
|
||||
});
|
||||
|
||||
test('returns the number of local copies deleted from a partial result', () async {
|
||||
const deletedIds = ['l1'];
|
||||
when(() => assetApiRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => remoteAssetRepository.updateVisibility(remoteIds, AssetVisibility.locked)).thenAnswer((_) async {});
|
||||
when(() => assetMediaRepository.deleteAll(localIds, trash: false)).thenAnswer((_) async => deletedIds);
|
||||
when(() => localAssetRepository.delete(deletedIds)).thenAnswer((_) async {});
|
||||
|
||||
final result = await sut.moveToLockFolder(remoteIds, localIds);
|
||||
|
||||
expect(result, deletedIds.length);
|
||||
verify(() => localAssetRepository.delete(deletedIds)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
99
mobile/test/unit/utils/value_codec_test.dart
Normal file
99
mobile/test/unit/utils/value_codec_test.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/value_codec.dart';
|
||||
|
||||
enum _Fruit { apple, banana, cherry }
|
||||
|
||||
void main() {
|
||||
group('MapCodec', () {
|
||||
group('encode', () {
|
||||
test('serializes an empty map to an empty JSON object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.encode({}), '{}');
|
||||
});
|
||||
|
||||
test('encodes a string-to-string map as a JSON object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.encode({'a': '1', 'b': '2'}), '{"a":"1","b":"2"}');
|
||||
});
|
||||
|
||||
test('stringifies non-string values via the value codec', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.encode({'x': 10, 'y': 20}), '{"x":"10","y":"20"}');
|
||||
});
|
||||
|
||||
test('stringifies non-string keys via the key codec', () {
|
||||
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
|
||||
expect(codec.encode({1: 'one', 2: 'two'}), '{"1":"one","2":"two"}');
|
||||
});
|
||||
});
|
||||
|
||||
group('decode', () {
|
||||
test('reconstructs a string-to-string map', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('{"a":"1","b":"2"}'), {'a': '1', 'b': '2'});
|
||||
});
|
||||
|
||||
test('parses values back to their domain type', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.decode('{"x":"10","y":"20"}'), {'x': 10, 'y': 20});
|
||||
});
|
||||
|
||||
test('parses keys back to their domain type', () {
|
||||
const codec = MapCodec<int, String>(PrimitiveCodec.integer, PrimitiveCodec.string);
|
||||
expect(codec.decode('{"1":"one","2":"two"}'), {1: 'one', 2: 'two'});
|
||||
});
|
||||
|
||||
test('returns an empty map for an empty JSON object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('{}'), isEmpty);
|
||||
});
|
||||
|
||||
test('returns an empty map when the payload is not valid JSON', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('not json'), isEmpty);
|
||||
});
|
||||
|
||||
test('returns an empty map when the JSON root is not an object', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('[]'), isEmpty);
|
||||
expect(codec.decode('"a string"'), isEmpty);
|
||||
expect(codec.decode('42'), isEmpty);
|
||||
});
|
||||
|
||||
test('skips entries whose value is not a JSON string, keeping the rest', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.decode('{"x":1,"y":"20"}'), {'y': 20});
|
||||
});
|
||||
|
||||
test('skips entries whose value is a nested object, keeping the rest', () {
|
||||
const codec = MapCodec<String, String>(PrimitiveCodec.string, PrimitiveCodec.string);
|
||||
expect(codec.decode('{"a":{"nested":"value"},"b":"ok"}'), {'b': 'ok'});
|
||||
});
|
||||
|
||||
test('returns an empty map when every entry is malformed', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
expect(codec.decode('{"x":1,"y":2}'), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('round trip', () {
|
||||
test('preserves a primitive map through encode then decode', () {
|
||||
const codec = MapCodec<String, int>(PrimitiveCodec.string, PrimitiveCodec.integer);
|
||||
const original = {'one': 1, 'two': 2, 'three': 3};
|
||||
expect(codec.decode(codec.encode(original)), original);
|
||||
});
|
||||
|
||||
test('preserves an enum-valued map by composing with EnumCodec', () {
|
||||
const codec = MapCodec<String, _Fruit>(PrimitiveCodec.string, EnumCodec(_Fruit.values));
|
||||
const original = {'breakfast': _Fruit.banana, 'snack': _Fruit.apple};
|
||||
expect(codec.decode(codec.encode(original)), original);
|
||||
});
|
||||
|
||||
test('preserves a DateTime-valued map by composing with DateTimeCodec', () {
|
||||
const codec = MapCodec<String, DateTime>(PrimitiveCodec.string, DateTimeCodec());
|
||||
final original = {'created': DateTime.utc(2024, 1, 1, 12, 30)};
|
||||
expect(codec.decode(codec.encode(original)), original);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -106,7 +106,11 @@ describe(MemoryController.name, () => {
|
||||
it('should require at least one field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/memories/${factory.uuid()}`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: [], message: 'At least one field must be provided' }]));
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: [], message: 'At least one of the following fields is required: isSaved, seenAt, memoryAt' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -304,6 +304,36 @@ export const columns = {
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
searchAsset: [
|
||||
'asset.id',
|
||||
'asset.updateId',
|
||||
'asset.createdAt',
|
||||
'asset.updatedAt',
|
||||
'asset.deletedAt',
|
||||
'asset.status',
|
||||
'asset.checksum',
|
||||
'asset.checksumAlgorithm',
|
||||
'asset.duplicateId',
|
||||
'asset.duration',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.isExternal',
|
||||
'asset.isFavorite',
|
||||
'asset.isOffline',
|
||||
'asset.isEdited',
|
||||
'asset.visibility',
|
||||
'asset.libraryId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.localDateTime',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.stackId',
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
workflowAssetV1: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
|
||||
@@ -108,9 +108,11 @@ export function ChunkedSet(options?: { paramIndex?: number; chunkSize?: number }
|
||||
}
|
||||
|
||||
const UUID = '00000000-0000-4000-a000-000000000000';
|
||||
const UUID_1 = '00000000-0000-4000-a000-000000000001';
|
||||
|
||||
export const DummyValue = {
|
||||
UUID,
|
||||
UUID_1,
|
||||
UUID_SET: new Set([UUID]),
|
||||
PAGINATION: { take: 10, skip: 0 },
|
||||
EMAIL: 'user@immich.app',
|
||||
|
||||
@@ -3,8 +3,15 @@ import { Place } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseSchema } from 'src/dtos/album.dto';
|
||||
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetOrderSchema, AssetTypeSchema, AssetVisibilitySchema } from 'src/enum';
|
||||
import { isoDatetimeToDate, stringToBool } from 'src/validation';
|
||||
import {
|
||||
AssetOrder,
|
||||
AssetOrderSchema,
|
||||
AssetTypeSchema,
|
||||
AssetVisibilitySchema,
|
||||
SearchOrderField,
|
||||
SearchOrderFieldSchema,
|
||||
} from 'src/enum';
|
||||
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
@@ -142,6 +149,176 @@ const SearchSuggestionRequestSchema = z
|
||||
})
|
||||
.meta({ id: 'SearchSuggestionRequestDto' });
|
||||
|
||||
const IdFilterSchema = nonEmptyPartial({
|
||||
eq: z.uuidv4(),
|
||||
ne: z.uuidv4(),
|
||||
}).meta({ id: 'IdFilter' });
|
||||
|
||||
const IdFilterNullableSchema = nonEmptyPartial({
|
||||
eq: z.uuidv4().nullable(),
|
||||
ne: z.uuidv4().nullable(),
|
||||
}).meta({ id: 'IdFilterNullable' });
|
||||
|
||||
const IdsFilterSchema = nonEmptyPartial({
|
||||
any: z.array(z.uuidv4()).min(1),
|
||||
all: z.array(z.uuidv4()).min(1),
|
||||
none: z.array(z.uuidv4()).min(1),
|
||||
}).meta({ id: 'IdsFilter' });
|
||||
|
||||
const stringListShape = {
|
||||
in: z.array(z.string()).min(1),
|
||||
notIn: z.array(z.string()).min(1),
|
||||
};
|
||||
|
||||
const StringFilterSchema = nonEmptyPartial({
|
||||
eq: z.string(),
|
||||
ne: z.string(),
|
||||
...stringListShape,
|
||||
}).meta({ id: 'StringFilter' });
|
||||
|
||||
const stringNullableShape = {
|
||||
eq: z.string().nullable(),
|
||||
ne: z.string().nullable(),
|
||||
...stringListShape,
|
||||
};
|
||||
|
||||
const StringFilterNullableSchema = nonEmptyPartial(stringNullableShape).meta({ id: 'StringFilterNullable' });
|
||||
|
||||
const StringPatternFilterSchema = nonEmptyPartial({
|
||||
...stringNullableShape,
|
||||
like: z.string().min(1),
|
||||
notLike: z.string().min(1),
|
||||
startsWith: z.string().min(1),
|
||||
endsWith: z.string().min(1),
|
||||
}).meta({ id: 'StringPatternFilter' });
|
||||
|
||||
const numberRangeShape = {
|
||||
lt: z.number(),
|
||||
lte: z.number(),
|
||||
gt: z.number(),
|
||||
gte: z.number(),
|
||||
in: z.array(z.number()).min(1),
|
||||
notIn: z.array(z.number()).min(1),
|
||||
};
|
||||
|
||||
const NumberFilterSchema = nonEmptyPartial({
|
||||
eq: z.number(),
|
||||
ne: z.number(),
|
||||
...numberRangeShape,
|
||||
}).meta({ id: 'NumberFilter' });
|
||||
|
||||
const NumberFilterNullableSchema = nonEmptyPartial({
|
||||
eq: z.number().nullable(),
|
||||
ne: z.number().nullable(),
|
||||
...numberRangeShape,
|
||||
}).meta({ id: 'NumberFilterNullable' });
|
||||
|
||||
const dateRangeShape = {
|
||||
gt: isoDatetimeToDate,
|
||||
gte: isoDatetimeToDate,
|
||||
lt: isoDatetimeToDate,
|
||||
lte: isoDatetimeToDate,
|
||||
};
|
||||
|
||||
const DateFilterSchema = nonEmptyPartial({
|
||||
eq: isoDatetimeToDate,
|
||||
ne: isoDatetimeToDate,
|
||||
...dateRangeShape,
|
||||
}).meta({ id: 'DateFilter' });
|
||||
|
||||
const DateFilterNullableSchema = nonEmptyPartial({
|
||||
eq: isoDatetimeToDate.nullable(),
|
||||
ne: isoDatetimeToDate.nullable(),
|
||||
...dateRangeShape,
|
||||
}).meta({ id: 'DateFilterNullable' });
|
||||
|
||||
const BoolFilterSchema = z.object({ eq: z.boolean() }).meta({ id: 'BoolFilter' });
|
||||
|
||||
const enumFilterSchema = <T extends z.core.util.EnumLike>(values: z.ZodEnum<T>, id: string) =>
|
||||
nonEmptyPartial({
|
||||
eq: values,
|
||||
ne: values,
|
||||
in: z.array(values).min(1),
|
||||
notIn: z.array(values).min(1),
|
||||
}).meta({ id });
|
||||
|
||||
const EnumFilterAssetTypeSchema = enumFilterSchema(AssetTypeSchema, 'EnumFilterAssetType');
|
||||
const EnumFilterAssetVisibilitySchema = enumFilterSchema(AssetVisibilitySchema, 'EnumFilterAssetVisibility');
|
||||
|
||||
const StringSimilarityFilterSchema = z
|
||||
.object({
|
||||
matches: z.string().min(1),
|
||||
})
|
||||
.meta({ id: 'StringSimilarityFilter' });
|
||||
|
||||
export const DEFAULT_SEARCH_ORDER = {
|
||||
field: SearchOrderField.FileCreatedAt,
|
||||
direction: AssetOrder.Desc,
|
||||
};
|
||||
|
||||
export const SearchOrderSchema = z
|
||||
.object({
|
||||
field: SearchOrderFieldSchema.default(DEFAULT_SEARCH_ORDER.field),
|
||||
direction: AssetOrderSchema.default(DEFAULT_SEARCH_ORDER.direction),
|
||||
})
|
||||
.meta({ id: 'SearchOrder' });
|
||||
|
||||
const SearchFilterBranchSchema = z
|
||||
.object({
|
||||
id: IdFilterSchema,
|
||||
libraryId: IdFilterNullableSchema,
|
||||
type: EnumFilterAssetTypeSchema,
|
||||
visibility: EnumFilterAssetVisibilitySchema,
|
||||
isFavorite: BoolFilterSchema,
|
||||
isMotion: BoolFilterSchema,
|
||||
isOffline: BoolFilterSchema,
|
||||
isEncoded: BoolFilterSchema,
|
||||
hasAlbums: BoolFilterSchema,
|
||||
hasPeople: BoolFilterSchema,
|
||||
hasTags: BoolFilterSchema,
|
||||
city: StringFilterNullableSchema,
|
||||
state: StringFilterNullableSchema,
|
||||
country: StringFilterNullableSchema,
|
||||
make: StringFilterNullableSchema,
|
||||
model: StringFilterNullableSchema,
|
||||
lensModel: StringFilterNullableSchema,
|
||||
description: StringPatternFilterSchema,
|
||||
originalFileName: StringPatternFilterSchema,
|
||||
originalPath: StringPatternFilterSchema,
|
||||
ocr: StringSimilarityFilterSchema,
|
||||
rating: NumberFilterNullableSchema,
|
||||
fileSizeInBytes: NumberFilterSchema,
|
||||
takenAt: DateFilterSchema,
|
||||
createdAt: DateFilterSchema,
|
||||
updatedAt: DateFilterSchema,
|
||||
trashedAt: DateFilterNullableSchema,
|
||||
personIds: IdsFilterSchema,
|
||||
tagIds: IdsFilterSchema,
|
||||
albumIds: IdsFilterSchema,
|
||||
checksum: StringFilterSchema,
|
||||
encodedVideoPath: StringFilterSchema,
|
||||
})
|
||||
.partial()
|
||||
.meta({ id: 'SearchFilterBranch' });
|
||||
|
||||
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
|
||||
or: z.array(SearchFilterBranchSchema).min(1).optional(),
|
||||
}).meta({ id: 'SearchFilter' });
|
||||
|
||||
export type IdFilter = z.infer<typeof IdFilterSchema>;
|
||||
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
|
||||
export type IdsFilter = z.infer<typeof IdsFilterSchema>;
|
||||
export type StringFilter = z.infer<typeof StringFilterSchema>;
|
||||
export type StringFilterNullable = z.infer<typeof StringFilterNullableSchema>;
|
||||
export type StringPatternFilter = z.infer<typeof StringPatternFilterSchema>;
|
||||
export type NumberFilter = z.infer<typeof NumberFilterSchema>;
|
||||
export type NumberFilterNullable = z.infer<typeof NumberFilterNullableSchema>;
|
||||
export type DateFilter = z.infer<typeof DateFilterSchema>;
|
||||
export type DateFilterNullable = z.infer<typeof DateFilterNullableSchema>;
|
||||
export type SearchOrder = z.infer<typeof SearchOrderSchema>;
|
||||
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
|
||||
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
|
||||
|
||||
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
|
||||
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
|
||||
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
|
||||
|
||||
@@ -1234,3 +1234,12 @@ export enum CalendarHeatmapType {
|
||||
Upload = 'Upload',
|
||||
Taken = 'Taken',
|
||||
}
|
||||
|
||||
export enum SearchOrderField {
|
||||
FileCreatedAt = 'fileCreatedAt',
|
||||
LocalDateTime = 'localDateTime',
|
||||
FileSizeInBytes = 'fileSizeInBytes',
|
||||
Rating = 'rating',
|
||||
}
|
||||
|
||||
export const SearchOrderFieldSchema = z.enum(SearchOrderField).meta({ id: 'SearchOrderField' });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,23 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SearchFilter, SearchOrder } from 'src/dtos/search.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum';
|
||||
import { probes } from 'src/repositories/database.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { anyUuid, searchAssetBuilder, withExifInner } from 'src/utils/database';
|
||||
import {
|
||||
anyUuid,
|
||||
searchAssetBuilder,
|
||||
searchAssetBuilderLegacy,
|
||||
searchMetadataV3Examples,
|
||||
searchStatisticsV3Examples,
|
||||
withExifInner,
|
||||
withSearchOrder,
|
||||
} from 'src/utils/database';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
import z from 'zod';
|
||||
|
||||
@@ -122,6 +133,21 @@ export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
|
||||
|
||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||
|
||||
export interface AssetSearchBuilderV3Options {
|
||||
filter?: SearchFilter;
|
||||
/** Server-derived ownership scope. Never client-controlled. */
|
||||
userIds?: string[];
|
||||
withExif?: boolean;
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
withStacked?: boolean;
|
||||
order?: SearchOrder;
|
||||
}
|
||||
|
||||
export interface AssetSearchPaginationV3Options {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type SmartSearchOptions = SearchDateOptions &
|
||||
SearchEmbeddingOptions &
|
||||
SearchExifOptions &
|
||||
@@ -196,9 +222,10 @@ export class SearchRepository {
|
||||
})
|
||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions) {
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
const items = await searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
const items = await searchAssetBuilderLegacy(this.db, options)
|
||||
.select(columns.searchAsset)
|
||||
.orderBy('asset.fileCreatedAt', orderDirection)
|
||||
.orderBy('asset.id', orderDirection)
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
@@ -217,7 +244,7 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
searchStatistics(options: AssetSearchOptions) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -235,8 +262,8 @@ export class SearchRepository {
|
||||
],
|
||||
})
|
||||
async searchRandom(size: number, options: AssetSearchOptions) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.select(columns.searchAsset)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size)
|
||||
.execute();
|
||||
@@ -256,8 +283,8 @@ export class SearchRepository {
|
||||
})
|
||||
searchLargeAssets(size: number, options: LargeAssetSearchOptions) {
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection;
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.selectAll('asset')
|
||||
return searchAssetBuilderLegacy(this.db, options)
|
||||
.select(columns.searchAsset)
|
||||
.$call(withExifInner)
|
||||
.where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0)
|
||||
.orderBy('asset_exif.fileSizeInByte', orderDirection)
|
||||
@@ -285,10 +312,11 @@ export class SearchRepository {
|
||||
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx);
|
||||
const items = await searchAssetBuilder(trx, options)
|
||||
.selectAll('asset')
|
||||
const items = await searchAssetBuilderLegacy(trx, options)
|
||||
.select(columns.searchAsset)
|
||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||
.orderBy('asset.id', 'asc')
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
@@ -417,7 +445,7 @@ export class SearchRepository {
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.innerJoin('cte', 'asset.id', 'cte.assetId')
|
||||
.selectAll('asset')
|
||||
.select(columns.searchAsset)
|
||||
.select((eb) =>
|
||||
eb
|
||||
.fn('to_jsonb', [eb.table('asset_exif')])
|
||||
@@ -490,6 +518,24 @@ export class SearchRepository {
|
||||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
@GenerateSql(...searchMetadataV3Examples)
|
||||
searchMetadataV3(
|
||||
pagination: AssetSearchPaginationV3Options,
|
||||
options: AssetSearchBuilderV3Options,
|
||||
): Promise<MapAsset[]> {
|
||||
return withSearchOrder(searchAssetBuilder(this.db, options), options.order)
|
||||
.select(columns.searchAsset)
|
||||
.limit(pagination.size)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql(...searchStatisticsV3Examples)
|
||||
searchStatisticsV3(options: AssetSearchBuilderV3Options) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
|
||||
@@ -7,21 +7,42 @@ import {
|
||||
Kysely,
|
||||
KyselyConfig,
|
||||
NotNull,
|
||||
OperandValueExpression,
|
||||
ReferenceExpression,
|
||||
Selectable,
|
||||
SelectQueryBuilder,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
SqlBool,
|
||||
} from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Notice, PostgresError } from 'postgres';
|
||||
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { DummyValue, GenerateSqlQueries } from 'src/decorators';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import {
|
||||
DEFAULT_SEARCH_ORDER,
|
||||
IdsFilter,
|
||||
SearchFilterBranch,
|
||||
SearchOrder,
|
||||
StringFilter,
|
||||
StringPatternFilter,
|
||||
} from 'src/dtos/search.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetOrder,
|
||||
AssetOrderBy,
|
||||
AssetVisibility,
|
||||
DatabaseExtension,
|
||||
ExifOrientation,
|
||||
SearchOrderField,
|
||||
} from 'src/enum';
|
||||
import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
return {
|
||||
@@ -54,6 +75,8 @@ export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyCon
|
||||
};
|
||||
};
|
||||
|
||||
const uniqueIds = (ids: string[]) => [...new Set(ids)];
|
||||
|
||||
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
|
||||
|
||||
export const anyUuid = (ids: string[]) => sql<string>`any(${`{${ids}}`}::uuid[])`;
|
||||
@@ -85,16 +108,15 @@ export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>)
|
||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||
}
|
||||
|
||||
const selectExifInfo = (eb: AssetExpressionBuilder) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('asset_exif'))
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
|
||||
.as('exifInfo');
|
||||
|
||||
// TODO come up with a better query that only selects the fields we need
|
||||
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('asset_exif'))
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
|
||||
.as('exifInfo'),
|
||||
);
|
||||
return qb.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId').select(selectExifInfo);
|
||||
}
|
||||
|
||||
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
@@ -373,7 +395,7 @@ export function withEdits(eb: ExpressionBuilder<DB, 'asset'>): AliasedEditAction
|
||||
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
||||
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
||||
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||
export function searchAssetBuilderLegacy(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
||||
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
|
||||
|
||||
return kysely
|
||||
@@ -493,6 +515,458 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||
}
|
||||
|
||||
type AssetExpressionBuilder = ExpressionBuilder<DB, 'asset' | 'asset_exif'>;
|
||||
|
||||
const albumAssets = (eb: AssetExpressionBuilder) =>
|
||||
eb.selectFrom('album_asset').whereRef('album_asset.assetId', '=', 'asset.id');
|
||||
|
||||
const visibleFaces = (eb: AssetExpressionBuilder) =>
|
||||
eb
|
||||
.selectFrom('asset_face')
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.where('asset_face.deletedAt', 'is', null)
|
||||
.where('asset_face.isVisible', '=', true);
|
||||
|
||||
const tagAssets = (eb: AssetExpressionBuilder) =>
|
||||
eb.selectFrom('tag_asset').whereRef('tag_asset.assetId', '=', 'asset.id');
|
||||
|
||||
// shared any/all/none mechanics; `matchesAll` only receives deduplicated multi-id lists,
|
||||
// so its `count(distinct id) = ids.length` check stays satisfiable
|
||||
function idsPredicates(
|
||||
eb: AssetExpressionBuilder,
|
||||
{ any, all, none }: IdsFilter = {},
|
||||
ops: {
|
||||
matchesAny: (ids: string[]) => Expression<SqlBool>;
|
||||
matchesAll: (ids: string[]) => Expression<SqlBool>;
|
||||
},
|
||||
) {
|
||||
const predicates: Expression<SqlBool>[] = [];
|
||||
if (any) {
|
||||
predicates.push(ops.matchesAny(any));
|
||||
}
|
||||
if (all) {
|
||||
const ids = uniqueIds(all);
|
||||
predicates.push(ids.length === 1 ? ops.matchesAny(ids) : ops.matchesAll(ids));
|
||||
}
|
||||
if (none) {
|
||||
predicates.push(eb.not(ops.matchesAny(none)));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||
const matching = (ids: string[]) => albumAssets(eb).where('album_asset.albumId', '=', anyUuid(ids));
|
||||
return idsPredicates(eb, filter, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
eb.exists(
|
||||
matching(ids)
|
||||
.select('album_asset.assetId')
|
||||
.groupBy('album_asset.assetId')
|
||||
.having((eb) => eb.fn.count('album_asset.albumId').distinct(), '=', ids.length),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||
const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids));
|
||||
return idsPredicates(eb, filter, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
eb.exists(
|
||||
matching(ids)
|
||||
.select('asset_face.assetId')
|
||||
.groupBy('asset_face.assetId')
|
||||
.having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function tagIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) {
|
||||
const matching = (ids: string[]) =>
|
||||
tagAssets(eb)
|
||||
.innerJoin('tag_closure', 'tag_asset.tagId', 'tag_closure.id_descendant')
|
||||
.where('tag_closure.id_ancestor', '=', anyUuid(ids));
|
||||
return idsPredicates(eb, filter, {
|
||||
matchesAny: (ids) => eb.exists(matching(ids)),
|
||||
matchesAll: (ids) =>
|
||||
eb.exists(
|
||||
matching(ids)
|
||||
.select('tag_asset.assetId')
|
||||
.groupBy('tag_asset.assetId')
|
||||
.having((eb) => eb.fn.count('tag_closure.id_ancestor').distinct(), '=', ids.length),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
type ComparisonFilter<T> = {
|
||||
eq?: T | null;
|
||||
ne?: T | null;
|
||||
lt?: T;
|
||||
lte?: T;
|
||||
gt?: T;
|
||||
gte?: T;
|
||||
in?: T[];
|
||||
notIn?: T[];
|
||||
};
|
||||
|
||||
// one operator dispatch for every filter shape; the DTO schemas constrain which
|
||||
// operators (and null literals) each filter can actually carry
|
||||
function comparisonPredicates<TB extends keyof DB, RE extends ReferenceExpression<DB, TB>>(
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
column: RE,
|
||||
filter: ComparisonFilter<OperandValueExpression<DB, TB, RE>> = {},
|
||||
) {
|
||||
const predicates: Expression<SqlBool>[] = [];
|
||||
if (filter.eq !== undefined) {
|
||||
predicates.push(filter.eq === null ? eb(column, 'is', null) : eb(column, '=', filter.eq));
|
||||
}
|
||||
if (filter.ne !== undefined) {
|
||||
predicates.push(filter.ne === null ? eb(column, 'is not', null) : eb(column, '!=', filter.ne));
|
||||
}
|
||||
if (filter.lt !== undefined) {
|
||||
predicates.push(eb(column, '<', filter.lt));
|
||||
}
|
||||
if (filter.lte !== undefined) {
|
||||
predicates.push(eb(column, '<=', filter.lte));
|
||||
}
|
||||
if (filter.gt !== undefined) {
|
||||
predicates.push(eb(column, '>', filter.gt));
|
||||
}
|
||||
if (filter.gte !== undefined) {
|
||||
predicates.push(eb(column, '>=', filter.gte));
|
||||
}
|
||||
if (filter.in !== undefined) {
|
||||
predicates.push(eb(column, 'in', filter.in));
|
||||
}
|
||||
if (filter.notIn !== undefined) {
|
||||
predicates.push(eb(column, 'not in', filter.notIn));
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
type StringColumn =
|
||||
| 'asset_exif.city'
|
||||
| 'asset_exif.state'
|
||||
| 'asset_exif.country'
|
||||
| 'asset_exif.make'
|
||||
| 'asset_exif.model'
|
||||
| 'asset_exif.lensModel'
|
||||
| 'asset_exif.description'
|
||||
| 'asset.originalFileName'
|
||||
| 'asset.originalPath';
|
||||
|
||||
function stringPatternPredicates(eb: AssetExpressionBuilder, column: StringColumn, filter: StringPatternFilter = {}) {
|
||||
const ref = sql.ref(column);
|
||||
const predicates = comparisonPredicates(eb, column, filter);
|
||||
if (filter.like !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.like}) || '%')`);
|
||||
}
|
||||
if (filter.notLike !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) not ilike ('%' || f_unaccent(${filter.notLike}) || '%')`);
|
||||
}
|
||||
if (filter.startsWith !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike (f_unaccent(${filter.startsWith}) || '%')`);
|
||||
}
|
||||
if (filter.endsWith !== undefined) {
|
||||
predicates.push(sql<SqlBool>`f_unaccent(${ref}) ilike ('%' || f_unaccent(${filter.endsWith}))`);
|
||||
}
|
||||
return predicates;
|
||||
}
|
||||
|
||||
function checksumPredicates(eb: AssetExpressionBuilder, filter: StringFilter = {}) {
|
||||
return comparisonPredicates(eb, 'asset.checksum', {
|
||||
eq: filter.eq === undefined ? undefined : fromChecksum(filter.eq),
|
||||
ne: filter.ne === undefined ? undefined : fromChecksum(filter.ne),
|
||||
in: filter.in?.map((checksum) => fromChecksum(checksum)),
|
||||
notIn: filter.notIn?.map((checksum) => fromChecksum(checksum)),
|
||||
});
|
||||
}
|
||||
|
||||
const encodedVideoFiles = (eb: AssetExpressionBuilder) =>
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', AssetFileType.EncodedVideo);
|
||||
|
||||
function existsPredicates(
|
||||
eb: AssetExpressionBuilder,
|
||||
filter: { eq: boolean } | undefined,
|
||||
subquery: () => Expression<unknown>,
|
||||
): Expression<SqlBool>[] {
|
||||
if (!filter) {
|
||||
return [];
|
||||
}
|
||||
const exists = eb.exists(subquery());
|
||||
return [filter.eq ? exists : eb.not(exists)];
|
||||
}
|
||||
|
||||
// predicates are collected as expressions rather than chained `where` calls so the same
|
||||
// helpers can build each `or` branch, which must compose into eb.and/eb.or
|
||||
function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch) {
|
||||
const { encodedVideoPath } = branch;
|
||||
return [
|
||||
...comparisonPredicates(eb, 'asset.id', branch.id),
|
||||
...comparisonPredicates(eb, 'asset.libraryId', branch.libraryId),
|
||||
...comparisonPredicates(eb, 'asset.type', branch.type),
|
||||
...comparisonPredicates(eb, 'asset.visibility', branch.visibility),
|
||||
...(branch.isFavorite ? [eb('asset.isFavorite', '=', branch.isFavorite.eq)] : []),
|
||||
...(branch.isOffline ? [eb('asset.isOffline', '=', branch.isOffline.eq)] : []),
|
||||
...(branch.isMotion ? [eb('asset.livePhotoVideoId', branch.isMotion.eq ? 'is not' : 'is', null)] : []),
|
||||
...existsPredicates(eb, branch.isEncoded, () => encodedVideoFiles(eb)),
|
||||
...existsPredicates(eb, branch.hasAlbums, () => albumAssets(eb)),
|
||||
...existsPredicates(eb, branch.hasPeople, () => visibleFaces(eb)),
|
||||
...existsPredicates(eb, branch.hasTags, () => tagAssets(eb)),
|
||||
...comparisonPredicates(eb, 'asset_exif.city', branch.city),
|
||||
...comparisonPredicates(eb, 'asset_exif.state', branch.state),
|
||||
...comparisonPredicates(eb, 'asset_exif.country', branch.country),
|
||||
...comparisonPredicates(eb, 'asset_exif.make', branch.make),
|
||||
...comparisonPredicates(eb, 'asset_exif.model', branch.model),
|
||||
...comparisonPredicates(eb, 'asset_exif.lensModel', branch.lensModel),
|
||||
...stringPatternPredicates(eb, 'asset_exif.description', branch.description),
|
||||
...stringPatternPredicates(eb, 'asset.originalFileName', branch.originalFileName),
|
||||
...stringPatternPredicates(eb, 'asset.originalPath', branch.originalPath),
|
||||
...(branch.ocr
|
||||
? [
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('ocr_search')
|
||||
.whereRef('ocr_search.assetId', '=', 'asset.id')
|
||||
.where(
|
||||
sql<SqlBool>`f_unaccent(ocr_search.text) %>> f_unaccent(${tokenizeForSearch(branch.ocr.matches).join(' ')})`,
|
||||
),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...comparisonPredicates(eb, 'asset_exif.rating', branch.rating),
|
||||
...comparisonPredicates(eb, 'asset_exif.fileSizeInByte', branch.fileSizeInBytes),
|
||||
...comparisonPredicates(eb, 'asset.fileCreatedAt', branch.takenAt),
|
||||
...comparisonPredicates(eb, 'asset.createdAt', branch.createdAt),
|
||||
...comparisonPredicates(eb, 'asset.updatedAt', branch.updatedAt),
|
||||
...comparisonPredicates(eb, 'asset.deletedAt', branch.trashedAt),
|
||||
...albumIdsPredicates(eb, branch.albumIds),
|
||||
...personIdsPredicates(eb, branch.personIds),
|
||||
...tagIdsPredicates(eb, branch.tagIds),
|
||||
...checksumPredicates(eb, branch.checksum),
|
||||
...(encodedVideoPath
|
||||
? [
|
||||
eb.exists(
|
||||
encodedVideoFiles(eb)
|
||||
.where('asset_file.isEdited', '=', false)
|
||||
.where((eb) => eb.and(comparisonPredicates(eb, 'asset_file.path', encodedVideoPath))),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats)
|
||||
// can compose the same filters without stripping an order by
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options) {
|
||||
const filter = options.filter ?? {};
|
||||
|
||||
return (
|
||||
kysely
|
||||
.withPlugin(joinDeduplicationPlugin)
|
||||
.selectFrom('asset')
|
||||
// postgres eliminates the left join when no exif column is referenced, so unused joins are free
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.$if(!!options.withExif, (qb) => qb.select(selectExifInfo))
|
||||
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
||||
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||
)
|
||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.where((eb) => {
|
||||
const predicates = branchPredicates(eb, filter);
|
||||
if (filter.or && filter.or.length > 0) {
|
||||
predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch)))));
|
||||
}
|
||||
return predicates.length > 0 ? eb.and(predicates) : eb.lit(true);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const searchOrderColumns = {
|
||||
[SearchOrderField.FileCreatedAt]: { column: 'asset.fileCreatedAt', nullable: false },
|
||||
[SearchOrderField.LocalDateTime]: { column: 'asset.localDateTime', nullable: false },
|
||||
[SearchOrderField.FileSizeInBytes]: { column: 'asset_exif.fileSizeInByte', nullable: true },
|
||||
[SearchOrderField.Rating]: { column: 'asset_exif.rating', nullable: true },
|
||||
} as const;
|
||||
|
||||
export function withSearchOrder(qb: ReturnType<typeof searchAssetBuilder>, order?: SearchOrder) {
|
||||
const { field, direction } = order ?? DEFAULT_SEARCH_ORDER;
|
||||
const { column, nullable } = searchOrderColumns[field];
|
||||
return (
|
||||
qb
|
||||
.orderBy(column, (ob) => {
|
||||
const ordered = direction === AssetOrder.Asc ? ob.asc() : ob.desc();
|
||||
// nulls last: assets without an asset_exif row would otherwise lead descending results
|
||||
return nullable ? ordered.nullsLast() : ordered;
|
||||
})
|
||||
// id tie-break for deterministic pagination
|
||||
.orderBy('asset.id', direction)
|
||||
);
|
||||
}
|
||||
|
||||
export const searchMetadataV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
|
||||
{ name: 'empty', params: [{ size: 100 }, {}] },
|
||||
{
|
||||
name: 'or-exif-only',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
|
||||
},
|
||||
{
|
||||
name: 'string-eq-null',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-like',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-notLike',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-startsWith',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-similarity-ocr',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-any',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ids-all-single',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-none',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
|
||||
},
|
||||
{
|
||||
name: 'ids-tags-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'has-albums-false',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
|
||||
},
|
||||
{
|
||||
name: 'is-encoded',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
|
||||
},
|
||||
{
|
||||
name: 'number-range',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
|
||||
},
|
||||
{
|
||||
name: 'date-eq',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
|
||||
},
|
||||
{
|
||||
name: 'date-range',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-fileSize-noExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
|
||||
withExif: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-rating-withExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
|
||||
withExif: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-branches',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-with-top-level',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const searchStatisticsV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] },
|
||||
{
|
||||
name: 'with-filter',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
fileSizeInBytes: { gte: 100 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'with-or',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export type ReindexVectorIndexOptions = { indexName: string; lists?: number };
|
||||
|
||||
type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension } & ReindexVectorIndexOptions;
|
||||
|
||||
@@ -42,7 +42,7 @@ export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
|
||||
.object(shape)
|
||||
.partial()
|
||||
.refine((data) => Object.values(data as Record<string, unknown>).some((value) => value !== undefined), {
|
||||
message: 'At least one field must be provided',
|
||||
message: `At least one of the following fields is required: ${Object.keys(shape).join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import { mdiChevronRight, mdiChevronLeft } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../NavigationArea.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
onNextAsset: () => void;
|
||||
@@ -20,5 +21,5 @@
|
||||
/>
|
||||
|
||||
<NavigationArea onClick={onNextAsset} label={$t('view_next_asset')}>
|
||||
<Icon icon={mdiChevronRight} size="36" aria-hidden />
|
||||
<Icon icon={languageManager.rtl ? mdiChevronLeft : mdiChevronRight} size="36" aria-hidden />
|
||||
</NavigationArea>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronLeft } from '@mdi/js';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import NavigationArea from '../NavigationArea.svelte';
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
|
||||
interface Props {
|
||||
onPreviousAsset: () => void;
|
||||
@@ -20,5 +21,5 @@
|
||||
/>
|
||||
|
||||
<NavigationArea onClick={onPreviousAsset} label={$t('view_previous_asset')}>
|
||||
<Icon icon={mdiChevronLeft} size="36" aria-hidden />
|
||||
<Icon icon={languageManager.rtl ? mdiChevronRight : mdiChevronLeft} size="36" aria-hidden />
|
||||
</NavigationArea>
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
const getPreviousRoute = $page.url.searchParams.get(QueryParameter.PREVIOUS_ROUTE);
|
||||
if (getPreviousRoute && !isExternalUrl(getPreviousRoute)) {
|
||||
previousRoute = getPreviousRoute;
|
||||
} else if ($page.params.assetId) {
|
||||
previousRoute = Route.viewPerson(data.person);
|
||||
}
|
||||
if (action == 'merge') {
|
||||
viewMode = PersonPageViewMode.MERGE_PEOPLE;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { languageManager } from '$lib/managers/language-manager.svelte';
|
||||
import { Button, Icon } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiArrowRight, mdiCheck } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -52,7 +53,7 @@
|
||||
<div class="flex w-full place-content-start">
|
||||
<Button
|
||||
shape="round"
|
||||
leadingIcon={mdiArrowLeft}
|
||||
leadingIcon={languageManager.rtl ? mdiArrowRight : mdiArrowLeft}
|
||||
class="flex place-content-center gap-2"
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
@@ -67,7 +68,7 @@
|
||||
<div class="flex w-full place-content-end">
|
||||
<Button
|
||||
shape="round"
|
||||
trailingIcon={nextTitle ? mdiArrowRight : mdiCheck}
|
||||
trailingIcon={nextTitle ? (languageManager.rtl ? mdiArrowLeft : mdiArrowRight) : mdiCheck}
|
||||
onclick={() => {
|
||||
onLeave?.();
|
||||
onNext?.();
|
||||
|
||||
Reference in New Issue
Block a user