mirror of
https://github.com/immich-app/immich.git
synced 2026-06-03 20:55:25 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 319b468519 |
@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
||||
|
||||
## Video formats
|
||||
|
||||
| Format | Extension(s) | Supported? | Notes |
|
||||
| :---------- | :-------------------------- | :----------------: | :---- |
|
||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||
| `AVI` | `.avi` | :white_check_mark: | |
|
||||
| `FLV` | `.flv` | :white_check_mark: | |
|
||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
| Format | Extension(s) | Supported? | Notes |
|
||||
| :---------- | :-------------------- | :----------------: | :---- |
|
||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||
| `AVI` | `.avi` | :white_check_mark: | |
|
||||
| `FLV` | `.flv` | :white_check_mark: | |
|
||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
|
||||
@@ -173,7 +173,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
@@ -31,10 +30,6 @@ test.describe('search gallery-viewer', () => {
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
@@ -49,10 +44,7 @@ test.describe('search gallery-viewer', () => {
|
||||
|
||||
await context.route('**/api/search/metadata', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const searchAssets = assets
|
||||
.slice(0, 5)
|
||||
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
||||
.map((asset) => toAssetResponseDto(asset));
|
||||
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -16,7 +16,6 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
|
||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
@@ -103,7 +102,6 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
|
||||
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
@@ -139,23 +137,20 @@
|
||||
);
|
||||
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
|
||||
};
|
||||
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Mutex.swift,
|
||||
);
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -167,16 +162,10 @@
|
||||
path = WidgetExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FE1BB4562F8319560087DBF9 /* Utility */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */,
|
||||
);
|
||||
path = Utility;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -284,7 +273,6 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE1BB4562F8319560087DBF9 /* Utility */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
@@ -339,7 +327,6 @@
|
||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */,
|
||||
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
|
||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
|
||||
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
|
||||
@@ -571,14 +558,10 @@
|
||||
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";
|
||||
@@ -607,14 +590,10 @@
|
||||
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";
|
||||
@@ -629,7 +608,6 @@
|
||||
files = (
|
||||
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
|
||||
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum ImageProcessing {
|
||||
static let queue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "thumbnail.processing"
|
||||
q.qualityOfService = .userInitiated
|
||||
q.maxConcurrentOperationCount = ProcessInfo.processInfo.activeProcessorCount * 2
|
||||
return q
|
||||
}()
|
||||
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
|
||||
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
|
||||
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct RequestRegistry<T: AnyObject & Sendable>: ~Copyable, Sendable {
|
||||
private let requests = Mutex<[Int64: T]>([:])
|
||||
|
||||
func add(requestId: Int64, request: T) {
|
||||
requests.withLock { $0[requestId] = request }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(requestId: Int64) -> T? {
|
||||
requests.withLock { $0.removeValue(forKey: requestId) }
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,13 @@ import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class LocalImageRequest {
|
||||
weak var operation: Operation?
|
||||
weak var workItem: DispatchWorkItem?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64]?, any Error>) -> Void
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
operation?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
class LocalImageApiImpl: LocalImageApi {
|
||||
@@ -36,7 +31,9 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
return requestOptions
|
||||
}()
|
||||
|
||||
private static let registry = RequestRegistry<LocalImageRequest>()
|
||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||
|
||||
private static var rgbaFormat = vImage_CGImageFormat(
|
||||
bitsPerComponent: 8,
|
||||
@@ -45,6 +42,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||
renderingIntent: .defaultIntent
|
||||
)!
|
||||
private static var requests = [Int64: LocalImageRequest]()
|
||||
private static let assetCache = {
|
||||
let assetCache = NSCache<NSString, PHAsset>()
|
||||
assetCache.countLimit = 10000
|
||||
@@ -52,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
}()
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
ImageProcessing.queue.addOperation {
|
||||
ImageProcessing.queue.async {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
@@ -68,14 +66,23 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
let request = LocalImageRequest(callback: completion)
|
||||
let operation = BlockOperation {
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
ImageProcessing.semaphore.wait()
|
||||
defer {
|
||||
ImageProcessing.semaphore.signal()
|
||||
}
|
||||
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.registry.remove(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
@@ -100,11 +107,12 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let data = imageData else {
|
||||
Self.registry.remove(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
@@ -114,6 +122,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
if request.isCancelled {
|
||||
free(pointer)
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
@@ -121,7 +130,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"length": Int64(length),
|
||||
]))
|
||||
Self.registry.remove(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -142,7 +151,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.registry.remove(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
@@ -162,32 +171,51 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||
"width": Int64(buffer.width),
|
||||
"height": Int64(buffer.height),
|
||||
"rowBytes": Int64(buffer.rowBytes),
|
||||
"rowBytes": Int64(buffer.rowBytes)
|
||||
]))
|
||||
Self.registry.remove(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
} catch {
|
||||
Self.registry.remove(requestId: requestId)
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
|
||||
}
|
||||
}
|
||||
|
||||
request.operation = operation
|
||||
Self.registry.add(requestId: requestId, request: request)
|
||||
ImageProcessing.queue.addOperation(operation)
|
||||
request.workItem = item
|
||||
Self.add(requestId: requestId, request: request)
|
||||
ImageProcessing.queue.async(execute: item)
|
||||
}
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.registry.remove(requestId: requestId)?.cancel()
|
||||
Self.cancel(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func remove(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancel(requestId: Int64) -> Void {
|
||||
requestQueue.async {
|
||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||
request.isCancelled = true
|
||||
guard let item = request.workItem else { return }
|
||||
if item.isCancelled {
|
||||
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||
if let cached = assetCache.object(forKey: assetId as NSString) {
|
||||
return cached
|
||||
}
|
||||
var asset: PHAsset?
|
||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||
if asset != nil { return asset }
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||
else { return nil }
|
||||
assetCache.setObject(asset, forKey: assetId as NSString)
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
return asset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,11 @@ class RemoteImageRequest {
|
||||
self.task = task
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
task?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
private static let registry = RequestRegistry<RemoteImageRequest>()
|
||||
private static var lock = os_unfair_lock()
|
||||
private static var requests = [Int64: RemoteImageRequest]()
|
||||
private static var rgbaFormat = vImage_CGImageFormat(
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
@@ -47,15 +43,20 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
|
||||
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
||||
|
||||
Self.registry.add(requestId: requestId, request: request)
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
Self.requests[requestId] = request
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
||||
guard let request = registry.remove(requestId: requestId) else {
|
||||
return
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
guard let request = requests[requestId] else {
|
||||
return os_unfair_lock_unlock(&Self.lock)
|
||||
}
|
||||
requests[requestId] = nil
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
if let error = error {
|
||||
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
||||
@@ -72,7 +73,10 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||
}
|
||||
|
||||
ImageProcessing.queue.addOperation {
|
||||
ImageProcessing.queue.async {
|
||||
ImageProcessing.semaphore.wait()
|
||||
defer { ImageProcessing.semaphore.signal() }
|
||||
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
@@ -126,7 +130,13 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
}
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.registry.remove(requestId: requestId)?.cancel()
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
let request = Self.requests[requestId]
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
guard let request = request else { return }
|
||||
request.isCancelled = true
|
||||
request.task?.cancel()
|
||||
}
|
||||
|
||||
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import Darwin
|
||||
|
||||
// Can be replaced with std Mutex when the deployment target is iOS 18+
|
||||
struct Mutex<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
|
||||
struct _Buffer: ~Copyable {
|
||||
var lock: os_unfair_lock = .init()
|
||||
var value: Value
|
||||
|
||||
init(value: consuming Value) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
deinit {}
|
||||
}
|
||||
|
||||
let _buffer: UnsafeMutablePointer<_Buffer>
|
||||
|
||||
init(_ initialValue: consuming sending Value) {
|
||||
_buffer = .allocate(capacity: 1)
|
||||
_buffer.initialize(to: _Buffer(value: initialValue))
|
||||
}
|
||||
|
||||
deinit {
|
||||
_buffer.deinitialize(count: 1)
|
||||
_buffer.deallocate()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
borrowing func withLock<Result: ~Copyable, E: Error>(
|
||||
_ body: (inout sending Value) throws(E) -> sending Result
|
||||
) throws(E) -> sending Result {
|
||||
os_unfair_lock_lock(&_buffer.pointee.lock)
|
||||
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
|
||||
return try body(&_buffer.pointee.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+
|
||||
typealias UnfairLock = Mutex<Void>
|
||||
|
||||
extension Mutex where Value == Void {
|
||||
init() {
|
||||
self.init(())
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
borrowing func withLock<Result: ~Copyable, E: Error>(
|
||||
_ body: () throws(E) -> sending Result
|
||||
) throws(E) -> sending Result {
|
||||
os_unfair_lock_lock(&_buffer.pointee.lock)
|
||||
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ class SearchApiRepository extends ApiRepository {
|
||||
type = AssetTypeEnum.VIDEO;
|
||||
}
|
||||
|
||||
final dateRange = filter.date.asDateTimeRange();
|
||||
|
||||
if ((filter.context != null && filter.context!.isNotEmpty) ||
|
||||
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
|
||||
return _api.searchSmart(
|
||||
@@ -28,14 +30,14 @@ class SearchApiRepository extends ApiRepository {
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
takenAfter: dateRange?.start,
|
||||
takenBefore: dateRange?.end,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||
rating: filter.rating.rating,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
tagIds: filter.tagIds,
|
||||
tagIds: filter.tags.map((t) => t.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 100,
|
||||
@@ -53,14 +55,14 @@ class SearchApiRepository extends ApiRepository {
|
||||
city: filter.location.city,
|
||||
make: filter.camera.make,
|
||||
model: filter.camera.model,
|
||||
takenAfter: filter.date.takenAfter,
|
||||
takenBefore: filter.date.takenBefore,
|
||||
takenAfter: dateRange?.start,
|
||||
takenBefore: dateRange?.end,
|
||||
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
|
||||
rating: filter.rating.rating,
|
||||
isFavorite: filter.display.isFavorite ? true : null,
|
||||
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
|
||||
personIds: filter.people.map((e) => e.id).toList(),
|
||||
tagIds: filter.tagIds,
|
||||
tagIds: filter.tags.map((t) => t.id).toList(),
|
||||
type: type,
|
||||
page: page,
|
||||
size: 1000,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
sealed class DateFilterInputModel {
|
||||
const DateFilterInputModel();
|
||||
bool get isEmpty => asDateTimeRange() == null;
|
||||
DateTimeRange<DateTime>? asDateTimeRange();
|
||||
|
||||
String asHumanReadable(BuildContext context) {
|
||||
final date = asDateTimeRange();
|
||||
if (date == null) return '';
|
||||
if (date.end.difference(date.start).inHours < 24) {
|
||||
return DateFormat.yMMMd().format(date.start.toLocal());
|
||||
} else {
|
||||
return 'search_filter_date_interval'.t(
|
||||
context: context,
|
||||
args: {
|
||||
"start": DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
"end": DateFormat.yMMMd().format(date.end.toLocal()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RecentMonthRangeFilter extends DateFilterInputModel {
|
||||
final int monthDelta;
|
||||
|
||||
const RecentMonthRangeFilter(this.monthDelta);
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
final now = DateTime.now();
|
||||
// Note that DateTime's constructor properly handles month overflow.
|
||||
final from = DateTime(now.year, now.month - monthDelta, 1);
|
||||
return DateTimeRange<DateTime>(start: from, end: now);
|
||||
}
|
||||
|
||||
@override
|
||||
String asHumanReadable(BuildContext context) {
|
||||
return 'last_months'.t(context: context, args: {"count": monthDelta.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
class YearFilter extends DateFilterInputModel {
|
||||
final int year;
|
||||
const YearFilter(this.year);
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
final now = DateTime.now();
|
||||
final from = DateTime(year, 1, 1);
|
||||
|
||||
if (now.year == year) {
|
||||
// To not go beyond today if the user picks the current year
|
||||
return DateTimeRange<DateTime>(start: from, end: now);
|
||||
}
|
||||
|
||||
final to = DateTime(year, 12, 31, 23, 59, 59);
|
||||
return DateTimeRange<DateTime>(start: from, end: to);
|
||||
}
|
||||
|
||||
@override
|
||||
String asHumanReadable(BuildContext context) {
|
||||
return 'in_year'.tr(namedArgs: {"year": year.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyDateFilter extends DateFilterInputModel {
|
||||
const EmptyDateFilter();
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime>? asDateTimeRange() => null;
|
||||
}
|
||||
|
||||
class CustomDateFilter extends DateFilterInputModel {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
const CustomDateFilter._(this.start, this.end);
|
||||
|
||||
factory CustomDateFilter.fromRange(DateTimeRange<DateTime> range) {
|
||||
return CustomDateFilter._(range.start, range.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)));
|
||||
}
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
return DateTimeRange<DateTime>(start: start, end: end);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/domain/models/tag.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
|
||||
class SearchLocationFilter {
|
||||
String? country;
|
||||
@@ -214,11 +216,11 @@ class SearchFilter {
|
||||
String? ocr;
|
||||
String? language;
|
||||
String? assetId;
|
||||
List<String>? tagIds;
|
||||
List<Tag> tags;
|
||||
Set<PersonDto> people;
|
||||
SearchLocationFilter location;
|
||||
SearchCameraFilter camera;
|
||||
SearchDateFilter date;
|
||||
DateFilterInputModel date;
|
||||
SearchRatingFilter rating;
|
||||
SearchDisplayFilters display;
|
||||
|
||||
@@ -232,7 +234,7 @@ class SearchFilter {
|
||||
this.ocr,
|
||||
this.language,
|
||||
this.assetId,
|
||||
this.tagIds,
|
||||
this.tags = const [],
|
||||
required this.people,
|
||||
required this.location,
|
||||
required this.camera,
|
||||
@@ -248,15 +250,14 @@ class SearchFilter {
|
||||
(description == null || (description!.isEmpty)) &&
|
||||
(assetId == null || (assetId!.isEmpty)) &&
|
||||
(ocr == null || (ocr!.isEmpty)) &&
|
||||
(tagIds ?? []).isEmpty &&
|
||||
tags.isEmpty &&
|
||||
people.isEmpty &&
|
||||
location.country == null &&
|
||||
location.state == null &&
|
||||
location.city == null &&
|
||||
camera.make == null &&
|
||||
camera.model == null &&
|
||||
date.takenBefore == null &&
|
||||
date.takenAfter == null &&
|
||||
date.isEmpty &&
|
||||
display.isNotInAlbum == false &&
|
||||
display.isArchive == false &&
|
||||
display.isFavorite == false &&
|
||||
@@ -272,10 +273,10 @@ class SearchFilter {
|
||||
String? ocr,
|
||||
String? assetId,
|
||||
Set<PersonDto>? people,
|
||||
List<String>? tagIds,
|
||||
List<Tag>? tags,
|
||||
SearchLocationFilter? location,
|
||||
SearchCameraFilter? camera,
|
||||
SearchDateFilter? date,
|
||||
DateFilterInputModel? date,
|
||||
SearchDisplayFilters? display,
|
||||
SearchRatingFilter? rating,
|
||||
AssetType? mediaType,
|
||||
@@ -294,13 +295,13 @@ class SearchFilter {
|
||||
display: display ?? this.display,
|
||||
rating: rating ?? this.rating,
|
||||
mediaType: mediaType ?? this.mediaType,
|
||||
tagIds: tagIds ?? this.tagIds,
|
||||
tags: tags ?? this.tags,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tags: $tags, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -314,7 +315,7 @@ class SearchFilter {
|
||||
other.ocr == ocr &&
|
||||
other.assetId == assetId &&
|
||||
other.people == people &&
|
||||
other.tagIds == tagIds &&
|
||||
other.tags == tags &&
|
||||
other.location == location &&
|
||||
other.camera == camera &&
|
||||
other.date == date &&
|
||||
@@ -332,7 +333,7 @@ class SearchFilter {
|
||||
ocr.hashCode ^
|
||||
assetId.hashCode ^
|
||||
people.hashCode ^
|
||||
tagIds.hashCode ^
|
||||
tags.hashCode ^
|
||||
location.hashCode ^
|
||||
camera.hashCode ^
|
||||
date.hashCode ^
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
@@ -111,7 +112,7 @@ class PlaceTile extends StatelessWidget {
|
||||
people: {},
|
||||
location: SearchLocationFilter(city: name),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
date: const EmptyDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/person.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
@@ -40,7 +41,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
people: prefilter?.people ?? {},
|
||||
location: prefilter?.location ?? SearchLocationFilter(),
|
||||
camera: prefilter?.camera ?? SearchCameraFilter(),
|
||||
date: prefilter?.date ?? SearchDateFilter(),
|
||||
date: prefilter?.date ?? const EmptyDateFilter(),
|
||||
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
mediaType: prefilter?.mediaType ?? AssetType.other,
|
||||
rating: prefilter?.rating ?? SearchRatingFilter(),
|
||||
@@ -242,15 +243,17 @@ class SearchPage extends HookConsumerWidget {
|
||||
final firstDate = DateTime(1900);
|
||||
final lastDate = DateTime.now();
|
||||
|
||||
final stored = filter.value.date.asDateTimeRange();
|
||||
final dateRange = stored != null
|
||||
? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end))
|
||||
: DateTimeRange(start: lastDate, end: lastDate);
|
||||
|
||||
final date = await showDateRangePicker(
|
||||
context: context,
|
||||
firstDate: firstDate,
|
||||
lastDate: lastDate,
|
||||
currentDate: DateTime.now(),
|
||||
initialDateRange: DateTimeRange(
|
||||
start: filter.value.date.takenAfter ?? lastDate,
|
||||
end: filter.value.date.takenBefore ?? lastDate,
|
||||
),
|
||||
initialDateRange: dateRange,
|
||||
helpText: 'search_filter_date_title'.tr(),
|
||||
cancelText: 'cancel'.tr(),
|
||||
confirmText: 'select'.tr(),
|
||||
@@ -264,7 +267,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
if (date == null) {
|
||||
filter.value = filter.value.copyWith(date: SearchDateFilter());
|
||||
filter.value = filter.value.copyWith(date: const EmptyDateFilter());
|
||||
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
unawaited(search());
|
||||
@@ -272,10 +275,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
filter.value = filter.value.copyWith(
|
||||
date: SearchDateFilter(
|
||||
takenAfter: date.start,
|
||||
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
|
||||
),
|
||||
date: CustomDateFilter.fromRange(date),
|
||||
);
|
||||
|
||||
// If date range is less than 24 hours, set the end date to the end of the day
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -57,26 +58,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
date: const EmptyDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
language: "${context.locale.languageCode}-${context.locale.countryCode}",
|
||||
tagIds: [],
|
||||
),
|
||||
);
|
||||
|
||||
final dateInputFilter = useState<DateFilterInputModel?>(null);
|
||||
|
||||
final peopleCurrentFilterWidget = useState<Widget?>(null);
|
||||
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final cameraCurrentFilterWidget = useState<Widget?>(null);
|
||||
final locationCurrentFilterWidget = useState<Widget?>(null);
|
||||
final tagCurrentFilterWidget = useState<Widget?>(null);
|
||||
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
|
||||
final ratingCurrentFilterWidget = useState<Widget?>(null);
|
||||
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
|
||||
|
||||
final userPreferences = ref.watch(userMetadataPreferencesProvider);
|
||||
|
||||
search(SearchFilter f) {
|
||||
@@ -107,14 +96,60 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
Future.microtask(() {
|
||||
textSearchController.clear();
|
||||
search(preFilter);
|
||||
if (preFilter.location.city != null) {
|
||||
locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}, [preFilter]);
|
||||
|
||||
Widget? chipLabel(String text) => text.isEmpty ? null : Text(text, style: context.textTheme.labelLarge);
|
||||
|
||||
Widget? peopleChip() {
|
||||
final label = filter.value.people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
|
||||
return chipLabel(label);
|
||||
}
|
||||
|
||||
Widget? locationChip() {
|
||||
final l = filter.value.location;
|
||||
final parts = [if (l.country != null) l.country!, if (l.state != null) l.state!, if (l.city != null) l.city!];
|
||||
return chipLabel(parts.join(', '));
|
||||
}
|
||||
|
||||
Widget? tagChip() {
|
||||
final label = filter.value.tags.map((t) => t.value).join(', ');
|
||||
return chipLabel(label);
|
||||
}
|
||||
|
||||
Widget? cameraChip() {
|
||||
final c = filter.value.camera;
|
||||
return chipLabel('${c.make ?? ''} ${c.model ?? ''}'.trim());
|
||||
}
|
||||
|
||||
Widget? dateChip() {
|
||||
final d = filter.value.date;
|
||||
return d.isEmpty ? null : chipLabel(d.asHumanReadable(context));
|
||||
}
|
||||
|
||||
Widget? mediaTypeChip() {
|
||||
final mt = filter.value.mediaType;
|
||||
if (mt == AssetType.other) return null;
|
||||
return chipLabel(mt == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context));
|
||||
}
|
||||
|
||||
Widget? ratingChip() {
|
||||
final r = filter.value.rating.rating;
|
||||
return r == null ? null : chipLabel('rating_count'.t(args: {'count': r}));
|
||||
}
|
||||
|
||||
Widget? displayChip() {
|
||||
final d = filter.value.display;
|
||||
final parts = [
|
||||
if (d.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context),
|
||||
if (d.isArchive) 'archive'.t(context: context),
|
||||
if (d.isFavorite) 'favorite'.t(context: context),
|
||||
];
|
||||
return chipLabel(parts.join(', '));
|
||||
}
|
||||
|
||||
showPeoplePicker() {
|
||||
var people = filter.value.people;
|
||||
|
||||
@@ -122,17 +157,6 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
people = value;
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
peopleCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(people: {}));
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
final label = people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
|
||||
peopleCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null;
|
||||
search(filter.value.copyWith(people: people));
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -141,8 +165,8 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_people_title'.t(context: context),
|
||||
expanded: true,
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
onSearch: () => search(filter.value.copyWith(people: people)),
|
||||
onClear: () => search(filter.value.copyWith(people: {})),
|
||||
child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people),
|
||||
),
|
||||
),
|
||||
@@ -150,22 +174,10 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
showTagPicker() {
|
||||
var tagIds = filter.value.tagIds ?? [];
|
||||
String tagLabel = '';
|
||||
var tags = filter.value.tags;
|
||||
|
||||
handleOnSelect(Iterable<Tag> tags) {
|
||||
tagIds = tags.map((t) => t.id).toList();
|
||||
tagLabel = tags.map((t) => t.value).join(', ');
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
tagCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(tagIds: []));
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null;
|
||||
search(filter.value.copyWith(tagIds: tagIds));
|
||||
handleOnSelect(Iterable<Tag> selected) {
|
||||
tags = selected.toList();
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
@@ -176,9 +188,9 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_tags_title'.t(context: context),
|
||||
expanded: true,
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
|
||||
onSearch: () => search(filter.value.copyWith(tags: tags)),
|
||||
onClear: () => search(filter.value.copyWith(tags: [])),
|
||||
child: TagPicker(onSelect: handleOnSelect, filter: filter.value.tags.map((t) => t.id).toSet()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -191,31 +203,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
location = SearchLocationFilter(country: value['country'], city: value['city'], state: value['state']);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
locationCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(location: SearchLocationFilter()));
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
final locationText = [
|
||||
if (location.country != null) location.country!,
|
||||
if (location.state != null) location.state!,
|
||||
if (location.city != null) location.city!,
|
||||
];
|
||||
locationCurrentFilterWidget.value = locationText.isNotEmpty
|
||||
? Text(locationText.join(', '), style: context.textTheme.labelLarge)
|
||||
: null;
|
||||
search(filter.value.copyWith(location: location));
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_location_title'.t(context: context),
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
onSearch: () => search(filter.value.copyWith(location: location)),
|
||||
onClear: () => search(filter.value.copyWith(location: SearchLocationFilter())),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: Container(
|
||||
@@ -237,28 +232,14 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
camera = SearchCameraFilter(make: value['make'], model: value['model']);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
cameraCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(camera: SearchCameraFilter()));
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
final make = camera.make ?? '';
|
||||
final model = camera.model ?? '';
|
||||
cameraCurrentFilterWidget.value = (make.isNotEmpty || model.isNotEmpty)
|
||||
? Text('$make $model', style: context.textTheme.labelLarge)
|
||||
: null;
|
||||
search(filter.value.copyWith(camera: camera));
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_camera_title'.t(context: context),
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
onSearch: () => search(filter.value.copyWith(camera: camera)),
|
||||
onClear: () => search(filter.value.copyWith(camera: SearchCameraFilter())),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: CameraPicker(onSelect: handleOnSelect, filter: filter.value.camera),
|
||||
@@ -268,42 +249,17 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
datePicked(DateFilterInputModel? selectedDate) {
|
||||
dateInputFilter.value = selectedDate;
|
||||
if (selectedDate == null) {
|
||||
dateRangeCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(date: SearchDateFilter()));
|
||||
return;
|
||||
}
|
||||
|
||||
final date = selectedDate.asDateTimeRange();
|
||||
dateRangeCurrentFilterWidget.value = Text(
|
||||
selectedDate.asHumanReadable(context),
|
||||
style: context.textTheme.labelLarge,
|
||||
);
|
||||
search(
|
||||
filter.value.copyWith(
|
||||
date: SearchDateFilter(
|
||||
takenAfter: date.start,
|
||||
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
|
||||
),
|
||||
),
|
||||
);
|
||||
search(filter.value.copyWith(date: selectedDate ?? const EmptyDateFilter()));
|
||||
}
|
||||
|
||||
showDatePicker() async {
|
||||
final firstDate = DateTime(1900);
|
||||
final lastDate = DateTime.now();
|
||||
|
||||
var dateRange = DateTimeRange(
|
||||
start: filter.value.date.takenAfter ?? lastDate,
|
||||
end: filter.value.date.takenBefore ?? lastDate,
|
||||
);
|
||||
|
||||
// datePicked() may increase the date, this will make the date picker fail an assertion
|
||||
// Fixup the end date to be at most now.
|
||||
if (dateRange.end.isAfter(lastDate)) {
|
||||
dateRange = DateTimeRange(start: dateRange.start, end: lastDate);
|
||||
}
|
||||
final stored = filter.value.date.asDateTimeRange();
|
||||
final dateRange = stored != null
|
||||
? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end))
|
||||
: DateTimeRange(start: lastDate, end: lastDate);
|
||||
|
||||
final date = await showDateRangePicker(
|
||||
context: context,
|
||||
@@ -338,7 +294,7 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
expanded: true,
|
||||
onClear: () => datePicked(null),
|
||||
child: QuickDatePicker(
|
||||
currentInput: dateInputFilter.value,
|
||||
currentInput: filter.value.date,
|
||||
onRequestPicker: () {
|
||||
context.pop();
|
||||
showDatePicker();
|
||||
@@ -360,27 +316,12 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
mediaType = assetType;
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
mediaTypeCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(mediaType: AssetType.other));
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
mediaTypeCurrentFilterWidget.value = mediaType != AssetType.other
|
||||
? Text(
|
||||
mediaType == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context),
|
||||
style: context.textTheme.labelLarge,
|
||||
)
|
||||
: null;
|
||||
search(filter.value.copyWith(mediaType: mediaType));
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'search_filter_media_type_title'.t(context: context),
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
onSearch: () => search(filter.value.copyWith(mediaType: mediaType)),
|
||||
onClear: () => search(filter.value.copyWith(mediaType: AssetType.other)),
|
||||
child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType),
|
||||
),
|
||||
);
|
||||
@@ -394,25 +335,13 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
rating = value;
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
ratingCurrentFilterWidget.value = null;
|
||||
search(filter.value.copyWith(rating: SearchRatingFilter(rating: null)));
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
ratingCurrentFilterWidget.value = rating.rating != null
|
||||
? Text('rating_count'.t(args: {'count': rating.rating!}), style: context.textTheme.labelLarge)
|
||||
: null;
|
||||
search(filter.value.copyWith(rating: rating));
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'rating'.t(context: context),
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
onSearch: () => search(filter.value.copyWith(rating: rating)),
|
||||
onClear: () => search(filter.value.copyWith(rating: SearchRatingFilter(rating: null))),
|
||||
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
|
||||
),
|
||||
);
|
||||
@@ -430,33 +359,16 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
displayOptionCurrentFilterWidget.value = null;
|
||||
search(
|
||||
filter.value.copyWith(
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
handleApply() {
|
||||
final filterText = [
|
||||
if (display.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context),
|
||||
if (display.isArchive) 'archive'.t(context: context),
|
||||
if (display.isFavorite) 'favorite'.t(context: context),
|
||||
];
|
||||
displayOptionCurrentFilterWidget.value = filterText.isNotEmpty
|
||||
? Text(filterText.join(', '), style: context.textTheme.labelLarge)
|
||||
: null;
|
||||
search(filter.value.copyWith(display: display));
|
||||
}
|
||||
|
||||
showFilterBottomSheet(
|
||||
context: context,
|
||||
child: FilterBottomSheetScaffold(
|
||||
title: 'display_options'.t(context: context),
|
||||
onSearch: handleApply,
|
||||
onClear: handleClear,
|
||||
onSearch: () => search(filter.value.copyWith(display: display)),
|
||||
onClear: () => search(
|
||||
filter.value.copyWith(
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
),
|
||||
),
|
||||
child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display),
|
||||
),
|
||||
);
|
||||
@@ -631,52 +543,52 @@ class DriftSearchPage extends HookConsumerWidget {
|
||||
icon: Icons.people_alt_outlined,
|
||||
onTap: showPeoplePicker,
|
||||
label: 'people'.t(context: context),
|
||||
currentFilter: peopleCurrentFilterWidget.value,
|
||||
currentFilter: peopleChip(),
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.location_on_outlined,
|
||||
onTap: showLocationPicker,
|
||||
label: 'search_filter_location'.t(context: context),
|
||||
currentFilter: locationCurrentFilterWidget.value,
|
||||
currentFilter: locationChip(),
|
||||
),
|
||||
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.sell_outlined,
|
||||
onTap: showTagPicker,
|
||||
label: 'tags'.t(context: context),
|
||||
currentFilter: tagCurrentFilterWidget.value,
|
||||
currentFilter: tagChip(),
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.camera_alt_outlined,
|
||||
onTap: showCameraPicker,
|
||||
label: 'camera'.t(context: context),
|
||||
currentFilter: cameraCurrentFilterWidget.value,
|
||||
currentFilter: cameraChip(),
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.date_range_outlined,
|
||||
onTap: showQuickDatePicker,
|
||||
label: 'search_filter_date'.t(context: context),
|
||||
currentFilter: dateRangeCurrentFilterWidget.value,
|
||||
currentFilter: dateChip(),
|
||||
),
|
||||
SearchFilterChip(
|
||||
key: const Key('media_type_chip'),
|
||||
icon: Icons.video_collection_outlined,
|
||||
onTap: showMediaTypePicker,
|
||||
label: 'search_filter_media_type'.t(context: context),
|
||||
currentFilter: mediaTypeCurrentFilterWidget.value,
|
||||
currentFilter: mediaTypeChip(),
|
||||
),
|
||||
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
|
||||
SearchFilterChip(
|
||||
icon: Icons.star_outline_rounded,
|
||||
onTap: showStarRatingPicker,
|
||||
label: 'search_filter_star_rating'.t(context: context),
|
||||
currentFilter: ratingCurrentFilterWidget.value,
|
||||
currentFilter: ratingChip(),
|
||||
),
|
||||
SearchFilterChip(
|
||||
icon: Icons.display_settings_outlined,
|
||||
onTap: showDisplayOptionPicker,
|
||||
label: 'search_filter_display_options'.t(context: context),
|
||||
currentFilter: displayOptionCurrentFilterWidget.value,
|
||||
currentFilter: displayChip(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
@@ -34,7 +35,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
||||
people: {},
|
||||
location: SearchLocationFilter(),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
date: const EmptyDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.image,
|
||||
|
||||
@@ -10,19 +10,20 @@ class TrashBottomBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: const SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,85 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
sealed class DateFilterInputModel {
|
||||
DateTimeRange<DateTime> asDateTimeRange();
|
||||
|
||||
String asHumanReadable(BuildContext context) {
|
||||
// General implementation for arbitrary date and time ranges
|
||||
// If date range is less than 24 hours, set the end date to the end of the day
|
||||
final date = asDateTimeRange();
|
||||
if (date.end.difference(date.start).inHours < 24) {
|
||||
return DateFormat.yMMMd().format(date.start.toLocal());
|
||||
} else {
|
||||
return 'search_filter_date_interval'.t(
|
||||
context: context,
|
||||
args: {
|
||||
"start": DateFormat.yMMMd().format(date.start.toLocal()),
|
||||
"end": DateFormat.yMMMd().format(date.end.toLocal()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RecentMonthRangeFilter extends DateFilterInputModel {
|
||||
final int monthDelta;
|
||||
RecentMonthRangeFilter(this.monthDelta);
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
final now = DateTime.now();
|
||||
// Note that DateTime's constructor properly handles month overflow.
|
||||
final from = DateTime(now.year, now.month - monthDelta, 1);
|
||||
return DateTimeRange<DateTime>(start: from, end: now);
|
||||
}
|
||||
|
||||
@override
|
||||
String asHumanReadable(BuildContext context) {
|
||||
return 'last_months'.t(context: context, args: {"count": monthDelta.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
class YearFilter extends DateFilterInputModel {
|
||||
final int year;
|
||||
YearFilter(this.year);
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
final now = DateTime.now();
|
||||
final from = DateTime(year, 1, 1);
|
||||
|
||||
if (now.year == year) {
|
||||
// To not go beyond today if the user picks the current year
|
||||
return DateTimeRange<DateTime>(start: from, end: now);
|
||||
}
|
||||
|
||||
final to = DateTime(year, 12, 31, 23, 59, 59);
|
||||
return DateTimeRange<DateTime>(start: from, end: to);
|
||||
}
|
||||
|
||||
@override
|
||||
String asHumanReadable(BuildContext context) {
|
||||
return 'in_year'.tr(namedArgs: {"year": year.toString()});
|
||||
}
|
||||
}
|
||||
|
||||
class CustomDateFilter extends DateFilterInputModel {
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
CustomDateFilter(this.start, this.end);
|
||||
|
||||
factory CustomDateFilter.fromRange(DateTimeRange<DateTime> range) {
|
||||
return CustomDateFilter(range.start, range.end);
|
||||
}
|
||||
|
||||
@override
|
||||
DateTimeRange<DateTime> asDateTimeRange() {
|
||||
return DateTimeRange<DateTime>(start: start, end: end);
|
||||
}
|
||||
}
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
|
||||
enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom }
|
||||
|
||||
@@ -102,7 +24,7 @@ class QuickDatePicker extends HookWidget {
|
||||
});
|
||||
|
||||
static int _initialYearFromModel(DateFilterInputModel? model) {
|
||||
return model?.asDateTimeRange().start.year ?? DateTime.now().year;
|
||||
return model?.asDateTimeRange()?.start.year ?? DateTime.now().year;
|
||||
}
|
||||
|
||||
static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) {
|
||||
@@ -149,7 +71,7 @@ class QuickDatePicker extends HookWidget {
|
||||
// Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default
|
||||
// so we wrap it in a InkWell
|
||||
Widget _exactPicker(BuildContext context) {
|
||||
final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter;
|
||||
final hasPreviousInput = currentInput is CustomDateFilter;
|
||||
|
||||
return InkWell(
|
||||
onTap: onRequestPicker,
|
||||
@@ -182,9 +104,9 @@ class QuickDatePicker extends HookWidget {
|
||||
if (value == null) return;
|
||||
final _ = switch (value) {
|
||||
_QuickPickerType.custom => onRequestPicker(),
|
||||
_QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)),
|
||||
_QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)),
|
||||
_QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)),
|
||||
_QuickPickerType.last1Month => onSelect(const RecentMonthRangeFilter(1)),
|
||||
_QuickPickerType.last3Months => onSelect(const RecentMonthRangeFilter(3)),
|
||||
_QuickPickerType.last9Months => onSelect(const RecentMonthRangeFilter(9)),
|
||||
// When a year is selected the combobox triggers onSelect() on its own.
|
||||
// Here we handle the radio button being selected which can only ever be the initial year
|
||||
_QuickPickerType.year => onSelect(YearFilter(_initialYear)),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
@@ -8,63 +7,26 @@ import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||
|
||||
class VideoControls extends ConsumerStatefulWidget {
|
||||
class VideoControls extends HookConsumerWidget {
|
||||
final String videoPlayerName;
|
||||
|
||||
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
|
||||
|
||||
const VideoControls({super.key, required this.videoPlayerName});
|
||||
|
||||
@override
|
||||
ConsumerState<VideoControls> createState() => _VideoControlsState();
|
||||
}
|
||||
|
||||
class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
late final RestartableTimer _hideTimer;
|
||||
|
||||
AutoDisposeStateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
|
||||
videoPlayerProvider(widget.videoPlayerName);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hideTimer = RestartableTimer(const Duration(seconds: 5), _onHideTimer);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VideoControls oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoPlayerName != widget.videoPlayerName) {
|
||||
_hideTimer.reset();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onHideTimer() {
|
||||
if (!mounted) return;
|
||||
if (ref.read(_provider).status == VideoPlaybackStatus.playing) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggle(bool isCasting) {
|
||||
void _toggle(WidgetRef ref, bool isCasting) {
|
||||
if (isCasting) {
|
||||
ref.read(castProvider.notifier).toggle();
|
||||
return;
|
||||
} else {
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
|
||||
}
|
||||
|
||||
ref.read(_provider.notifier).toggle();
|
||||
}
|
||||
|
||||
void _onSeek(bool isCasting, double value) {
|
||||
void _onSeek(WidgetRef ref, bool isCasting, double value) {
|
||||
final seekTo = Duration(microseconds: value.toInt());
|
||||
|
||||
if (isCasting) {
|
||||
@@ -72,30 +34,38 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(_provider.notifier).seekTo(seekTo);
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = videoPlayerProvider(videoPlayerName);
|
||||
final cast = ref.watch(castProvider);
|
||||
final isCasting = cast.isCasting;
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||
: ref.watch(_provider.select((v) => (v.position, v.duration)));
|
||||
: ref.watch(provider.select((v) => (v.position, v.duration)));
|
||||
|
||||
final videoStatus = ref.watch(_provider.select((v) => v.status));
|
||||
final videoStatus = ref.watch(provider.select((v) => v.status));
|
||||
final isPlaying = isCasting
|
||||
? cast.castState == CastState.playing
|
||||
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
|
||||
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
|
||||
|
||||
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
|
||||
if (showing && prev != showing) _hideTimer.reset();
|
||||
final hideTimer = useTimer(const Duration(seconds: 5), () {
|
||||
if (!context.mounted) return;
|
||||
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
});
|
||||
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
|
||||
|
||||
final notifier = ref.read(_provider.notifier);
|
||||
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
|
||||
if (showing && prev != showing) hideTimer.reset();
|
||||
});
|
||||
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
|
||||
|
||||
final notifier = ref.read(provider.notifier);
|
||||
final isLoaded = duration != Duration.zero;
|
||||
|
||||
return Padding(
|
||||
@@ -110,13 +80,9 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(),
|
||||
icon: isFinished
|
||||
? const Icon(Icons.replay, color: Colors.white, shadows: VideoControls._controlShadows)
|
||||
: AnimatedPlayPause(
|
||||
color: Colors.white,
|
||||
playing: isPlaying,
|
||||
shadows: VideoControls._controlShadows,
|
||||
),
|
||||
onPressed: () => _toggle(isCasting),
|
||||
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
|
||||
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
|
||||
onPressed: () => _toggle(ref, isCasting),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -125,7 +91,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
shadows: VideoControls._controlShadows,
|
||||
shadows: _controlShadows,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -141,7 +107,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
padding: EdgeInsets.zero,
|
||||
onChangeStart: (_) => notifier.hold(),
|
||||
onChangeEnd: (_) => notifier.release(),
|
||||
onChanged: isLoaded ? (value) => _onSeek(isCasting, value) : null,
|
||||
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/models/search/date_filter.model.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
|
||||
@@ -53,7 +54,7 @@ class ExploreGrid extends StatelessWidget {
|
||||
people: {},
|
||||
location: SearchLocationFilter(city: content.label),
|
||||
camera: SearchCameraFilter(),
|
||||
date: SearchDateFilter(),
|
||||
date: const EmptyDateFilter(),
|
||||
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
|
||||
rating: SearchRatingFilter(),
|
||||
mediaType: AssetType.other,
|
||||
|
||||
+3
-11
@@ -15,36 +15,30 @@ class DatabaseBackupDto {
|
||||
DatabaseBackupDto({
|
||||
required this.filename,
|
||||
required this.filesize,
|
||||
required this.timezone,
|
||||
});
|
||||
|
||||
String filename;
|
||||
|
||||
num filesize;
|
||||
|
||||
String timezone;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
|
||||
other.filename == filename &&
|
||||
other.filesize == filesize &&
|
||||
other.timezone == timezone;
|
||||
other.filesize == filesize;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(filename.hashCode) +
|
||||
(filesize.hashCode) +
|
||||
(timezone.hashCode);
|
||||
(filesize.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]';
|
||||
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'filename'] = this.filename;
|
||||
json[r'filesize'] = this.filesize;
|
||||
json[r'timezone'] = this.timezone;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -59,7 +53,6 @@ class DatabaseBackupDto {
|
||||
return DatabaseBackupDto(
|
||||
filename: mapValueOfType<String>(json, r'filename')!,
|
||||
filesize: num.parse('${json[r'filesize']}'),
|
||||
timezone: mapValueOfType<String>(json, r'timezone')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -109,7 +102,6 @@ class DatabaseBackupDto {
|
||||
static const requiredKeys = <String>{
|
||||
'filename',
|
||||
'filesize',
|
||||
'timezone',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17721,15 +17721,11 @@
|
||||
},
|
||||
"filesize": {
|
||||
"type": "number"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filename",
|
||||
"filesize",
|
||||
"timezone"
|
||||
"filesize"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -63,7 +63,6 @@ export type DatabaseBackupDeleteDto = {
|
||||
export type DatabaseBackupDto = {
|
||||
filename: string;
|
||||
filesize: number;
|
||||
timezone: string;
|
||||
};
|
||||
export type DatabaseBackupListResponseDto = {
|
||||
backups: DatabaseBackupDto[];
|
||||
|
||||
@@ -4,7 +4,6 @@ import { IsString } from 'class-validator';
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
timezone!: string;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
|
||||
@@ -75,10 +75,6 @@ export interface EnvData {
|
||||
server: string;
|
||||
};
|
||||
|
||||
versionCheck: {
|
||||
url: string;
|
||||
};
|
||||
|
||||
network: {
|
||||
trustedProxies: string[];
|
||||
};
|
||||
@@ -324,10 +320,6 @@ const getEnv = (): EnvData => {
|
||||
|
||||
licensePublicKey: isProd ? productionKeys : stagingKeys,
|
||||
|
||||
versionCheck: {
|
||||
url: isProd ? 'https://version.immich.cloud/version' : 'https://version.dev.immich.cloud/version',
|
||||
},
|
||||
|
||||
network: {
|
||||
trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'],
|
||||
},
|
||||
|
||||
@@ -66,8 +66,7 @@ export class ServerInfoRepository {
|
||||
|
||||
async getLatestRelease(): Promise<VersionResponse> {
|
||||
try {
|
||||
const { versionCheck } = this.configRepository.getEnv();
|
||||
const response = await fetch(versionCheck.url);
|
||||
const response = await fetch('https://version.immich.cloud/version');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
|
||||
|
||||
@@ -111,7 +111,6 @@ const validVideos = [
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.mxf',
|
||||
'.ts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
|
||||
@@ -283,7 +283,6 @@ export class DatabaseBackupService {
|
||||
async listBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
const files = await this.storageRepository.readdir(backupsFolder);
|
||||
const timezone = DateTime.local().zoneName;
|
||||
|
||||
const validFiles = files
|
||||
.filter((fn) => isValidDatabaseBackupName(fn))
|
||||
@@ -293,7 +292,7 @@ export class DatabaseBackupService {
|
||||
const backups = await Promise.all(
|
||||
validFiles.map(async (filename) => {
|
||||
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
|
||||
return { filename, filesize: stats.size, timezone };
|
||||
return { filename, filesize: stats.size };
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -72,6 +73,15 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
|
||||
describe('handVersionCheck', () => {
|
||||
beforeEach(() => {
|
||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production }));
|
||||
});
|
||||
|
||||
it('should not run in dev mode', async () => {
|
||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development }));
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
|
||||
});
|
||||
|
||||
it('should not run if the last check was < 60 minutes ago', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { VersionCheckMetadata } from 'src/types';
|
||||
@@ -71,6 +71,11 @@ export class VersionService extends BaseService {
|
||||
try {
|
||||
this.logger.debug('Running version check');
|
||||
|
||||
const { environment } = this.configRepository.getEnv();
|
||||
if (environment === ImmichEnvironment.Development) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const { newVersionCheck } = await this.getConfig({ withCache: true });
|
||||
if (!newVersionCheck.enabled) {
|
||||
return JobStatus.Skipped;
|
||||
|
||||
@@ -83,7 +83,6 @@ describe('mimeTypes', () => {
|
||||
{ mimetype: 'video/mp2t', extension: '.m2t' },
|
||||
{ mimetype: 'video/mp2t', extension: '.m2ts' },
|
||||
{ mimetype: 'video/mp2t', extension: '.mts' },
|
||||
{ mimetype: 'video/mp2t', extension: '.ts' },
|
||||
{ mimetype: 'video/mp4', extension: '.mp4' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpe' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpeg' },
|
||||
|
||||
@@ -114,7 +114,6 @@ const video: Record<string, string[]> = {
|
||||
'.mpg': ['video/mpeg'],
|
||||
'.mts': ['video/mp2t'],
|
||||
'.mxf': ['application/mxf'],
|
||||
'.ts': ['video/mp2t'],
|
||||
'.vob': ['video/mpeg'],
|
||||
'.webm': ['video/webm'],
|
||||
'.wmv': ['video/x-ms-wmv'],
|
||||
|
||||
@@ -44,10 +44,6 @@ const envData: EnvData = {
|
||||
server: 'server-public-key',
|
||||
},
|
||||
|
||||
versionCheck: {
|
||||
url: 'https://version.immich.cloud/version',
|
||||
},
|
||||
|
||||
network: {
|
||||
trustedProxies: [],
|
||||
},
|
||||
|
||||
@@ -276,6 +276,7 @@
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -337,7 +338,7 @@
|
||||
onAction?.(action);
|
||||
};
|
||||
|
||||
let isFullScreen = $derived(!!fullscreenElement);
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
setCursorStyle('');
|
||||
if (unsubscribeRestart) {
|
||||
unsubscribeRestart();
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { screen } from '@testing-library/svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
|
||||
|
||||
vi.mock('$lib/services/database-backups.service', () => ({
|
||||
getDatabaseBackupActions: () => ({
|
||||
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
|
||||
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
|
||||
}),
|
||||
handleRestoreDatabaseBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('MaintenanceBackupEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
|
||||
locale.set('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders relative backup time using the user timezone instead of UTC', () => {
|
||||
const backupTimestamp = '20260324T110000';
|
||||
|
||||
const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
|
||||
zone: 'Asia/Tokyo',
|
||||
})
|
||||
.toLocal()
|
||||
.toRelative({ locale: 'en' });
|
||||
|
||||
const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
|
||||
zone: 'UTC',
|
||||
})
|
||||
.toLocal()
|
||||
.toRelative({ locale: 'en' });
|
||||
|
||||
expect(expectedRelativeTime).toBeTruthy();
|
||||
expect(expectedRelativeTime).not.toEqual(utcRelativeTime);
|
||||
|
||||
renderWithTooltips(MaintenanceBackupEntry, {
|
||||
expectedVersion: '1.2.3',
|
||||
filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz',
|
||||
filesize: 1024,
|
||||
timezone: 'Asia/Tokyo',
|
||||
});
|
||||
|
||||
expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,17 +13,16 @@
|
||||
filename: string;
|
||||
filesize: number;
|
||||
expectedVersion: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
const { filename, filesize, expectedVersion, timezone }: Props = $props();
|
||||
const { filename, filesize, expectedVersion }: Props = $props();
|
||||
|
||||
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
|
||||
|
||||
const backupDateTime = $derived.by(() => {
|
||||
const dateMatch = filename.match(/\d+T\d+/);
|
||||
if (dateMatch) {
|
||||
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
|
||||
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }).toLocal();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -51,13 +51,12 @@
|
||||
const unknownDateKey = $t('unknown_date');
|
||||
|
||||
for (const backup of backups) {
|
||||
const timezone = backup.timezone;
|
||||
const dateMatch = backup.filename.match(/\d+T\d+/);
|
||||
let dateKey: string;
|
||||
let dt: DateTime;
|
||||
|
||||
if (dateMatch) {
|
||||
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
|
||||
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' });
|
||||
dateKey = dt.toFormat('LLLL d, yyyy');
|
||||
} else {
|
||||
dt = DateTime.fromMillis(0);
|
||||
@@ -129,7 +128,6 @@
|
||||
filename={backup.filename}
|
||||
filesize={backup.filesize}
|
||||
expectedVersion={props.expectedVersion}
|
||||
timezone={backup.timezone}
|
||||
/>
|
||||
{/each}
|
||||
</Stack>
|
||||
|
||||
@@ -46,7 +46,6 @@ export function layoutTimelineMonth(timelineManager: TimelineManager, month: Tim
|
||||
} else {
|
||||
// Move to next row
|
||||
cumulativeHeight += currentRowHeight;
|
||||
currentRowHeight = 0;
|
||||
cumulativeWidth = 0;
|
||||
timelineDayRow++;
|
||||
timelineDayCol = 0;
|
||||
@@ -60,7 +59,7 @@ export function layoutTimelineMonth(timelineManager: TimelineManager, month: Tim
|
||||
timelineDayCol++;
|
||||
cumulativeWidth += timelineDay.width + timelineManager.gap;
|
||||
}
|
||||
currentRowHeight = Math.max(currentRowHeight, timelineDay.height + timelineManager.headerHeight);
|
||||
currentRowHeight = timelineDay.height + timelineManager.headerHeight;
|
||||
}
|
||||
|
||||
// Add the height of the final row
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { layoutTimelineMonth } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||
import { getTimelineMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
@@ -772,100 +771,6 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutTimelineMonth', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
timelineManager = new TimelineManager();
|
||||
sdkMock.getTimeBuckets.mockResolvedValue([]);
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('uses tallest day height when multiple days share a row', () => {
|
||||
const day20Asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const day10Asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-10T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.upsertAssets([day20Asset, day10Asset]);
|
||||
|
||||
const month = timelineManager.months[0];
|
||||
expect(month.timelineDays).toHaveLength(2);
|
||||
|
||||
const [tallDay, shortDay] = month.timelineDays;
|
||||
vi.spyOn(tallDay, 'layout').mockImplementation(() => {
|
||||
tallDay.width = 400;
|
||||
tallDay.height = 300;
|
||||
});
|
||||
vi.spyOn(shortDay, 'layout').mockImplementation(() => {
|
||||
shortDay.width = 200;
|
||||
shortDay.height = 150;
|
||||
});
|
||||
|
||||
layoutTimelineMonth(timelineManager, month);
|
||||
|
||||
// Both days fit in one row: 400 + 12 (gap) + 200 = 612 < 1588
|
||||
expect(tallDay.row).toBe(0);
|
||||
expect(shortDay.row).toBe(0);
|
||||
|
||||
// Month height should use the tallest day: 300 + 48 (headerHeight) = 348
|
||||
expect(month.height).toBe(300 + timelineManager.headerHeight);
|
||||
});
|
||||
|
||||
it('resets row height tracking when starting a new row', () => {
|
||||
const day30Asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-30T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const day20Asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
const day10Asset = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-10T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.upsertAssets([day30Asset, day20Asset, day10Asset]);
|
||||
|
||||
const month = timelineManager.months[0];
|
||||
expect(month.timelineDays).toHaveLength(3);
|
||||
|
||||
const [day1, day2, day3] = month.timelineDays;
|
||||
// Row 0: day1 (wide, tall) fills the row
|
||||
vi.spyOn(day1, 'layout').mockImplementation(() => {
|
||||
day1.width = 1500;
|
||||
day1.height = 400;
|
||||
});
|
||||
// Row 1: day2 and day3 share a row
|
||||
vi.spyOn(day2, 'layout').mockImplementation(() => {
|
||||
day2.width = 300;
|
||||
day2.height = 200;
|
||||
});
|
||||
vi.spyOn(day3, 'layout').mockImplementation(() => {
|
||||
day3.width = 300;
|
||||
day3.height = 100;
|
||||
});
|
||||
|
||||
layoutTimelineMonth(timelineManager, month);
|
||||
|
||||
expect(day1.row).toBe(0);
|
||||
expect(day2.row).toBe(1);
|
||||
expect(day3.row).toBe(1);
|
||||
|
||||
const headerHeight = timelineManager.headerHeight;
|
||||
// Row 0: 400 + 48 = 448. Row 1: max(200, 100) + 48 = 248. Total = 696
|
||||
expect(month.height).toBe(400 + headerHeight + (200 + headerHeight));
|
||||
});
|
||||
});
|
||||
|
||||
describe('showAssetOwners', () => {
|
||||
const LS_KEY = 'album-show-asset-owners';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user