mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
Merge remote-tracking branch 'origin/main' into feature/sync_assets_trashed_state
# Conflicts: # mobile/ios/Runner/Sync/MessagesImpl.swift
This commit is contained in:
commit
69b6472adf
41 changed files with 829 additions and 625 deletions
|
|
@ -140,7 +140,7 @@ services:
|
|||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ services:
|
|||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ services:
|
|||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:41eacbe83eca995561fe43814fd4891e16e39632806253848efaf04d3c8a8b84
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0@sha256:bcf63357191b76a916ae5eb93464d65c07511da41e3bf7a8416db519b40b1c23
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ For organizations seeking to resell Immich, we have established the following gu
|
|||
|
||||
- Do not misrepresent your reseller site or services as being officially affiliated with or endorsed by Immich or our development team.
|
||||
|
||||
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directy from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||
- For small resellers who wish to contribute financially to Immich's development, we recommend directing your customers to purchase licenses directly from us rather than attempting to broker revenue-sharing arrangements. We ask that you refrain from misrepresenting reseller activities as directly supporting our development work.
|
||||
|
||||
When in doubt or if you have an edge case scenario, we encourage you to contact us directly via email to discuss the use of our trademark. We can provide clear guidance on what is acceptable and what is not. You can reach out at: questions@immich.app
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Immich supports the Google's Cast protocol so that photos and videos can be cast
|
|||
|
||||
## Enable Google Cast Support
|
||||
|
||||
Google Cast support is disabled by default. The web UI uses Google-provided scripts and must retreive them from Google servers when the page loads. This is a privacy concern for some and is thus opt-in.
|
||||
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.
|
||||
|
||||
You can enable Google Cast support through `Account Settings > Features > Cast > Google Cast`
|
||||
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@ services:
|
|||
- 2285:2285
|
||||
|
||||
redis:
|
||||
image: redis:6.2-alpine@sha256:2185e741f4c1e7b0ea9ca1e163a3767c4270a73086b6bbea2049a7203212fb7f
|
||||
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
|
||||
|
||||
database:
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:11ced39d65a92a54d12890ced6a26cc2003f92697d6f0d4d944b98459dba7138
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
|
||||
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
|
|
|||
|
|
@ -1039,6 +1039,7 @@
|
|||
"exif_bottom_sheet_description_error": "Error updating description",
|
||||
"exif_bottom_sheet_details": "DETAILS",
|
||||
"exif_bottom_sheet_location": "LOCATION",
|
||||
"exif_bottom_sheet_no_description": "No description",
|
||||
"exif_bottom_sheet_people": "PEOPLE",
|
||||
"exif_bottom_sheet_person_add_person": "Add name",
|
||||
"exit_slideshow": "Exit Slideshow",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
[tools]
|
||||
node = "22.20.0"
|
||||
flutter = "3.35.5"
|
||||
pnpm = "10.18.0"
|
||||
pnpm = "10.18.1"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
|
|
|||
|
|
@ -131,10 +131,13 @@
|
|||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
|
@ -247,6 +250,7 @@
|
|||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
|
|
@ -331,6 +335,7 @@
|
|||
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||
);
|
||||
name = Runner;
|
||||
|
|
@ -521,10 +526,14 @@
|
|||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
|
|
@ -553,10 +562,14 @@
|
|||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import UIKit
|
|||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||
AppDelegate.registerPlugins(binaryMessenger: controller.binaryMessenger)
|
||||
AppDelegate.registerPlugins(with: controller.engine)
|
||||
BackgroundServicePlugin.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||
|
||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||
|
|
@ -51,9 +51,13 @@ import UIKit
|
|||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
public static func registerPlugins(with engine: FlutterEngine) {
|
||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||
}
|
||||
|
||||
public static func cancelPlugins(with engine: FlutterEngine) {
|
||||
(engine.valuePublished(byPlugin: NativeSyncApiImpl.name) as? NativeSyncApiImpl)?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
|||
// Register plugins in the new engine
|
||||
GeneratedPluginRegistrant.register(with: engine)
|
||||
// Register custom plugins
|
||||
AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger)
|
||||
AppDelegate.registerPlugins(with: engine)
|
||||
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
||||
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
||||
|
||||
|
|
@ -168,6 +168,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
|||
}
|
||||
|
||||
isComplete = true
|
||||
AppDelegate.cancelPlugins(with: engine)
|
||||
engine.destroyContext()
|
||||
flutterApi = nil
|
||||
completionHandler(success)
|
||||
|
|
|
|||
17
mobile/ios/Runner/Core/ImmichPlugin.swift
Normal file
17
mobile/ios/Runner/Core/ImmichPlugin.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
class ImmichPlugin: NSObject {
|
||||
var detached: Bool
|
||||
|
||||
override init() {
|
||||
detached = false
|
||||
super.init()
|
||||
}
|
||||
|
||||
func detachFromEngine() {
|
||||
self.detached = true
|
||||
}
|
||||
|
||||
func completeWhenActive<T>(for completion: @escaping (T) -> Void, with value: T) {
|
||||
guard !self.detached else { return }
|
||||
completion(value)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,13 +17,25 @@ struct AssetWrapper: Hashable, Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
class NativeSyncApiImpl: NativeSyncApi {
|
||||
class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
||||
static let name = "NativeSyncApi"
|
||||
|
||||
static func register(with registrar: any FlutterPluginRegistrar) {
|
||||
let instance = NativeSyncApiImpl()
|
||||
NativeSyncApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
|
||||
registrar.publish(instance)
|
||||
}
|
||||
|
||||
func detachFromEngine(for registrar: any FlutterPluginRegistrar) {
|
||||
super.detachFromEngine()
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let changeTokenKey = "immich:changeToken"
|
||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||
private let recoveredAlbumSubType = 1000000219
|
||||
|
||||
private var hashTask: Task<Void, Error>?
|
||||
private var hashTask: Task<Void?, Error>?
|
||||
private static let hashCancelledCode = "HASH_CANCELLED"
|
||||
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
||||
|
||||
|
|
@ -272,7 +284,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
return completion(Self.hashCancelled)
|
||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||
}
|
||||
|
||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||
|
|
@ -280,7 +292,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||
results.reserveCapacity(assets.count)
|
||||
for asset in assets {
|
||||
if Task.isCancelled {
|
||||
return completion(Self.hashCancelled)
|
||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||
}
|
||||
taskGroup.addTask {
|
||||
guard let self = self else { return nil }
|
||||
|
|
@ -290,7 +302,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||
|
||||
for await result in taskGroup {
|
||||
guard let result = result else {
|
||||
return completion(Self.hashCancelled)
|
||||
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||
}
|
||||
results.append(result)
|
||||
}
|
||||
|
|
@ -299,7 +311,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||
}
|
||||
|
||||
completion(.success(results))
|
||||
return self?.completeWhenActive(for: completion, with: .success(results))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||
}
|
||||
}
|
||||
|
||||
// If we didnt add any memory images (some failure occured or no images in memory),
|
||||
// If we didn't add any memory images (some failure occurred or no images in memory),
|
||||
// default to 12 hours of random photos
|
||||
if entries.count == 0 {
|
||||
// this must be a do/catch since we need to
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ sealed class BaseAsset {
|
|||
|
||||
// Overridden in subclasses
|
||||
AssetState get storage;
|
||||
String? get localId;
|
||||
String? get remoteId;
|
||||
String get heroTag;
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ part of 'base_asset.model.dart';
|
|||
|
||||
class LocalAsset extends BaseAsset {
|
||||
final String id;
|
||||
final String? remoteId;
|
||||
final String? remoteAssetId;
|
||||
final int orientation;
|
||||
|
||||
const LocalAsset({
|
||||
required this.id,
|
||||
this.remoteId,
|
||||
String? remoteId,
|
||||
required super.name,
|
||||
super.checksum,
|
||||
required super.type,
|
||||
|
|
@ -19,7 +19,13 @@ class LocalAsset extends BaseAsset {
|
|||
super.isFavorite = false,
|
||||
super.livePhotoVideoId,
|
||||
this.orientation = 0,
|
||||
});
|
||||
}) : remoteAssetId = remoteId;
|
||||
|
||||
@override
|
||||
String? get localId => id;
|
||||
|
||||
@override
|
||||
String? get remoteId => remoteAssetId;
|
||||
|
||||
@override
|
||||
AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ enum AssetVisibility { timeline, hidden, archive, locked }
|
|||
// Model for an asset stored in the server
|
||||
class RemoteAsset extends BaseAsset {
|
||||
final String id;
|
||||
final String? localId;
|
||||
final String? localAssetId;
|
||||
final String? thumbHash;
|
||||
final AssetVisibility visibility;
|
||||
final String ownerId;
|
||||
|
|
@ -13,7 +13,7 @@ class RemoteAsset extends BaseAsset {
|
|||
|
||||
const RemoteAsset({
|
||||
required this.id,
|
||||
this.localId,
|
||||
String? localId,
|
||||
required super.name,
|
||||
required this.ownerId,
|
||||
required super.checksum,
|
||||
|
|
@ -28,7 +28,13 @@ class RemoteAsset extends BaseAsset {
|
|||
this.visibility = AssetVisibility.timeline,
|
||||
super.livePhotoVideoId,
|
||||
this.stackId,
|
||||
});
|
||||
}) : localAssetId = localId;
|
||||
|
||||
@override
|
||||
String? get localId => localAssetId;
|
||||
|
||||
@override
|
||||
String? get remoteId => id;
|
||||
|
||||
@override
|
||||
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||
_cancellationToken.cancel();
|
||||
_logger.info("Cleaning up background worker");
|
||||
final cleanupFutures = [
|
||||
nativeSyncApi?.cancelHashing(),
|
||||
workerManager.dispose().catchError((_) async {
|
||||
// Discard any errors on the dispose call
|
||||
return;
|
||||
|
|
@ -201,7 +202,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||
_drift.close(),
|
||||
_driftLogger.close(),
|
||||
backgroundSyncManager?.cancel(),
|
||||
nativeSyncApi?.cancelHashing(),
|
||||
];
|
||||
|
||||
if (_isar.isOpen) {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ class RemoteAlbumService {
|
|||
return _repository.getSharedUsers(albumId);
|
||||
}
|
||||
|
||||
Future<AlbumUserRole?> getUserRole(String albumId, String userId) {
|
||||
return _repository.getUserRole(albumId, userId);
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||
return _repository.getAssets(albumId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ class StoreService {
|
|||
_cache.remove(key.id);
|
||||
}
|
||||
|
||||
/// Clears all values from thw store (cache and DB)
|
||||
/// Clears all values from the store (cache and DB)
|
||||
Future<void> clear() async {
|
||||
await _storeRepository.deleteAll();
|
||||
_cache.clear();
|
||||
|
|
|
|||
|
|
@ -221,6 +221,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||
.get();
|
||||
}
|
||||
|
||||
Future<AlbumUserRole?> getUserRole(String albumId, String userId) async {
|
||||
final query = _db.remoteAlbumUserEntity.select()
|
||||
..where((row) => row.albumId.equals(albumId) & row.userId.equals(userId))
|
||||
..limit(1);
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result?.role;
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||
final query = _db.remoteAlbumAssetEntity.select().join([
|
||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||
|
|
|
|||
|
|
@ -169,9 +169,11 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
context.pushRoute(const DriftActivitiesRoute());
|
||||
}
|
||||
|
||||
void showOptionSheet(BuildContext context) {
|
||||
Future<void> showOptionSheet(BuildContext context) async {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||
final canAddPhotos =
|
||||
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
|
@ -193,22 +195,30 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
context.pop();
|
||||
}
|
||||
: null,
|
||||
onAddPhotos: () async {
|
||||
onAddPhotos: isOwner || canAddPhotos
|
||||
? () async {
|
||||
await addAssets(context);
|
||||
context.pop();
|
||||
},
|
||||
onToggleAlbumOrder: () async {
|
||||
}
|
||||
: null,
|
||||
onToggleAlbumOrder: isOwner
|
||||
? () async {
|
||||
await toggleAlbumOrder();
|
||||
context.pop();
|
||||
},
|
||||
onEditAlbum: () async {
|
||||
}
|
||||
: null,
|
||||
onEditAlbum: isOwner
|
||||
? () async {
|
||||
context.pop();
|
||||
await showEditTitleAndDescription(context);
|
||||
},
|
||||
onCreateSharedLink: () async {
|
||||
}
|
||||
: null,
|
||||
onCreateSharedLink: isOwner
|
||||
? () async {
|
||||
context.pop();
|
||||
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
|
||||
},
|
||||
}
|
||||
: null,
|
||||
onShowOptions: () {
|
||||
context.pop();
|
||||
context.pushRoute(const DriftAlbumOptionsRoute());
|
||||
|
|
@ -220,6 +230,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
|
|
@ -243,8 +256,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||
appBar: RemoteAlbumSliverAppBar(
|
||||
icon: Icons.photo_album_outlined,
|
||||
onShowOptions: () => showOptionSheet(context),
|
||||
onToggleAlbumOrder: () => toggleAlbumOrder(),
|
||||
onEditTitle: () => showEditTitleAndDescription(context),
|
||||
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
|
||||
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
|
||||
onActivity: () => showActivity(context),
|
||||
),
|
||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
/// This delete action has the following behavior:
|
||||
|
|
@ -22,7 +23,17 @@ class DeleteLocalActionButton extends ConsumerWidget {
|
|||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteLocal(source);
|
||||
bool? backedUpOnly = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
|
||||
);
|
||||
|
||||
if (backedUpOnly == null) {
|
||||
// User cancelled the dialog
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).deleteLocal(source, backedUpOnly);
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
if (source == ActionSource.viewer) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||
final actions = <Widget>[
|
||||
const ShareActionButton(source: ActionSource.viewer),
|
||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||
if (asset.type == AssetType.image && isOwner) const EditImageActionButton(),
|
||||
if (isOwner) ...[
|
||||
if (asset.hasRemote && isOwner && isArchived)
|
||||
const UnArchiveActionButton(source: ActionSource.viewer)
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
|
|
@ -147,10 +148,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||
_SheetTile(
|
||||
title: _getDateTime(context, asset),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : null,
|
||||
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
|
||||
const SheetPeopleDetails(),
|
||||
const SheetLocationDetails(),
|
||||
// Details header
|
||||
|
|
@ -265,8 +266,9 @@ class _SheetTile extends ConsumerWidget {
|
|||
|
||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||
final ExifInfo exif;
|
||||
final bool isEditable;
|
||||
|
||||
const _SheetAssetDescription({required this.exif});
|
||||
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
||||
|
||||
@override
|
||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||
|
|
@ -312,19 +314,24 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||
|
||||
// Update controller text when EXIF data changes
|
||||
final currentDescription = currentExifInfo?.description ?? '';
|
||||
final hintText = (widget.isEditable ? 'exif_bottom_sheet_description' : 'exif_bottom_sheet_no_description').t(
|
||||
context: context,
|
||||
);
|
||||
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||
_controller.text = currentDescription;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.isEditable,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.multiline,
|
||||
focusNode: _descriptionFocus,
|
||||
maxLines: null, // makes it grow as text is added
|
||||
decoration: InputDecoration(
|
||||
hintText: 'exif_bottom_sheet_description'.t(context: context),
|
||||
hintText: hintText,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
|
|
@ -334,6 +341,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||
),
|
||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
|
||||
previousRouteName != AssetViewerRoute.name &&
|
||||
previousRouteName != null &&
|
||||
previousRouteName != LocalTimelineRoute.name;
|
||||
previousRouteName != LocalTimelineRoute.name &&
|
||||
isOwner;
|
||||
|
||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
|||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||
|
|
@ -53,6 +54,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||
Widget build(BuildContext context) {
|
||||
final multiselect = ref.watch(multiSelectProvider);
|
||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||
final ownsAlbum = ref.watch(currentUserProvider)?.id == widget.album.ownerId;
|
||||
|
||||
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||
final selectedAssets = multiselect.selectedAssets;
|
||||
|
|
@ -93,9 +95,13 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||
const ShareActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasRemote) ...[
|
||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||
|
||||
if (ownsAlbum) ...[
|
||||
const ArchiveActionButton(source: ActionSource.timeline),
|
||||
const FavoriteActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
const DownloadActionButton(source: ActionSource.timeline),
|
||||
if (ownsAlbum) ...[
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
|
|
@ -105,16 +111,19 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
],
|
||||
if (multiselect.hasLocal) ...[
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
const UploadActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||
],
|
||||
slivers: [
|
||||
slivers: ownsAlbum
|
||||
? [
|
||||
const AddToAlbumHeader(),
|
||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||
],
|
||||
]
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,8 +260,15 @@ class ActionNotifier extends Notifier<void> {
|
|||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> deleteLocal(ActionSource source) async {
|
||||
final ids = _getLocalIdsForSource(source);
|
||||
Future<ActionResult> deleteLocal(ActionSource source, bool backedUpOnly) async {
|
||||
final List<String> ids;
|
||||
if (backedUpOnly) {
|
||||
final assets = _getAssets(source);
|
||||
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
|
||||
} else {
|
||||
ids = _getLocalIdsForSource(source);
|
||||
}
|
||||
|
||||
try {
|
||||
final deletedCount = await _service.deleteLocal(ids);
|
||||
return ActionResult(count: deletedCount, success: true);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
|
|||
|
||||
class AssetMediaRepository {
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
static final Logger _log = Logger("AssetMediaRepository");
|
||||
|
||||
const AssetMediaRepository(this._assetApiRepository);
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onDeleteBackedUpOnly() {
|
||||
context.pop();
|
||||
context.pop(true);
|
||||
onDeleteLocal(true);
|
||||
}
|
||||
|
||||
void onForceDelete() {
|
||||
context.pop();
|
||||
context.pop(false);
|
||||
onDeleteLocal(false);
|
||||
}
|
||||
|
||||
|
|
@ -36,26 +36,44 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
|
|||
title: const Text("delete_dialog_title").tr(),
|
||||
content: const Text("delete_dialog_alert_local_non_backed_up").tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"cancel",
|
||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.surfaceDim,
|
||||
foregroundColor: context.primaryColor,
|
||||
),
|
||||
TextButton(
|
||||
child: const Text("cancel", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
|
||||
child: FilledButton(
|
||||
onPressed: onDeleteBackedUpOnly,
|
||||
child: Text(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.errorContainer,
|
||||
foregroundColor: context.colorScheme.onErrorContainer,
|
||||
),
|
||||
child: const Text(
|
||||
"delete_local_dialog_ok_backed_up_only",
|
||||
style: TextStyle(color: context.colorScheme.tertiary, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: onForceDelete,
|
||||
child: Text(
|
||||
"delete_local_dialog_ok_force",
|
||||
style: TextStyle(color: Colors.red[400], fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.red[400], foregroundColor: Colors.white),
|
||||
child: const Text("delete_local_dialog_ok_force", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "0.0.1",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.18.0+sha512.e804f889f1cecc40d572db084eec3e4881739f8dec69c0ff10d2d1beff9a4e309383ba27b5b750059d7f4c149535b6cd0d2cb1ed3aeb739239a4284a68f40cfa",
|
||||
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
|
|
|||
880
pnpm-lock.yaml
generated
880
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -44,14 +44,14 @@
|
|||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.205.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.205.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.53.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.52.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.58.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.206.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.206.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.54.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.53.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.59.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.205.0",
|
||||
"@opentelemetry/sdk-node": "^0.206.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
"ioredis": "^5.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"kysely": "0.28.2",
|
||||
"kysely-postgres-js": "^2.0.0",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"mnemonist": "^0.40.3",
|
||||
|
|
|
|||
|
|
@ -89,10 +89,10 @@
|
|||
|
||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||
|
||||
let element: HTMLElement | undefined = $state();
|
||||
let scrollableElement: HTMLElement | undefined = $state();
|
||||
|
||||
let timelineElement: HTMLElement | undefined = $state();
|
||||
let showSkeleton = $state(true);
|
||||
let invisible = $state(true);
|
||||
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
|
||||
// Note: There may be multiple months visible within the viewport at any given time.
|
||||
let viewportTopMonthScrollPercent = $state(0);
|
||||
|
|
@ -124,29 +124,22 @@
|
|||
timelineManager.setLayoutOptions(layoutOptions);
|
||||
});
|
||||
|
||||
const scrollTo = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTo({ top });
|
||||
}
|
||||
};
|
||||
const scrollTop = (top: number) => {
|
||||
if (element) {
|
||||
element.scrollTop = top;
|
||||
}
|
||||
};
|
||||
$effect(() => {
|
||||
timelineManager.scrollableElement = scrollableElement;
|
||||
});
|
||||
|
||||
const scrollToTop = () => {
|
||||
scrollTo(0);
|
||||
timelineManager.scrollTo(0);
|
||||
};
|
||||
|
||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
||||
|
||||
const assetIsVisible = (assetTop: number): boolean => {
|
||||
if (!element) {
|
||||
if (!scrollableElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { clientHeight, scrollTop } = element;
|
||||
const { clientHeight, scrollTop } = scrollableElement;
|
||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
||||
};
|
||||
|
||||
|
|
@ -163,8 +156,7 @@
|
|||
return true;
|
||||
}
|
||||
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
timelineManager.scrollTo(height);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -174,8 +166,7 @@
|
|||
return false;
|
||||
}
|
||||
const height = getAssetHeight(asset.id, monthGroup);
|
||||
scrollTo(height);
|
||||
updateSlidingWindow();
|
||||
timelineManager.scrollTo(height);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
|
@ -189,7 +180,7 @@
|
|||
// if the asset is not found, scroll to the top
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
invisible = false;
|
||||
};
|
||||
|
||||
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||
|
|
@ -216,7 +207,7 @@
|
|||
} else {
|
||||
scrollToTop();
|
||||
}
|
||||
showSkeleton = false;
|
||||
invisible = false;
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
|
@ -230,13 +221,12 @@
|
|||
|
||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
||||
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
|
||||
|
||||
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||
|
||||
onMount(() => {
|
||||
if (!enableRouting) {
|
||||
showSkeleton = false;
|
||||
invisible = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -246,11 +236,13 @@
|
|||
};
|
||||
|
||||
const getMaxScroll = () => {
|
||||
if (!element || !timelineElement) {
|
||||
if (!scrollableElement || !timelineElement) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight)
|
||||
timelineManager.topSectionHeight +
|
||||
bottomSectionHeight +
|
||||
(timelineElement.clientHeight - scrollableElement.clientHeight)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -260,7 +252,7 @@
|
|||
const delta = monthGroup.height * monthGroupScrollPercent;
|
||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||
|
||||
scrollTop(scrollToTop);
|
||||
timelineManager.scrollTo(scrollToTop);
|
||||
};
|
||||
|
||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
||||
|
|
@ -272,7 +264,7 @@
|
|||
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
const offset = maxScroll * overallScrollPercent;
|
||||
scrollTop(offset);
|
||||
timelineManager.scrollTo(offset);
|
||||
} else {
|
||||
const monthGroup = timelineManager.months.find(
|
||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||
|
|
@ -288,26 +280,26 @@
|
|||
const handleTimelineScroll = () => {
|
||||
isInLeadOutSection = false;
|
||||
|
||||
if (!element) {
|
||||
if (!scrollableElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||
const maxScroll = getMaxScroll();
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
} else {
|
||||
let top = element.scrollTop;
|
||||
let top = scrollableElement.scrollTop;
|
||||
if (top < timelineManager.topSectionHeight) {
|
||||
// in the lead-in area
|
||||
viewportTopMonth = undefined;
|
||||
viewportTopMonthScrollPercent = 0;
|
||||
const maxScroll = getMaxScroll();
|
||||
|
||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
||||
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -414,7 +406,7 @@
|
|||
onSelect(asset);
|
||||
|
||||
if (singleSelect) {
|
||||
scrollTop(0);
|
||||
timelineManager.scrollTo(0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -564,10 +556,10 @@
|
|||
if (evt.key === 'ArrowUp') {
|
||||
amount = -amount;
|
||||
if (shiftKeyIsDown) {
|
||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
}
|
||||
} else if (evt.key === 'ArrowDown') {
|
||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -580,19 +572,19 @@
|
|||
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||
tabindex="-1"
|
||||
bind:clientHeight={timelineManager.viewportHeight}
|
||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
||||
bind:this={element}
|
||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
||||
bind:clientWidth={timelineManager.viewportWidth}
|
||||
bind:this={scrollableElement}
|
||||
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
||||
>
|
||||
<section
|
||||
bind:this={timelineElement}
|
||||
id="virtual-timeline"
|
||||
class:invisible={showSkeleton}
|
||||
class:invisible
|
||||
style:height={timelineManager.timelineHeight + 'px'}
|
||||
>
|
||||
<section
|
||||
use:resizeObserver={topSectionResizeObserver}
|
||||
class:invisible={showSkeleton}
|
||||
class:invisible
|
||||
style:position="absolute"
|
||||
style:left="0"
|
||||
style:right="0"
|
||||
|
|
@ -615,10 +607,7 @@
|
|||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||
style:width="100%"
|
||||
>
|
||||
<Skeleton
|
||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
||||
title={monthGroup.monthGroupTitle}
|
||||
/>
|
||||
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
|
||||
</div>
|
||||
{:else if display}
|
||||
<div
|
||||
|
|
@ -658,7 +647,7 @@
|
|||
|
||||
<Portal target="body">
|
||||
{#if $showAssetViewer}
|
||||
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
||||
<TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
||||
{/if}
|
||||
</Portal>
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
interface Props {
|
||||
timelineManager: TimelineManager;
|
||||
showSkeleton: boolean;
|
||||
invisible: boolean;
|
||||
withStacked?: boolean;
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto | null;
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
let {
|
||||
timelineManager,
|
||||
showSkeleton = $bindable(false),
|
||||
invisible = $bindable(false),
|
||||
removeAction,
|
||||
withStacked = false,
|
||||
isShared = false,
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
const handleClose = async (asset: { id: string }) => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
showSkeleton = true;
|
||||
invisible = true;
|
||||
$gridScrollTarget = { at: asset.id };
|
||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
|
|
|
|||
|
|
@ -2,24 +2,21 @@
|
|||
interface Props {
|
||||
height: number;
|
||||
title?: string;
|
||||
invisible?: boolean;
|
||||
}
|
||||
|
||||
let { height = 0, title }: Props = $props();
|
||||
let { height = 0, title, invisible = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-clip" style:height={height + 'px'}>
|
||||
<div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
|
||||
{#if title}
|
||||
<div
|
||||
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
||||
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
||||
style:width="calc(100% - 20px)"
|
||||
data-skeleton="true"
|
||||
></div>
|
||||
<div class="animate-pulse h-full w-full" data-skeleton="true"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -47,4 +44,7 @@
|
|||
0s linear 0.1s forwards delayedVisibility,
|
||||
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
.invisible [data-skeleton] {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { getTimeBucket } from '@immich/sdk';
|
|||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { TimelineManagerOptions } from '../types';
|
||||
import { layoutMonthGroup } from './layout-support.svelte';
|
||||
|
||||
export async function loadFromTimeBuckets(
|
||||
timelineManager: TimelineManager,
|
||||
|
|
@ -55,6 +54,4 @@ export async function loadFromTimeBuckets(
|
|||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
layoutMonthGroup(timelineManager, monthGroup);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export class MonthGroup {
|
|||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
percent: number = $state(0);
|
||||
|
||||
assetsCount: number = $derived(
|
||||
this.isLoaded
|
||||
|
|
@ -241,7 +242,6 @@ export class MonthGroup {
|
|||
if (this.#height === height) {
|
||||
return;
|
||||
}
|
||||
let needsIntersectionUpdate = false;
|
||||
const timelineManager = this.timelineManager;
|
||||
const index = timelineManager.months.indexOf(this);
|
||||
const heightDelta = height - this.#height;
|
||||
|
|
@ -261,11 +261,21 @@ export class MonthGroup {
|
|||
const newTop = monthGroup.#top + heightDelta;
|
||||
if (monthGroup.#top !== newTop) {
|
||||
monthGroup.#top = newTop;
|
||||
needsIntersectionUpdate = true;
|
||||
}
|
||||
}
|
||||
if (needsIntersectionUpdate) {
|
||||
timelineManager.updateIntersections();
|
||||
if (!timelineManager.viewportTopMonthIntersection) {
|
||||
return;
|
||||
}
|
||||
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
|
||||
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
|
||||
if (!month || currentIndex <= 0 || index > currentIndex) {
|
||||
return;
|
||||
}
|
||||
if (index < currentIndex || monthBottomViewportRatio < 1) {
|
||||
timelineManager.scrollBy(heightDelta);
|
||||
} else if (index === currentIndex) {
|
||||
const scrollTo = this.top + height * viewportTopRatioInMonth;
|
||||
timelineManager.scrollTo(scrollTo);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { AbortError } from '$lib/utils';
|
|||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { tick } from 'svelte';
|
||||
import { TimelineManager } from './timeline-manager.svelte';
|
||||
import type { TimelineAsset } from './types';
|
||||
|
||||
|
|
@ -64,11 +65,12 @@ describe('TimelineManager', () => {
|
|||
|
||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
await tick();
|
||||
});
|
||||
|
||||
it('should load months in viewport', () => {
|
||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3);
|
||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calculates month height', () => {
|
||||
|
|
@ -82,13 +84,13 @@ describe('TimelineManager', () => {
|
|||
expect.arrayContaining([
|
||||
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
||||
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
||||
expect.objectContaining({ year: 2024, month: 1, height: 48 }),
|
||||
expect.objectContaining({ year: 2024, month: 1, height: 286 }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates timeline height', () => {
|
||||
expect(timelineManager.timelineHeight).toBe(12_209.5);
|
||||
expect(timelineManager.timelineHeight).toBe(12_447.5);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
|||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||
|
||||
import { debounce, isEqual } from 'lodash-es';
|
||||
import { clamp, debounce, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
|
|
@ -37,6 +37,13 @@ import type {
|
|||
Viewport,
|
||||
} from './types';
|
||||
|
||||
type ViewportTopMonthIntersection = {
|
||||
month: MonthGroup | undefined;
|
||||
// Where viewport top intersects month (0 = month top, 1 = month bottom)
|
||||
viewportTopRatioInMonth: number;
|
||||
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
|
||||
monthBottomViewportRatio: number;
|
||||
};
|
||||
export class TimelineManager {
|
||||
isInitialized = $state(false);
|
||||
months: MonthGroup[] = $state([]);
|
||||
|
|
@ -49,6 +56,8 @@ export class TimelineManager {
|
|||
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||
scrubberTimelineHeight: number = $state(0);
|
||||
|
||||
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
||||
|
||||
visibleWindow = $derived.by(() => ({
|
||||
top: this.#scrollTop,
|
||||
bottom: this.#scrollTop + this.viewportHeight,
|
||||
|
|
@ -85,6 +94,8 @@ export class TimelineManager {
|
|||
#suspendTransitions = $state(false);
|
||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||
#updatingIntersections = false;
|
||||
#scrollableElement: HTMLElement | undefined = $state();
|
||||
|
||||
constructor() {}
|
||||
|
||||
|
|
@ -98,6 +109,20 @@ export class TimelineManager {
|
|||
}
|
||||
}
|
||||
|
||||
set scrollableElement(element: HTMLElement | undefined) {
|
||||
this.#scrollableElement = element;
|
||||
}
|
||||
|
||||
scrollTo(top: number) {
|
||||
this.#scrollableElement?.scrollTo({ top });
|
||||
this.updateSlidingWindow();
|
||||
}
|
||||
|
||||
scrollBy(y: number) {
|
||||
this.#scrollableElement?.scrollBy(0, y);
|
||||
this.updateSlidingWindow();
|
||||
}
|
||||
|
||||
#setHeaderHeight(value: number) {
|
||||
if (this.#headerHeight == value) {
|
||||
return false;
|
||||
|
|
@ -161,7 +186,8 @@ export class TimelineManager {
|
|||
const changed = value !== this.#viewportWidth;
|
||||
this.#viewportWidth = value;
|
||||
this.suspendTransitions = true;
|
||||
void this.#updateViewportGeometry(changed);
|
||||
this.#updateViewportGeometry(changed);
|
||||
this.updateSlidingWindow();
|
||||
}
|
||||
|
||||
get viewportWidth() {
|
||||
|
|
@ -223,20 +249,52 @@ export class TimelineManager {
|
|||
this.#websocketSupport = undefined;
|
||||
}
|
||||
|
||||
updateSlidingWindow(scrollTop: number) {
|
||||
updateSlidingWindow() {
|
||||
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
|
||||
if (this.#scrollTop !== scrollTop) {
|
||||
this.#scrollTop = scrollTop;
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) {
|
||||
if (!month) {
|
||||
return 0;
|
||||
}
|
||||
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
|
||||
const bottomOfMonth = month.top + month.height;
|
||||
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
|
||||
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
|
||||
}
|
||||
|
||||
#calculateVewportTopRatioInMonth(month: MonthGroup | undefined) {
|
||||
if (!month) {
|
||||
return 0;
|
||||
}
|
||||
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
|
||||
}
|
||||
|
||||
updateIntersections() {
|
||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||
return;
|
||||
}
|
||||
this.#updatingIntersections = true;
|
||||
|
||||
for (const month of this.months) {
|
||||
updateIntersectionMonthGroup(this, month);
|
||||
}
|
||||
|
||||
const month = this.months.find((month) => month.actuallyIntersecting);
|
||||
const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month);
|
||||
const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month);
|
||||
|
||||
this.viewportTopMonthIntersection = {
|
||||
month,
|
||||
monthBottomViewportRatio,
|
||||
viewportTopRatioInMonth,
|
||||
};
|
||||
|
||||
this.#updatingIntersections = false;
|
||||
}
|
||||
|
||||
clearDeferredLayout(month: MonthGroup) {
|
||||
|
|
@ -368,7 +426,8 @@ export class TimelineManager {
|
|||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||
}, cancelable);
|
||||
if (executionStatus === 'LOADED') {
|
||||
updateIntersectionMonthGroup(this, monthGroup);
|
||||
updateGeometry(this, monthGroup, { invalidateHeight: false });
|
||||
this.updateIntersections();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue