mirror of
https://github.com/immich-app/immich.git
synced 2025-12-15 01:00:44 -08:00
Compare commits
10 Commits
fix/docs-f
...
test/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5f31751a | ||
|
|
4d41fa08ad | ||
|
|
6d00930082 | ||
|
|
e4d2c4926c | ||
|
|
dbee133764 | ||
|
|
8473dab684 | ||
|
|
146973b072 | ||
|
|
e8ca7f235c | ||
|
|
d411594c84 | ||
|
|
cf52b879b1 |
10
.github/workflows/static_analysis.yml
vendored
10
.github/workflows/static_analysis.yml
vendored
@@ -41,10 +41,18 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
steps:
|
steps:
|
||||||
|
- name: Generate token
|
||||||
|
id: token
|
||||||
|
uses: immich-app/devtools/actions/create-workflow-token@feat/create-workflow-token-action
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||||
|
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup Flutter SDK
|
- name: Setup Flutter SDK
|
||||||
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
uses: subosito/flutter-action@fd55f4c5af5b953cc57a2be44cb082c8f6635e8e # v2.21.0
|
||||||
@@ -58,7 +66,7 @@ jobs:
|
|||||||
- name: Install DCM
|
- name: Install DCM
|
||||||
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
|
uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ steps.token.outputs.token }}
|
||||||
version: auto
|
version: auto
|
||||||
working-directory: ./mobile
|
working-directory: ./mobile
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
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_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
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_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
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:
|
environment:
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
POSTGRES_USER: ${DB_USERNAME}
|
POSTGRES_USER: ${DB_USERNAME}
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ services:
|
|||||||
- 2285:2285
|
- 2285:2285
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:2185e741f4c1e7b0ea9ca1e163a3767c4270a73086b6bbea2049a7203212fb7f
|
image: redis:6.2-alpine@sha256:77697a75da9f94e9357b61fcaf8345f69e3d9d32e9d15032c8415c21263977dc
|
||||||
|
|
||||||
database:
|
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
|
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|||||||
@@ -1039,6 +1039,7 @@
|
|||||||
"exif_bottom_sheet_description_error": "Error updating description",
|
"exif_bottom_sheet_description_error": "Error updating description",
|
||||||
"exif_bottom_sheet_details": "DETAILS",
|
"exif_bottom_sheet_details": "DETAILS",
|
||||||
"exif_bottom_sheet_location": "LOCATION",
|
"exif_bottom_sheet_location": "LOCATION",
|
||||||
|
"exif_bottom_sheet_no_description": "No description",
|
||||||
"exif_bottom_sheet_people": "PEOPLE",
|
"exif_bottom_sheet_people": "PEOPLE",
|
||||||
"exif_bottom_sheet_person_add_person": "Add name",
|
"exif_bottom_sheet_person_add_person": "Add name",
|
||||||
"exit_slideshow": "Exit Slideshow",
|
"exit_slideshow": "Exit Slideshow",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[tools]
|
[tools]
|
||||||
node = "22.20.0"
|
node = "22.20.0"
|
||||||
flutter = "3.35.5"
|
flutter = "3.35.5"
|
||||||
pnpm = "10.18.0"
|
pnpm = "10.18.1"
|
||||||
|
|
||||||
[tools."github:CQLabs/homebrew-dcm"]
|
[tools."github:CQLabs/homebrew-dcm"]
|
||||||
version = "1.30.0"
|
version = "1.30.0"
|
||||||
|
|||||||
@@ -131,10 +131,13 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -247,6 +250,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
@@ -331,6 +335,7 @@
|
|||||||
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
|
F0B57D482DF764BE00DC5BCC /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
|
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
@@ -521,10 +526,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -553,10 +562,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import UIKit
|
|||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
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.register(with: self.registrar(forPlugin: "BackgroundServicePlugin")!)
|
||||||
|
|
||||||
BackgroundServicePlugin.registerBackgroundProcessing()
|
BackgroundServicePlugin.registerBackgroundProcessing()
|
||||||
@@ -51,9 +51,13 @@ import UIKit
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
|
public static func registerPlugins(with engine: FlutterEngine) {
|
||||||
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
|
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
|
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
|
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
|
// Register plugins in the new engine
|
||||||
GeneratedPluginRegistrant.register(with: engine)
|
GeneratedPluginRegistrant.register(with: engine)
|
||||||
// Register custom plugins
|
// Register custom plugins
|
||||||
AppDelegate.registerPlugins(binaryMessenger: engine.binaryMessenger)
|
AppDelegate.registerPlugins(with: engine)
|
||||||
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
flutterApi = BackgroundWorkerFlutterApi(binaryMessenger: engine.binaryMessenger)
|
||||||
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
BackgroundWorkerBgHostApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: self)
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ class BackgroundWorker: BackgroundWorkerBgHostApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isComplete = true
|
isComplete = true
|
||||||
|
AppDelegate.cancelPlugins(with: engine)
|
||||||
engine.destroyContext()
|
engine.destroyContext()
|
||||||
flutterApi = nil
|
flutterApi = nil
|
||||||
completionHandler(success)
|
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 defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
private let recoveredAlbumSubType = 1000000219
|
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 hashCancelledCode = "HASH_CANCELLED"
|
||||||
private static let hashCancelled = Result<[HashResult], Error>.failure(PigeonError(code: hashCancelledCode, message: "Hashing cancelled", details: nil))
|
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 {
|
if Task.isCancelled {
|
||||||
return completion(Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
|
|
||||||
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
await withTaskGroup(of: HashResult?.self) { taskGroup in
|
||||||
@@ -280,7 +292,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
results.reserveCapacity(assets.count)
|
results.reserveCapacity(assets.count)
|
||||||
for asset in assets {
|
for asset in assets {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
return completion(Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
taskGroup.addTask {
|
taskGroup.addTask {
|
||||||
guard let self = self else { return nil }
|
guard let self = self else { return nil }
|
||||||
@@ -290,7 +302,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
|
|
||||||
for await result in taskGroup {
|
for await result in taskGroup {
|
||||||
guard let result = result else {
|
guard let result = result else {
|
||||||
return completion(Self.hashCancelled)
|
return self?.completeWhenActive(for: completion, with: Self.hashCancelled)
|
||||||
}
|
}
|
||||||
results.append(result)
|
results.append(result)
|
||||||
}
|
}
|
||||||
@@ -299,7 +311,7 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
results.append(HashResult(assetId: missing, error: "Asset not found in library", hash: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
completion(.success(results))
|
return self?.completeWhenActive(for: completion, with: .success(results))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ sealed class BaseAsset {
|
|||||||
|
|
||||||
// Overridden in subclasses
|
// Overridden in subclasses
|
||||||
AssetState get storage;
|
AssetState get storage;
|
||||||
|
String? get localId;
|
||||||
|
String? get remoteId;
|
||||||
String get heroTag;
|
String get heroTag;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ part of 'base_asset.model.dart';
|
|||||||
|
|
||||||
class LocalAsset extends BaseAsset {
|
class LocalAsset extends BaseAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String? remoteId;
|
final String? remoteAssetId;
|
||||||
final int orientation;
|
final int orientation;
|
||||||
|
|
||||||
const LocalAsset({
|
const LocalAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.remoteId,
|
String? remoteId,
|
||||||
required super.name,
|
required super.name,
|
||||||
super.checksum,
|
super.checksum,
|
||||||
required super.type,
|
required super.type,
|
||||||
@@ -19,7 +19,13 @@ class LocalAsset extends BaseAsset {
|
|||||||
super.isFavorite = false,
|
super.isFavorite = false,
|
||||||
super.livePhotoVideoId,
|
super.livePhotoVideoId,
|
||||||
this.orientation = 0,
|
this.orientation = 0,
|
||||||
});
|
}) : remoteAssetId = remoteId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get localId => id;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get remoteId => remoteAssetId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged;
|
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
|
// Model for an asset stored in the server
|
||||||
class RemoteAsset extends BaseAsset {
|
class RemoteAsset extends BaseAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String? localId;
|
final String? localAssetId;
|
||||||
final String? thumbHash;
|
final String? thumbHash;
|
||||||
final AssetVisibility visibility;
|
final AssetVisibility visibility;
|
||||||
final String ownerId;
|
final String ownerId;
|
||||||
@@ -13,7 +13,7 @@ class RemoteAsset extends BaseAsset {
|
|||||||
|
|
||||||
const RemoteAsset({
|
const RemoteAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
this.localId,
|
String? localId,
|
||||||
required super.name,
|
required super.name,
|
||||||
required this.ownerId,
|
required this.ownerId,
|
||||||
required super.checksum,
|
required super.checksum,
|
||||||
@@ -28,7 +28,13 @@ class RemoteAsset extends BaseAsset {
|
|||||||
this.visibility = AssetVisibility.timeline,
|
this.visibility = AssetVisibility.timeline,
|
||||||
super.livePhotoVideoId,
|
super.livePhotoVideoId,
|
||||||
this.stackId,
|
this.stackId,
|
||||||
});
|
}) : localAssetId = localId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get localId => localAssetId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get remoteId => id;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;
|
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_cancellationToken.cancel();
|
_cancellationToken.cancel();
|
||||||
_logger.info("Cleaning up background worker");
|
_logger.info("Cleaning up background worker");
|
||||||
final cleanupFutures = [
|
final cleanupFutures = [
|
||||||
|
nativeSyncApi?.cancelHashing(),
|
||||||
workerManager.dispose().catchError((_) async {
|
workerManager.dispose().catchError((_) async {
|
||||||
// Discard any errors on the dispose call
|
// Discard any errors on the dispose call
|
||||||
return;
|
return;
|
||||||
@@ -201,7 +202,6 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
|
|||||||
_drift.close(),
|
_drift.close(),
|
||||||
_driftLogger.close(),
|
_driftLogger.close(),
|
||||||
backgroundSyncManager?.cancel(),
|
backgroundSyncManager?.cancel(),
|
||||||
nativeSyncApi?.cancelHashing(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (_isar.isOpen) {
|
if (_isar.isOpen) {
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ class RemoteAlbumService {
|
|||||||
return _repository.getSharedUsers(albumId);
|
return _repository.getSharedUsers(albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<AlbumUserRole?> getUserRole(String albumId, String userId) {
|
||||||
|
return _repository.getUserRole(albumId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<RemoteAsset>> getAssets(String albumId) {
|
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||||
return _repository.getAssets(albumId);
|
return _repository.getAssets(albumId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
.get();
|
.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) {
|
Future<List<RemoteAsset>> getAssets(String albumId) {
|
||||||
final query = _db.remoteAlbumAssetEntity.select().join([
|
final query = _db.remoteAlbumAssetEntity.select().join([
|
||||||
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
|
||||||
|
|||||||
@@ -169,9 +169,11 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
context.pushRoute(const DriftActivitiesRoute());
|
context.pushRoute(const DriftActivitiesRoute());
|
||||||
}
|
}
|
||||||
|
|
||||||
void showOptionSheet(BuildContext context) {
|
Future<void> showOptionSheet(BuildContext context) async {
|
||||||
final user = ref.watch(currentUserProvider);
|
final user = ref.watch(currentUserProvider);
|
||||||
final isOwner = user != null ? user.id == _album.ownerId : false;
|
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||||
|
final canAddPhotos =
|
||||||
|
await ref.read(remoteAlbumServiceProvider).getUserRole(_album.id, user?.id ?? '') == AlbumUserRole.editor;
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -193,22 +195,30 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
context.pop();
|
context.pop();
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onAddPhotos: () async {
|
onAddPhotos: isOwner || canAddPhotos
|
||||||
|
? () async {
|
||||||
await addAssets(context);
|
await addAssets(context);
|
||||||
context.pop();
|
context.pop();
|
||||||
},
|
}
|
||||||
onToggleAlbumOrder: () async {
|
: null,
|
||||||
|
onToggleAlbumOrder: isOwner
|
||||||
|
? () async {
|
||||||
await toggleAlbumOrder();
|
await toggleAlbumOrder();
|
||||||
context.pop();
|
context.pop();
|
||||||
},
|
}
|
||||||
onEditAlbum: () async {
|
: null,
|
||||||
|
onEditAlbum: isOwner
|
||||||
|
? () async {
|
||||||
context.pop();
|
context.pop();
|
||||||
await showEditTitleAndDescription(context);
|
await showEditTitleAndDescription(context);
|
||||||
},
|
}
|
||||||
onCreateSharedLink: () async {
|
: null,
|
||||||
|
onCreateSharedLink: isOwner
|
||||||
|
? () async {
|
||||||
context.pop();
|
context.pop();
|
||||||
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
|
context.pushRoute(SharedLinkEditRoute(albumId: _album.id));
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
onShowOptions: () {
|
onShowOptions: () {
|
||||||
context.pop();
|
context.pop();
|
||||||
context.pushRoute(const DriftAlbumOptionsRoute());
|
context.pushRoute(const DriftAlbumOptionsRoute());
|
||||||
@@ -220,6 +230,9 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isOwner = user != null ? user.id == _album.ownerId : false;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
if (didPop) {
|
if (didPop) {
|
||||||
@@ -243,8 +256,8 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
|
|||||||
appBar: RemoteAlbumSliverAppBar(
|
appBar: RemoteAlbumSliverAppBar(
|
||||||
icon: Icons.photo_album_outlined,
|
icon: Icons.photo_album_outlined,
|
||||||
onShowOptions: () => showOptionSheet(context),
|
onShowOptions: () => showOptionSheet(context),
|
||||||
onToggleAlbumOrder: () => toggleAlbumOrder(),
|
onToggleAlbumOrder: isOwner ? () => toggleAlbumOrder() : null,
|
||||||
onEditTitle: () => showEditTitleAndDescription(context),
|
onEditTitle: isOwner ? () => showEditTitleAndDescription(context) : null,
|
||||||
onActivity: () => showActivity(context),
|
onActivity: () => showActivity(context),
|
||||||
),
|
),
|
||||||
bottomSheet: RemoteAlbumBottomSheet(album: _album),
|
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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.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';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
/// This delete action has the following behavior:
|
||||||
@@ -22,7 +23,17 @@ class DeleteLocalActionButton extends ConsumerWidget {
|
|||||||
return;
|
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();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
if (asset.isLocalOnly) const UploadActionButton(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 (isOwner) ...[
|
||||||
if (asset.hasRemote && isOwner && isArchived)
|
if (asset.hasRemote && isOwner && isArchived)
|
||||||
const UnArchiveActionButton(source: ActionSource.viewer)
|
const UnArchiveActionButton(source: ActionSource.viewer)
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
|
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
|
||||||
|
|
||||||
return SliverList.list(
|
return SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
@@ -147,10 +148,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
_SheetTile(
|
_SheetTile(
|
||||||
title: _getDateTime(context, asset),
|
title: _getDateTime(context, asset),
|
||||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
|
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
|
||||||
onTap: asset.hasRemote ? () async => await _editDateTime(context, ref) : 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 SheetPeopleDetails(),
|
||||||
const SheetLocationDetails(),
|
const SheetLocationDetails(),
|
||||||
// Details header
|
// Details header
|
||||||
@@ -265,8 +266,9 @@ class _SheetTile extends ConsumerWidget {
|
|||||||
|
|
||||||
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
class _SheetAssetDescription extends ConsumerStatefulWidget {
|
||||||
final ExifInfo exif;
|
final ExifInfo exif;
|
||||||
|
final bool isEditable;
|
||||||
|
|
||||||
const _SheetAssetDescription({required this.exif});
|
const _SheetAssetDescription({required this.exif, this.isEditable = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
|
||||||
@@ -312,19 +314,24 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
|
|
||||||
// Update controller text when EXIF data changes
|
// Update controller text when EXIF data changes
|
||||||
final currentDescription = currentExifInfo?.description ?? '';
|
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) {
|
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
|
||||||
_controller.text = currentDescription;
|
_controller.text = currentDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !widget.isEditable,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
focusNode: _descriptionFocus,
|
focusNode: _descriptionFocus,
|
||||||
maxLines: null, // makes it grow as text is added
|
maxLines: null, // makes it grow as text is added
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'exif_bottom_sheet_description'.t(context: context),
|
hintText: hintText,
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
enabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
focusedBorder: InputBorder.none,
|
focusedBorder: InputBorder.none,
|
||||||
@@ -334,6 +341,7 @@ class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription>
|
|||||||
),
|
),
|
||||||
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
onTapOutside: (_) => saveDescription(currentExifInfo?.description),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
|
(previousRouteName != TabShellRoute.name || tabRoute == TabEnum.search) &&
|
||||||
previousRouteName != AssetViewerRoute.name &&
|
previousRouteName != AssetViewerRoute.name &&
|
||||||
previousRouteName != null &&
|
previousRouteName != null &&
|
||||||
previousRouteName != LocalTimelineRoute.name;
|
previousRouteName != LocalTimelineRoute.name &&
|
||||||
|
isOwner;
|
||||||
|
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
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/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.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/timeline/multiselect.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
|
class RemoteAlbumBottomSheet extends ConsumerStatefulWidget {
|
||||||
@@ -53,6 +54,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final multiselect = ref.watch(multiSelectProvider);
|
final multiselect = ref.watch(multiSelectProvider);
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
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 {
|
Future<void> addAssetsToAlbum(RemoteAlbum album) async {
|
||||||
final selectedAssets = multiselect.selectedAssets;
|
final selectedAssets = multiselect.selectedAssets;
|
||||||
@@ -93,9 +95,13 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
|
|
||||||
|
if (ownsAlbum) ...[
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
const ArchiveActionButton(source: ActionSource.timeline),
|
||||||
const FavoriteActionButton(source: ActionSource.timeline),
|
const FavoriteActionButton(source: ActionSource.timeline),
|
||||||
|
],
|
||||||
const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
|
if (ownsAlbum) ...[
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(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.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasLocal) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
const UploadActionButton(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(),
|
const AddToAlbumHeader(),
|
||||||
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
AlbumSelector(onAlbumSelected: addAssetsToAlbum, onKeyboardExpanded: onKeyboardExpand),
|
||||||
],
|
]
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,8 +260,15 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ActionResult> deleteLocal(ActionSource source) async {
|
Future<ActionResult> deleteLocal(ActionSource source, bool backedUpOnly) async {
|
||||||
final ids = _getLocalIdsForSource(source);
|
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 {
|
try {
|
||||||
final deletedCount = await _service.deleteLocal(ids);
|
final deletedCount = await _service.deleteLocal(ids);
|
||||||
return ActionResult(count: deletedCount, success: true);
|
return ActionResult(count: deletedCount, success: true);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
|
|||||||
|
|
||||||
class AssetMediaRepository {
|
class AssetMediaRepository {
|
||||||
final AssetApiRepository _assetApiRepository;
|
final AssetApiRepository _assetApiRepository;
|
||||||
|
|
||||||
static final Logger _log = Logger("AssetMediaRepository");
|
static final Logger _log = Logger("AssetMediaRepository");
|
||||||
|
|
||||||
const AssetMediaRepository(this._assetApiRepository);
|
const AssetMediaRepository(this._assetApiRepository);
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
void onDeleteBackedUpOnly() {
|
void onDeleteBackedUpOnly() {
|
||||||
context.pop();
|
context.pop(true);
|
||||||
onDeleteLocal(true);
|
onDeleteLocal(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onForceDelete() {
|
void onForceDelete() {
|
||||||
context.pop();
|
context.pop(false);
|
||||||
onDeleteLocal(false);
|
onDeleteLocal(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,26 +36,44 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
|
|||||||
title: const Text("delete_dialog_title").tr(),
|
title: const Text("delete_dialog_title").tr(),
|
||||||
content: const Text("delete_dialog_alert_local_non_backed_up").tr(),
|
content: const Text("delete_dialog_alert_local_non_backed_up").tr(),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton(
|
||||||
onPressed: () => context.pop(),
|
onPressed: () => context.pop(),
|
||||||
child: Text(
|
style: FilledButton.styleFrom(
|
||||||
"cancel",
|
backgroundColor: context.colorScheme.surfaceDim,
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
|
foregroundColor: context.primaryColor,
|
||||||
).tr(),
|
|
||||||
),
|
),
|
||||||
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,
|
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",
|
"delete_local_dialog_ok_backed_up_only",
|
||||||
style: TextStyle(color: context.colorScheme.tertiary, fontWeight: FontWeight.bold),
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
TextButton(
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 48,
|
||||||
|
child: FilledButton(
|
||||||
onPressed: onForceDelete,
|
onPressed: onForceDelete,
|
||||||
child: Text(
|
style: FilledButton.styleFrom(backgroundColor: Colors.red[400], foregroundColor: Colors.white),
|
||||||
"delete_local_dialog_ok_force",
|
child: const Text("delete_local_dialog_ok_force", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
|
||||||
style: TextStyle(color: Colors.red[400], fontWeight: FontWeight.bold),
|
),
|
||||||
).tr(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Monorepo for Immich",
|
"description": "Monorepo for Immich",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.18.0+sha512.e804f889f1cecc40d572db084eec3e4881739f8dec69c0ff10d2d1beff9a4e309383ba27b5b750059d7f4c149535b6cd0d2cb1ed3aeb739239a4284a68f40cfa",
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
"engines": {
|
"engines": {
|
||||||
"pnpm": ">=10.0.0"
|
"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",
|
"@nestjs/websockets": "^11.0.4",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.205.0",
|
"@opentelemetry/exporter-prometheus": "^0.206.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.205.0",
|
"@opentelemetry/instrumentation-http": "^0.206.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.53.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.54.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.52.0",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.53.0",
|
||||||
"@opentelemetry/instrumentation-pg": "^0.58.0",
|
"@opentelemetry/instrumentation-pg": "^0.59.0",
|
||||||
"@opentelemetry/resources": "^2.0.1",
|
"@opentelemetry/resources": "^2.0.1",
|
||||||
"@opentelemetry/sdk-metrics": "^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",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@react-email/components": "^0.5.0",
|
"@react-email/components": "^0.5.0",
|
||||||
"@react-email/render": "^1.1.2",
|
"@react-email/render": "^1.1.2",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"kysely": "0.28.2",
|
"kysely": "0.28.2",
|
||||||
"kysely-postgres-js": "^2.0.0",
|
"kysely-postgres-js": "^3.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"luxon": "^3.4.2",
|
"luxon": "^3.4.2",
|
||||||
"mnemonist": "^0.40.3",
|
"mnemonist": "^0.40.3",
|
||||||
|
|||||||
@@ -89,10 +89,10 @@
|
|||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
let element: HTMLElement | undefined = $state();
|
let scrollableElement: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
let timelineElement: 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.
|
// 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.
|
// Note: There may be multiple months visible within the viewport at any given time.
|
||||||
let viewportTopMonthScrollPercent = $state(0);
|
let viewportTopMonthScrollPercent = $state(0);
|
||||||
@@ -124,29 +124,22 @@
|
|||||||
timelineManager.setLayoutOptions(layoutOptions);
|
timelineManager.setLayoutOptions(layoutOptions);
|
||||||
});
|
});
|
||||||
|
|
||||||
const scrollTo = (top: number) => {
|
$effect(() => {
|
||||||
if (element) {
|
timelineManager.scrollableElement = scrollableElement;
|
||||||
element.scrollTo({ top });
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
const scrollTop = (top: number) => {
|
|
||||||
if (element) {
|
|
||||||
element.scrollTop = top;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
scrollTo(0);
|
timelineManager.scrollTo(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
|
||||||
|
|
||||||
const assetIsVisible = (assetTop: number): boolean => {
|
const assetIsVisible = (assetTop: number): boolean => {
|
||||||
if (!element) {
|
if (!scrollableElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { clientHeight, scrollTop } = element;
|
const { clientHeight, scrollTop } = scrollableElement;
|
||||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,8 +156,7 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollTo(height);
|
timelineManager.scrollTo(height);
|
||||||
updateSlidingWindow();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,8 +166,7 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const height = getAssetHeight(asset.id, monthGroup);
|
const height = getAssetHeight(asset.id, monthGroup);
|
||||||
scrollTo(height);
|
timelineManager.scrollTo(height);
|
||||||
updateSlidingWindow();
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,7 +180,7 @@
|
|||||||
// if the asset is not found, scroll to the top
|
// if the asset is not found, scroll to the top
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
}
|
}
|
||||||
showSkeleton = false;
|
invisible = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
beforeNavigate(() => (timelineManager.suspendTransitions = true));
|
||||||
@@ -216,7 +207,7 @@
|
|||||||
} else {
|
} else {
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
}
|
}
|
||||||
showSkeleton = false;
|
invisible = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -230,13 +221,12 @@
|
|||||||
|
|
||||||
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
const updateIsScrolling = () => (timelineManager.scrolling = true);
|
||||||
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker
|
// 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);
|
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!enableRouting) {
|
if (!enableRouting) {
|
||||||
showSkeleton = false;
|
invisible = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,11 +236,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getMaxScroll = () => {
|
const getMaxScroll = () => {
|
||||||
if (!element || !timelineElement) {
|
if (!scrollableElement || !timelineElement) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
return (
|
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 delta = monthGroup.height * monthGroupScrollPercent;
|
||||||
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
const scrollToTop = (topOffset + delta) * maxScrollPercent;
|
||||||
|
|
||||||
scrollTop(scrollToTop);
|
timelineManager.scrollTo(scrollToTop);
|
||||||
};
|
};
|
||||||
|
|
||||||
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
|
// 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
|
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
const offset = maxScroll * overallScrollPercent;
|
const offset = maxScroll * overallScrollPercent;
|
||||||
scrollTop(offset);
|
timelineManager.scrollTo(offset);
|
||||||
} else {
|
} else {
|
||||||
const monthGroup = timelineManager.months.find(
|
const monthGroup = timelineManager.months.find(
|
||||||
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
|
||||||
@@ -288,26 +280,26 @@
|
|||||||
const handleTimelineScroll = () => {
|
const handleTimelineScroll = () => {
|
||||||
isInLeadOutSection = false;
|
isInLeadOutSection = false;
|
||||||
|
|
||||||
if (!element) {
|
if (!scrollableElement) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
|
||||||
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||||
|
|
||||||
viewportTopMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
} else {
|
} else {
|
||||||
let top = element.scrollTop;
|
let top = scrollableElement.scrollTop;
|
||||||
if (top < timelineManager.topSectionHeight) {
|
if (top < timelineManager.topSectionHeight) {
|
||||||
// in the lead-in area
|
// in the lead-in area
|
||||||
viewportTopMonth = undefined;
|
viewportTopMonth = undefined;
|
||||||
viewportTopMonthScrollPercent = 0;
|
viewportTopMonthScrollPercent = 0;
|
||||||
const maxScroll = getMaxScroll();
|
const maxScroll = getMaxScroll();
|
||||||
|
|
||||||
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll);
|
timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,7 +406,7 @@
|
|||||||
onSelect(asset);
|
onSelect(asset);
|
||||||
|
|
||||||
if (singleSelect) {
|
if (singleSelect) {
|
||||||
scrollTop(0);
|
timelineManager.scrollTo(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,10 +556,10 @@
|
|||||||
if (evt.key === 'ArrowUp') {
|
if (evt.key === 'ArrowUp') {
|
||||||
amount = -amount;
|
amount = -amount;
|
||||||
if (shiftKeyIsDown) {
|
if (shiftKeyIsDown) {
|
||||||
element?.scrollBy({ top: amount, behavior: 'smooth' });
|
scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
} else if (evt.key === 'ArrowDown') {
|
} 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'}
|
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
bind:clientHeight={timelineManager.viewportHeight}
|
||||||
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
|
bind:clientWidth={timelineManager.viewportWidth}
|
||||||
bind:this={element}
|
bind:this={scrollableElement}
|
||||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
bind:this={timelineElement}
|
bind:this={timelineElement}
|
||||||
id="virtual-timeline"
|
id="virtual-timeline"
|
||||||
class:invisible={showSkeleton}
|
class:invisible
|
||||||
style:height={timelineManager.timelineHeight + 'px'}
|
style:height={timelineManager.timelineHeight + 'px'}
|
||||||
>
|
>
|
||||||
<section
|
<section
|
||||||
use:resizeObserver={topSectionResizeObserver}
|
use:resizeObserver={topSectionResizeObserver}
|
||||||
class:invisible={showSkeleton}
|
class:invisible
|
||||||
style:position="absolute"
|
style:position="absolute"
|
||||||
style:left="0"
|
style:left="0"
|
||||||
style:right="0"
|
style:right="0"
|
||||||
@@ -615,10 +607,7 @@
|
|||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
||||||
style:width="100%"
|
style:width="100%"
|
||||||
>
|
>
|
||||||
<Skeleton
|
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
|
||||||
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
|
|
||||||
title={monthGroup.monthGroupTitle}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{:else if display}
|
{:else if display}
|
||||||
<div
|
<div
|
||||||
@@ -658,7 +647,7 @@
|
|||||||
|
|
||||||
<Portal target="body">
|
<Portal target="body">
|
||||||
{#if $showAssetViewer}
|
{#if $showAssetViewer}
|
||||||
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
<TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
|
||||||
{/if}
|
{/if}
|
||||||
</Portal>
|
</Portal>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
showSkeleton: boolean;
|
invisible: boolean;
|
||||||
withStacked?: boolean;
|
withStacked?: boolean;
|
||||||
isShared?: boolean;
|
isShared?: boolean;
|
||||||
album?: AlbumResponseDto | null;
|
album?: AlbumResponseDto | null;
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
timelineManager,
|
timelineManager,
|
||||||
showSkeleton = $bindable(false),
|
invisible = $bindable(false),
|
||||||
removeAction,
|
removeAction,
|
||||||
withStacked = false,
|
withStacked = false,
|
||||||
isShared = false,
|
isShared = false,
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
const handleClose = async (asset: { id: string }) => {
|
const handleClose = async (asset: { id: string }) => {
|
||||||
assetViewingStore.showAssetViewer(false);
|
assetViewingStore.showAssetViewer(false);
|
||||||
showSkeleton = true;
|
invisible = true;
|
||||||
$gridScrollTarget = { at: asset.id };
|
$gridScrollTarget = { at: asset.id };
|
||||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import type { Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
import { flip } from 'svelte/animate';
|
||||||
import { scale } from 'svelte/transition';
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,21 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
height: number;
|
height: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
invisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { height = 0, title }: Props = $props();
|
let { height = 0, title, invisible = false }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="overflow-clip" style:height={height + 'px'}>
|
<div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
|
||||||
{#if title}
|
{#if title}
|
||||||
<div
|
<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}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div class="animate-pulse h-full w-full" data-skeleton="true"></div>
|
||||||
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
|
||||||
style:width="calc(100% - 20px)"
|
|
||||||
data-skeleton="true"
|
|
||||||
></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -47,4 +44,7 @@
|
|||||||
0s linear 0.1s forwards delayedVisibility,
|
0s linear 0.1s forwards delayedVisibility,
|
||||||
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
.invisible [data-skeleton] {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { getTimeBucket } from '@immich/sdk';
|
|||||||
import type { MonthGroup } from '../month-group.svelte';
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
import type { TimelineManagerOptions } from '../types';
|
import type { TimelineManagerOptions } from '../types';
|
||||||
import { layoutMonthGroup } from './layout-support.svelte';
|
|
||||||
|
|
||||||
export async function loadFromTimeBuckets(
|
export async function loadFromTimeBuckets(
|
||||||
timelineManager: TimelineManager,
|
timelineManager: TimelineManager,
|
||||||
@@ -55,6 +54,4 @@ export async function loadFromTimeBuckets(
|
|||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
layoutMonthGroup(timelineManager, monthGroup);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class MonthGroup {
|
|||||||
|
|
||||||
#initialCount: number = 0;
|
#initialCount: number = 0;
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
|
percent: number = $state(0);
|
||||||
|
|
||||||
assetsCount: number = $derived(
|
assetsCount: number = $derived(
|
||||||
this.isLoaded
|
this.isLoaded
|
||||||
@@ -241,7 +242,6 @@ export class MonthGroup {
|
|||||||
if (this.#height === height) {
|
if (this.#height === height) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let needsIntersectionUpdate = false;
|
|
||||||
const timelineManager = this.timelineManager;
|
const timelineManager = this.timelineManager;
|
||||||
const index = timelineManager.months.indexOf(this);
|
const index = timelineManager.months.indexOf(this);
|
||||||
const heightDelta = height - this.#height;
|
const heightDelta = height - this.#height;
|
||||||
@@ -261,11 +261,21 @@ export class MonthGroup {
|
|||||||
const newTop = monthGroup.#top + heightDelta;
|
const newTop = monthGroup.#top + heightDelta;
|
||||||
if (monthGroup.#top !== newTop) {
|
if (monthGroup.#top !== newTop) {
|
||||||
monthGroup.#top = newTop;
|
monthGroup.#top = newTop;
|
||||||
needsIntersectionUpdate = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (needsIntersectionUpdate) {
|
if (!timelineManager.viewportTopMonthIntersection) {
|
||||||
timelineManager.updateIntersections();
|
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 { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { TimelineManager } from './timeline-manager.svelte';
|
import { TimelineManager } from './timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from './types';
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
@@ -64,11 +65,12 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
|
await tick();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load months in viewport', () => {
|
it('should load months in viewport', () => {
|
||||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3);
|
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates month height', () => {
|
it('calculates month height', () => {
|
||||||
@@ -82,13 +84,13 @@ describe('TimelineManager', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
|
||||||
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
|
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', () => {
|
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 { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
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 { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
@@ -37,6 +37,13 @@ import type {
|
|||||||
Viewport,
|
Viewport,
|
||||||
} from './types';
|
} 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 {
|
export class TimelineManager {
|
||||||
isInitialized = $state(false);
|
isInitialized = $state(false);
|
||||||
months: MonthGroup[] = $state([]);
|
months: MonthGroup[] = $state([]);
|
||||||
@@ -49,6 +56,8 @@ export class TimelineManager {
|
|||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
|
|
||||||
|
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
visibleWindow = $derived.by(() => ({
|
||||||
top: this.#scrollTop,
|
top: this.#scrollTop,
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
@@ -85,6 +94,8 @@ export class TimelineManager {
|
|||||||
#suspendTransitions = $state(false);
|
#suspendTransitions = $state(false);
|
||||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||||
|
#updatingIntersections = false;
|
||||||
|
#scrollableElement: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
constructor() {}
|
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) {
|
#setHeaderHeight(value: number) {
|
||||||
if (this.#headerHeight == value) {
|
if (this.#headerHeight == value) {
|
||||||
return false;
|
return false;
|
||||||
@@ -161,7 +186,8 @@ export class TimelineManager {
|
|||||||
const changed = value !== this.#viewportWidth;
|
const changed = value !== this.#viewportWidth;
|
||||||
this.#viewportWidth = value;
|
this.#viewportWidth = value;
|
||||||
this.suspendTransitions = true;
|
this.suspendTransitions = true;
|
||||||
void this.#updateViewportGeometry(changed);
|
this.#updateViewportGeometry(changed);
|
||||||
|
this.updateSlidingWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewportWidth() {
|
get viewportWidth() {
|
||||||
@@ -223,20 +249,52 @@ export class TimelineManager {
|
|||||||
this.#websocketSupport = undefined;
|
this.#websocketSupport = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlidingWindow(scrollTop: number) {
|
updateSlidingWindow() {
|
||||||
|
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
|
||||||
if (this.#scrollTop !== scrollTop) {
|
if (this.#scrollTop !== scrollTop) {
|
||||||
this.#scrollTop = scrollTop;
|
this.#scrollTop = scrollTop;
|
||||||
this.updateIntersections();
|
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() {
|
updateIntersections() {
|
||||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.#updatingIntersections = true;
|
||||||
|
|
||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
updateIntersectionMonthGroup(this, month);
|
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) {
|
clearDeferredLayout(month: MonthGroup) {
|
||||||
@@ -368,7 +426,8 @@ export class TimelineManager {
|
|||||||
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||||
}, cancelable);
|
}, cancelable);
|
||||||
if (executionStatus === 'LOADED') {
|
if (executionStatus === 'LOADED') {
|
||||||
updateIntersectionMonthGroup(this, monthGroup);
|
updateGeometry(this, monthGroup, { invalidateHeight: false });
|
||||||
|
this.updateIntersections();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user