Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis
3e9f713d81 refactor: new timeline component
- Create new timeline component (extracted from asset-gird) without removing old code
- Add timeline/, timeline/actions/, timeline/base-components/, and timeline/internal-components/ directories
- Copy needed components (delete-asset-dialog, scrubber, skeleton) to new locations
- Add new timeline components (base-timeline, base-timeline-viewer, timeline-month, etc.)
- Update timeline-util.ts with new functions (findMonthAtScrollPosition, formatGroupTitleFull)
- Add asset-viewer-actions and asset-viewer-and-actions components

This allows the timeline to exist alongside the current AssetGrid component.
2025-09-18 21:07:45 +00:00
70 changed files with 2309 additions and 9508 deletions

View File

@@ -5,7 +5,8 @@
"immich-server", "immich-server",
"redis", "redis",
"database", "database",
"immich-machine-learning" "immich-machine-learning",
"init"
], ],
"dockerComposeFile": [ "dockerComposeFile": [
"../docker/docker-compose.dev.yml", "../docker/docker-compose.dev.yml",

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@ mobile/libisar.dylib
mobile/openapi/test mobile/openapi/test
mobile/openapi/doc mobile/openapi/doc
mobile/openapi/.openapi-generator/FILES mobile/openapi/.openapi-generator/FILES
mobile/ios/build
open-api/typescript-sdk/build open-api/typescript-sdk/build
mobile/android/fastlane/report.xml mobile/android/fastlane/report.xml

View File

@@ -1353,6 +1353,8 @@
"my_albums": "My albums", "my_albums": "My albums",
"name": "Name", "name": "Name",
"name_or_nickname": "Name or nickname", "name_or_nickname": "Name or nickname",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos", "network_requirement_photos_upload": "Use cellular data to backup photos",
"network_requirement_videos_upload": "Use cellular data to backup videos", "network_requirement_videos_upload": "Use cellular data to backup videos",
"network_requirements": "Network Requirements", "network_requirements": "Network Requirements",

View File

@@ -3,19 +3,14 @@ package app.alextran.immich.images
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.* import android.graphics.*
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import android.provider.DocumentsContract
import android.provider.MediaStore.Images import android.provider.MediaStore.Images
import android.provider.MediaStore.Video import android.provider.MediaStore.Video
import android.system.Int64Ref
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.* import kotlin.math.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -177,7 +172,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
} }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadThumbnail(uri, Size(targetWidth, targetHeight), signal) resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS) Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
@@ -190,7 +185,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled() signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id) val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
loadThumbnail(uri, Size(targetWidth, targetHeight), signal) resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS) Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
@@ -220,72 +215,4 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
ref.get() ref.get()
} }
} }
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun loadThumbnail(uri: Uri, size: Size, signal: CancellationSignal?): Bitmap {
// Convert to Point, since that's what the API is defined as
val opts = Bundle()
if (size.width < 512 && size.height < 512) {
opts.putParcelable(ContentResolver.EXTRA_SIZE, Point(size.width, size.height))
}
val orientation = Int64Ref(0)
var bitmap =
ImageDecoder.decodeBitmap(
ImageDecoder.createSource {
val afd =
resolver.openTypedAssetFile(uri, "image/*", opts, signal)
?: throw Resources.NotFoundException("Asset $uri not found")
val extras = afd.extras
orientation.value =
(extras?.getInt(DocumentsContract.EXTRA_ORIENTATION, 0) ?: 0).toLong()
afd
}
) { decoder: ImageDecoder, info: ImageDecoder.ImageInfo, _: ImageDecoder.Source ->
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE)
// One last-ditch check to see if we've been canceled.
signal?.throwIfCanceled()
// We requested a rough thumbnail size, but the remote size may have
// returned something giant, so defensively scale down as needed.
// This is modified from the original to target the smaller edge instead of the larger edge.
val widthSample = info.size.width.toDouble() / size.width
val heightSample = info.size.height.toDouble() / size.height
val sample = min(widthSample, heightSample)
if (sample > 1) {
val width = (info.size.width / sample).toInt()
val height = (info.size.height / sample).toInt()
decoder.setTargetSize(width, height)
}
}
// Transform the bitmap if requested. We use a side-channel to
// communicate the orientation, since EXIF thumbnails don't contain
// the rotation flags of the original image.
if (orientation.value != 0L) {
val width = bitmap.getWidth()
val height = bitmap.getHeight()
val m = Matrix()
m.setRotate(orientation.value.toFloat(), (width / 2).toFloat(), (height / 2).toFloat())
bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false)
}
return bitmap
}
} }

File diff suppressed because one or more lines are too long

View File

@@ -29,11 +29,9 @@
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340C92E7326630050078A /* AssetResolver.swift */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CB2E7326630050078A /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FEC340D32E7326630050078A /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CF2E7326630050078A /* Request.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CC2E7326630050078A /* ThumbnailResolver.swift */; };
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CD2E7326630050078A /* Thumbnails.g.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -117,11 +115,9 @@
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; }; FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
FEC340C92E7326630050078A /* AssetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResolver.swift; sourceTree = "<group>"; }; FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FEC340CB2E7326630050078A /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; }; FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailResolver.swift; sourceTree = "<group>"; }; FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
FEC340CD2E7326630050078A /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FEC340CF2E7326630050078A /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -251,7 +247,6 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FEC340D02E7326630050078A /* Resolvers */,
B25D37792E72CA15008B6CA7 /* Connectivity */, B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */, B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -266,6 +261,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FED3B1952E253E9B0030FD97 /* Images */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -300,34 +296,16 @@
path = ShareExtension; path = ShareExtension;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FEC340CA2E7326630050078A /* Assets */ = { FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FEC340C92E7326630050078A /* AssetResolver.swift */, FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
); FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
path = Assets; FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
sourceTree = "<group>";
};
FEC340CE2E7326630050078A /* Images */ = {
isa = PBXGroup;
children = (
FEC340CB2E7326630050078A /* Thumbhash.swift */,
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */,
FEC340CD2E7326630050078A /* Thumbnails.g.swift */,
); );
path = Images; path = Images;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FEC340D02E7326630050078A /* Resolvers */ = {
isa = PBXGroup;
children = (
FEC340CA2E7326630050078A /* Assets */,
FEC340CE2E7326630050078A /* Images */,
FEC340CF2E7326630050078A /* Request.swift */,
);
path = Resolvers;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -595,16 +573,14 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */,
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */,
FEC340D32E7326630050078A /* Request.swift in Sources */,
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */,
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -15,7 +15,7 @@ import UIKit
) -> Bool { ) -> Bool {
// Required for flutter_local_notification // Required for flutter_local_notification
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
} }
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
@@ -53,7 +53,7 @@ import UIKit
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) { public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl()) NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailResolver()) ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
} }
} }

View File

@@ -0,0 +1,211 @@
import CryptoKit
import Flutter
import MobileCoreServices
import Photos
class Request {
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
}
}
class ThumbnailApiImpl: ThumbnailApi {
private static let imageManager = PHImageManager.default()
private static let fetchOptions = {
let fetchOptions = PHFetchOptions()
fetchOptions.fetchLimit = 1
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}()
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
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 let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: Request]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
}
Self.concurrencySemaphore.wait()
defer {
Self.concurrencySemaphore.signal()
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.removeRequest(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
}
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
request.workItem = item
Self.addRequest(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: Request) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(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(Self.cancelledResult) }
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
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 }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -1,115 +0,0 @@
import Photos
class AssetRequest: Request {
let assetId: String
var completion: (PHAsset?) -> Void
init(cancellationToken: CancellationToken, assetId: String, completion: @escaping (PHAsset?) -> Void) {
self.assetId = assetId
self.completion = completion
super.init(cancellationToken: cancellationToken)
}
}
class AssetResolver {
private let requestQueue: DispatchQueue
private let processingQueue: DispatchQueue
private var batchTimer: DispatchWorkItem?
private let batchLock = NSLock()
private let batchTimeout: TimeInterval
private let fetchOptions: PHFetchOptions
private var assetRequests = [AssetRequest]()
private let assetCache: NSCache<NSString, PHAsset>
init(
fetchOptions: PHFetchOptions,
batchTimeout: TimeInterval = 0.00025, // 250μs
cacheSize: Int = 10000,
qos: DispatchQoS = .unspecified
) {
self.fetchOptions = fetchOptions
self.batchTimeout = batchTimeout
self.assetCache = NSCache<NSString, PHAsset>()
self.assetCache.countLimit = cacheSize
self.requestQueue = DispatchQueue(label: "assets.requests", qos: qos)
self.processingQueue = DispatchQueue(label: "assets.processing", qos: qos)
}
func requestAsset(request: AssetRequest) {
requestQueue.async {
if (request.isCancelled) {
request.completion(nil)
return
}
if let cachedAsset = self.assetCache.object(forKey: request.assetId as NSString) {
request.completion(cachedAsset)
return
}
self.batchLock.lock()
if (request.isCancelled) {
self.batchLock.unlock()
request.completion(nil)
return
}
self.assetRequests.append(request)
self.batchTimer?.cancel()
let timer = DispatchWorkItem(block: self.processBatch)
self.batchTimer = timer
self.batchLock.unlock()
self.processingQueue.asyncAfter(deadline: .now() + self.batchTimeout, execute: timer)
}
}
private func processBatch() {
batchLock.lock()
if assetRequests.isEmpty {
batchTimer = nil
batchLock.unlock()
return
}
var completionMap = [String: [(PHAsset?) -> Void]]()
var activeAssetIds = [String]()
completionMap.reserveCapacity(assetRequests.count)
activeAssetIds.reserveCapacity(assetRequests.count)
for request in assetRequests {
if (request.isCancelled) {
request.completion(nil)
continue
}
if var completions = completionMap[request.assetId] {
completions.append(request.completion)
} else {
activeAssetIds.append(request.assetId)
completionMap[request.assetId] = [request.completion]
}
}
assetRequests.removeAll(keepingCapacity: true)
batchTimer = nil
batchLock.unlock()
guard !activeAssetIds.isEmpty else { return }
let assets = PHAsset.fetchAssets(withLocalIdentifiers: activeAssetIds, options: self.fetchOptions)
assets.enumerateObjects { asset, _, _ in
let assetId = asset.localIdentifier
for completion in completionMap.removeValue(forKey: assetId)! {
completion(asset)
}
self.requestQueue.async { self.assetCache.setObject(asset, forKey: assetId as NSString) }
}
for completions in completionMap.values {
for completion in completions {
completion(nil)
}
}
}
}

View File

@@ -1,198 +0,0 @@
import CryptoKit
import Flutter
import MobileCoreServices
import Photos
class ThumbnailRequest: Request {
weak var workItem: DispatchWorkItem?
let completion: (Result<[String: Int64], any Error>) -> Void
init(cancellationToken: CancellationToken, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
self.completion = completion
super.init(cancellationToken: cancellationToken)
}
}
class ThumbnailResolver: ThumbnailApi {
private static let imageManager = PHImageManager.default()
private static let assetResolver = AssetResolver(fetchOptions: {
let fetchOptions = PHFetchOptions()
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}(), qos: .userInitiated)
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: ThumbnailRequest]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static let thumbnailConcurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount / 2 + 1)
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let cancellationToken = CancellationToken()
let thumbnailRequest = ThumbnailRequest(cancellationToken: cancellationToken, completion: completion)
Self.assetResolver.requestAsset(request: AssetRequest(cancellationToken: cancellationToken, assetId: assetId) { asset in
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
let item = DispatchWorkItem {
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = asset else {
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
Self.removeRequest(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
Self.thumbnailConcurrencySemaphore.wait()
defer { Self.thumbnailConcurrencySemaphore.signal() }
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
}
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
thumbnailRequest.workItem = item
Self.processingQueue.async(execute: item)
})
Self.addRequest(requestId: requestId, request: thumbnailRequest)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: ThumbnailRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
item.cancel()
if item.isCancelled {
cancelQueue.async { request.completion(Self.cancelledResult) }
}
}
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -1,20 +0,0 @@
class CancellationToken {
var isCancelled = false
}
class Request {
let cancellationToken: CancellationToken
init(cancellationToken: CancellationToken) {
self.cancellationToken = cancellationToken
}
var isCancelled: Bool {
get {
return cancellationToken.isCancelled
}
set(newValue) {
cancellationToken.isCancelled = newValue
}
}
}

View File

@@ -10,9 +10,6 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)(); TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
// Used for mark & sweep
BoolColumn get marker_ => boolean().nullable()();
@override @override
Set<Column> get primaryKey => {assetId, albumId}; Set<Column> get primaryKey => {assetId, albumId};
} }

View File

@@ -15,13 +15,11 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({ i1.LocalAlbumAssetEntityCompanion Function({
required String assetId, required String assetId,
required String albumId, required String albumId,
i0.Value<bool?> marker_,
}); });
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder = typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
i1.LocalAlbumAssetEntityCompanion Function({ i1.LocalAlbumAssetEntityCompanion Function({
i0.Value<String> assetId, i0.Value<String> assetId,
i0.Value<String> albumId, i0.Value<String> albumId,
i0.Value<bool?> marker_,
}); });
final class $$LocalAlbumAssetEntityTableReferences final class $$LocalAlbumAssetEntityTableReferences
@@ -115,11 +113,6 @@ class $$LocalAlbumAssetEntityTableFilterComposer
super.$addJoinBuilderToRootComposer, super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer, super.$removeJoinBuilderFromRootComposer,
}); });
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
column: $table.marker_,
builder: (column) => i0.ColumnFilters(column),
);
i3.$$LocalAssetEntityTableFilterComposer get assetId { i3.$$LocalAssetEntityTableFilterComposer get assetId {
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder( final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this, composer: this,
@@ -184,11 +177,6 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
super.$addJoinBuilderToRootComposer, super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer, super.$removeJoinBuilderFromRootComposer,
}); });
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
column: $table.marker_,
builder: (column) => i0.ColumnOrderings(column),
);
i3.$$LocalAssetEntityTableOrderingComposer get assetId { i3.$$LocalAssetEntityTableOrderingComposer get assetId {
final i3.$$LocalAssetEntityTableOrderingComposer composer = final i3.$$LocalAssetEntityTableOrderingComposer composer =
$composerBuilder( $composerBuilder(
@@ -255,9 +243,6 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
super.$addJoinBuilderToRootComposer, super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer, super.$removeJoinBuilderFromRootComposer,
}); });
i0.GeneratedColumn<bool> get marker_ =>
$composableBuilder(column: $table.marker_, builder: (column) => column);
i3.$$LocalAssetEntityTableAnnotationComposer get assetId { i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
final i3.$$LocalAssetEntityTableAnnotationComposer composer = final i3.$$LocalAssetEntityTableAnnotationComposer composer =
$composerBuilder( $composerBuilder(
@@ -359,22 +344,16 @@ class $$LocalAlbumAssetEntityTableTableManager
({ ({
i0.Value<String> assetId = const i0.Value.absent(), i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<String> albumId = const i0.Value.absent(), i0.Value<String> albumId = const i0.Value.absent(),
i0.Value<bool?> marker_ = const i0.Value.absent(),
}) => i1.LocalAlbumAssetEntityCompanion( }) => i1.LocalAlbumAssetEntityCompanion(
assetId: assetId, assetId: assetId,
albumId: albumId, albumId: albumId,
marker_: marker_,
), ),
createCompanionCallback: createCompanionCallback:
({ ({required String assetId, required String albumId}) =>
required String assetId, i1.LocalAlbumAssetEntityCompanion.insert(
required String albumId, assetId: assetId,
i0.Value<bool?> marker_ = const i0.Value.absent(), albumId: albumId,
}) => i1.LocalAlbumAssetEntityCompanion.insert( ),
assetId: assetId,
albumId: albumId,
marker_: marker_,
),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map( .map(
(e) => ( (e) => (
@@ -498,22 +477,8 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
'REFERENCES local_album_entity (id) ON DELETE CASCADE', 'REFERENCES local_album_entity (id) ON DELETE CASCADE',
), ),
); );
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
'marker_',
);
@override @override
late final i0.GeneratedColumn<bool> marker_ = i0.GeneratedColumn<bool>( List<i0.GeneratedColumn> get $columns => [assetId, albumId];
'marker',
aliasedName,
true,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("marker" IN (0, 1))',
),
);
@override
List<i0.GeneratedColumn> get $columns => [assetId, albumId, marker_];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@override @override
@@ -542,12 +507,6 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
} else if (isInserting) { } else if (isInserting) {
context.missing(_albumIdMeta); context.missing(_albumIdMeta);
} }
if (data.containsKey('marker')) {
context.handle(
_marker_Meta,
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
);
}
return context; return context;
} }
@@ -568,10 +527,6 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}album_id'], data['${effectivePrefix}album_id'],
)!, )!,
marker_: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}marker'],
),
); );
} }
@@ -590,20 +545,15 @@ class LocalAlbumAssetEntityData extends i0.DataClass
implements i0.Insertable<i1.LocalAlbumAssetEntityData> { implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
final String assetId; final String assetId;
final String albumId; final String albumId;
final bool? marker_;
const LocalAlbumAssetEntityData({ const LocalAlbumAssetEntityData({
required this.assetId, required this.assetId,
required this.albumId, required this.albumId,
this.marker_,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{}; final map = <String, i0.Expression>{};
map['asset_id'] = i0.Variable<String>(assetId); map['asset_id'] = i0.Variable<String>(assetId);
map['album_id'] = i0.Variable<String>(albumId); map['album_id'] = i0.Variable<String>(albumId);
if (!nullToAbsent || marker_ != null) {
map['marker'] = i0.Variable<bool>(marker_);
}
return map; return map;
} }
@@ -615,7 +565,6 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return LocalAlbumAssetEntityData( return LocalAlbumAssetEntityData(
assetId: serializer.fromJson<String>(json['assetId']), assetId: serializer.fromJson<String>(json['assetId']),
albumId: serializer.fromJson<String>(json['albumId']), albumId: serializer.fromJson<String>(json['albumId']),
marker_: serializer.fromJson<bool?>(json['marker_']),
); );
} }
@override @override
@@ -624,26 +573,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
return <String, dynamic>{ return <String, dynamic>{
'assetId': serializer.toJson<String>(assetId), 'assetId': serializer.toJson<String>(assetId),
'albumId': serializer.toJson<String>(albumId), 'albumId': serializer.toJson<String>(albumId),
'marker_': serializer.toJson<bool?>(marker_),
}; };
} }
i1.LocalAlbumAssetEntityData copyWith({ i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
String? assetId, i1.LocalAlbumAssetEntityData(
String? albumId, assetId: assetId ?? this.assetId,
i0.Value<bool?> marker_ = const i0.Value.absent(), albumId: albumId ?? this.albumId,
}) => i1.LocalAlbumAssetEntityData( );
assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId,
marker_: marker_.present ? marker_.value : this.marker_,
);
LocalAlbumAssetEntityData copyWithCompanion( LocalAlbumAssetEntityData copyWithCompanion(
i1.LocalAlbumAssetEntityCompanion data, i1.LocalAlbumAssetEntityCompanion data,
) { ) {
return LocalAlbumAssetEntityData( return LocalAlbumAssetEntityData(
assetId: data.assetId.present ? data.assetId.value : this.assetId, assetId: data.assetId.present ? data.assetId.value : this.assetId,
albumId: data.albumId.present ? data.albumId.value : this.albumId, albumId: data.albumId.present ? data.albumId.value : this.albumId,
marker_: data.marker_.present ? data.marker_.value : this.marker_,
); );
} }
@@ -651,60 +594,51 @@ class LocalAlbumAssetEntityData extends i0.DataClass
String toString() { String toString() {
return (StringBuffer('LocalAlbumAssetEntityData(') return (StringBuffer('LocalAlbumAssetEntityData(')
..write('assetId: $assetId, ') ..write('assetId: $assetId, ')
..write('albumId: $albumId, ') ..write('albumId: $albumId')
..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash(assetId, albumId, marker_); int get hashCode => Object.hash(assetId, albumId);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is i1.LocalAlbumAssetEntityData && (other is i1.LocalAlbumAssetEntityData &&
other.assetId == this.assetId && other.assetId == this.assetId &&
other.albumId == this.albumId && other.albumId == this.albumId);
other.marker_ == this.marker_);
} }
class LocalAlbumAssetEntityCompanion class LocalAlbumAssetEntityCompanion
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> { extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
final i0.Value<String> assetId; final i0.Value<String> assetId;
final i0.Value<String> albumId; final i0.Value<String> albumId;
final i0.Value<bool?> marker_;
const LocalAlbumAssetEntityCompanion({ const LocalAlbumAssetEntityCompanion({
this.assetId = const i0.Value.absent(), this.assetId = const i0.Value.absent(),
this.albumId = const i0.Value.absent(), this.albumId = const i0.Value.absent(),
this.marker_ = const i0.Value.absent(),
}); });
LocalAlbumAssetEntityCompanion.insert({ LocalAlbumAssetEntityCompanion.insert({
required String assetId, required String assetId,
required String albumId, required String albumId,
this.marker_ = const i0.Value.absent(),
}) : assetId = i0.Value(assetId), }) : assetId = i0.Value(assetId),
albumId = i0.Value(albumId); albumId = i0.Value(albumId);
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({ static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
i0.Expression<String>? assetId, i0.Expression<String>? assetId,
i0.Expression<String>? albumId, i0.Expression<String>? albumId,
i0.Expression<bool>? marker_,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (assetId != null) 'asset_id': assetId, if (assetId != null) 'asset_id': assetId,
if (albumId != null) 'album_id': albumId, if (albumId != null) 'album_id': albumId,
if (marker_ != null) 'marker': marker_,
}); });
} }
i1.LocalAlbumAssetEntityCompanion copyWith({ i1.LocalAlbumAssetEntityCompanion copyWith({
i0.Value<String>? assetId, i0.Value<String>? assetId,
i0.Value<String>? albumId, i0.Value<String>? albumId,
i0.Value<bool?>? marker_,
}) { }) {
return i1.LocalAlbumAssetEntityCompanion( return i1.LocalAlbumAssetEntityCompanion(
assetId: assetId ?? this.assetId, assetId: assetId ?? this.assetId,
albumId: albumId ?? this.albumId, albumId: albumId ?? this.albumId,
marker_: marker_ ?? this.marker_,
); );
} }
@@ -717,9 +651,6 @@ class LocalAlbumAssetEntityCompanion
if (albumId.present) { if (albumId.present) {
map['album_id'] = i0.Variable<String>(albumId.value); map['album_id'] = i0.Variable<String>(albumId.value);
} }
if (marker_.present) {
map['marker'] = i0.Variable<bool>(marker_.value);
}
return map; return map;
} }
@@ -727,8 +658,7 @@ class LocalAlbumAssetEntityCompanion
String toString() { String toString() {
return (StringBuffer('LocalAlbumAssetEntityCompanion(') return (StringBuffer('LocalAlbumAssetEntityCompanion(')
..write('assetId: $assetId, ') ..write('assetId: $assetId, ')
..write('albumId: $albumId, ') ..write('albumId: $albumId')
..write('marker_: $marker_')
..write(')')) ..write(')'))
.toString(); .toString();
} }

View File

@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 11; int get schemaVersion => 10;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -156,9 +156,6 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor); await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
await m.alterTable(TableMigration(v10.userEntity)); await m.alterTable(TableMigration(v10.userEntity));
}, },
from10To11: (m, v11) async {
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
},
), ),
); );

View File

@@ -4270,395 +4270,6 @@ i1.GeneratedColumn<String> _column_94(String aliasedName) =>
true, true,
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
); );
final class Schema11 extends i0.VersionedSchema {
Schema11({required super.database}) : super(version: 11);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
idxLatLng,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape2 localAssetEntity = Shape2(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
class Shape22 extends i0.VersionedTable {
Shape22({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get albumId =>
columnsByName['album_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get marker_ =>
columnsByName['marker']! as i1.GeneratedColumn<bool>;
}
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -4669,7 +4280,6 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9, required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10, required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -4718,11 +4328,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from9To10(migrator, schema); await from9To10(migrator, schema);
return 10; return 10;
case 10:
final schema = Schema11(database: database);
final migrator = i1.Migrator(database, schema);
await from10To11(migrator, schema);
return 11;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -4739,7 +4344,6 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8, required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9, required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10, required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -4751,6 +4355,5 @@ i1.OnUpgrade stepByStep({
from7To8: from7To8, from7To8: from7To8,
from8To9: from8To9, from8To9: from8To9,
from9To10: from9To10, from9To10: from9To10,
from10To11: from10To11,
), ),
); );

View File

@@ -72,33 +72,17 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return Future.value(); return Future.value();
} }
return _db.transaction(() async { final deleteSmt = _db.localAssetEntity.delete();
await _db.managers.localAlbumAssetEntity deleteSmt.where((localAsset) {
.filter((row) => row.albumId.id.equals(albumId)) final subQuery = _db.localAlbumAssetEntity.selectOnly()
.update((album) => album(marker_: const Value(true))); ..addColumns([_db.localAlbumAssetEntity.assetId])
..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
await _db.batch((batch) { subQuery.where(
for (final assetId in assetIdsToKeep) { _db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
batch.update( );
_db.localAlbumAssetEntity, return localAsset.id.isInQuery(subQuery);
const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
);
}
});
final query = _db.localAssetEntity.delete()
..where(
(row) => row.id.isInQuery(
_db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(
_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
),
),
);
await query.go();
}); });
await deleteSmt.go();
} }
Future<void> upsert( Future<void> upsert(
@@ -214,9 +198,10 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
// List<String> // List<String>
await _db.batch((batch) async { await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) { assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
for (final albumId in albumIds.cast<String?>().nonNulls) { batch.deleteWhere(
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId)); _db.localAlbumAssetEntity,
} (f) => f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) & f.assetId.equals(assetId),
);
}); });
}); });
await _db.batch((batch) async { await _db.batch((batch) async {
@@ -303,14 +288,12 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
return transaction(() async { return transaction(() async {
if (assetsToUnLink.isNotEmpty) { if (assetsToUnLink.isNotEmpty) {
await _db.batch((batch) { await _db.batch(
for (final assetId in assetsToUnLink) { (batch) => batch.deleteWhere(
batch.deleteWhere( _db.localAlbumAssetEntity,
_db.localAlbumAssetEntity, (f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
(row) => row.assetId.equals(assetId) & row.albumId.equals(albumId), ),
); );
}
});
} }
await _deleteAssets(assetsToDelete); await _deleteAssets(assetsToDelete);
@@ -337,9 +320,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
} }
return _db.batch((batch) { return _db.batch((batch) {
for (final id in ids) { batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
}
}); });
} }

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -57,8 +58,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
} }
return _db.batch((batch) { return _db.batch((batch) {
for (final id in ids) { for (final slice in ids.slices(32000)) {
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id)); batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
} }
}); });
} }

View File

@@ -166,15 +166,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
); );
} }
Future<void> removeAssets(String albumId, List<String> assetIds) { Future<int> removeAssets(String albumId, List<String> assetIds) {
return _db.batch((batch) { return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
for (final assetId in assetIds) {
batch.deleteWhere(
_db.remoteAlbumAssetEntity,
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
);
}
});
} }
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {

View File

@@ -160,11 +160,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
} }
Future<void> delete(List<String> ids) { Future<void> delete(List<String> ids) {
return _db.batch((batch) { return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
for (final id in ids) {
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
}
});
} }
Future<void> updateLocation(List<String> ids, LatLng location) { Future<void> updateLocation(List<String> ids, LatLng location) {
@@ -203,11 +199,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
.map((row) => row.id) .map((row) => row.id)
.get(); .get();
await _db.batch((batch) { await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
for (final stackId in stackIds) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
}
});
await _db.batch((batch) { await _db.batch((batch) {
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId)); final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
@@ -227,21 +219,15 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
Future<void> unStack(List<String> stackIds) { Future<void> unStack(List<String> stackIds) {
return _db.transaction(() async { return _db.transaction(() async {
await _db.batch((batch) { await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
for (final stackId in stackIds) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
}
});
// TODO: delete this after adding foreign key on stackId // TODO: delete this after adding foreign key on stackId
await _db.batch((batch) { await _db.batch((batch) {
for (final stackId in stackIds) { batch.update(
batch.update( _db.remoteAssetEntity,
_db.remoteAssetEntity, const RemoteAssetEntityCompanion(stackId: Value(null)),
const RemoteAssetEntityCompanion(stackId: Value(null)), where: (e) => e.stackId.isIn(stackIds),
where: (e) => e.stackId.equals(stackId), );
);
}
}); });
}); });
} }

View File

@@ -33,7 +33,7 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(), personIds: filter.people.map((e) => e.id).toList(),
type: type, type: type,
page: page, page: page,
size: 100, size: 1000,
), ),
); );
} }

View File

@@ -93,11 +93,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async { Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
for (final user in data) {
batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: SyncUserDeleteV1', error, stack); _logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow; rethrow;
@@ -162,11 +158,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async { Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
try { try {
await _db.batch((batch) { await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
for (final asset in data) {
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack); _logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow; rethrow;
@@ -251,11 +243,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async { Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
for (final album in data) {
batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack); _logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow; rethrow;
@@ -391,11 +379,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async { Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
try { try {
await _db.batch((batch) { await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
for (final memory in data) {
batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack); _logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow; rethrow;
@@ -459,11 +443,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async { Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
try { try {
await _db.batch((batch) { await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
for (final stack in data) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
}
});
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack); _logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
rethrow; rethrow;

View File

@@ -1,57 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
@RoutePage()
class DownloadInfoPage extends ConsumerWidget {
const DownloadInfoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasks = ref.watch(downloadStateProvider.select((state) => state.taskProgress)).entries.toList();
onCancelDownload(String id) {
ref.watch(downloadStateProvider.notifier).cancelDownload(id);
}
return Scaffold(
appBar: AppBar(
title: Text("download".t(context: context)),
actions: [],
),
body: ListView.builder(
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DownloadTaskTile(
progress: task.value.progress,
fileName: task.value.fileName,
status: task.value.status,
onCancelDownload: () => onCancelDownload(task.key),
),
);
},
),
persistentFooterButtons: [
OutlinedButton(
onPressed: () {
tasks.map((e) => e.key).forEach(onCancelDownload);
},
style: OutlinedButton.styleFrom(side: BorderSide(color: context.colorScheme.primary)),
child: Text(
'clear_all'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
),
),
],
);
}
}

View File

@@ -633,7 +633,7 @@ class _SearchResultGrid extends ConsumerWidget {
groupBy: GroupAssetsBy.none, groupBy: GroupAssetsBy.none,
appBar: null, appBar: null,
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20), bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
snapToMonth: false, withScrubber: false,
), ),
), ),
), ),

View File

@@ -1,45 +1,54 @@
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/background_sync.provider.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/common/immich_toast.dart';
class DownloadActionButton extends ConsumerWidget { class DownloadActionButton extends ConsumerWidget {
final ActionSource source; final ActionSource source;
final bool menuItem;
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async { const DownloadActionButton({super.key, required this.source});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
try { final result = await ref.read(actionProvider.notifier).downloadAll(source);
await ref.read(actionProvider.notifier).downloadAll(source); ref.read(multiSelectProvider.notifier).reset();
Future.delayed(const Duration(seconds: 1), () async { if (!context.mounted) {
await backgroundSyncManager.syncLocal(); return;
await backgroundSyncManager.hashAssets(); }
});
} finally { if (!result.success) {
ref.read(multiSelectProvider.notifier).reset(); ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
} else if (result.count > 0) {
ImmichToast.show(
context: context,
msg: 'download_action_prompt'.t(context: context, args: {'count': result.count.toString()}),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.success,
);
} }
} }
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final backgroundManager = ref.watch(backgroundSyncProvider);
return BaseActionButton( return BaseActionButton(
iconData: Icons.download, iconData: Icons.download,
maxWidth: 95, maxWidth: 95,
label: "download".t(context: context), label: "download".t(context: context),
menuItem: menuItem, onPressed: () => _onTap(context, ref),
onPressed: () => _onTap(context, ref, backgroundManager),
); );
} }
} }

View File

@@ -1,64 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/routing/router.dart';
class DownloadStatusFloatingButton extends ConsumerWidget {
const DownloadStatusFloatingButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final shouldShow = ref.watch(downloadStateProvider.select((state) => state.showProgress));
final itemCount = ref.watch(downloadStateProvider.select((state) => state.taskProgress.length));
final isDownloading = ref
.watch(downloadStateProvider.select((state) => state.taskProgress))
.values
.where((element) => element.progress != 1)
.isNotEmpty;
return shouldShow
? Badge.count(
count: itemCount,
textColor: context.colorScheme.onPrimary,
backgroundColor: context.colorScheme.primary,
child: FloatingActionButton(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
backgroundColor: context.isDarkTheme
? context.colorScheme.surfaceContainer
: context.colorScheme.surfaceBright,
elevation: 2,
onPressed: () {
context.pushRoute(const DownloadInfoRoute());
},
child: Stack(
alignment: AlignmentDirectional.center,
children: [
isDownloading
? Icon(Icons.downloading_rounded, color: context.colorScheme.primary, size: 28)
: Icon(
Icons.download_done,
color: context.isDarkTheme ? Colors.green[200] : Colors.green[400],
size: 28,
),
if (isDownloading)
const SizedBox(
height: 31,
width: 31,
child: CircularProgressIndicator(
strokeWidth: 2,
backgroundColor: Colors.transparent,
value: null, // Indeterminate progress
),
),
],
),
),
)
: const SizedBox.shrink();
}
}

View File

@@ -12,7 +12,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart'; import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -650,25 +649,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
appBar: const ViewerTopAppBar(), appBar: const ViewerTopAppBar(),
extendBody: true, extendBody: true,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
floatingActionButton: const DownloadStatusFloatingButton(), body: PhotoViewGallery.builder(
body: Stack( gaplessPlayback: true,
children: [ loadingBuilder: _placeholderBuilder,
PhotoViewGallery.builder( pageController: pageController,
gaplessPlayback: true, scrollPhysics: CurrentPlatform.isIOS
loadingBuilder: _placeholderBuilder, ? const FastScrollPhysics() // Use bouncing physics for iOS
pageController: pageController, : const FastClampingScrollPhysics(), // Use heavy physics for Android
scrollPhysics: CurrentPlatform.isIOS itemCount: totalAssets,
? const FastScrollPhysics() // Use bouncing physics for iOS onPageChanged: _onPageChanged,
: const FastClampingScrollPhysics(), // Use heavy physics for Android onPageBuild: _onPageBuild,
itemCount: totalAssets, scaleStateChangedCallback: _onScaleStateChanged,
onPageChanged: _onPageChanged, builder: _assetBuilder,
onPageBuild: _onPageBuild, backgroundDecoration: BoxDecoration(color: backgroundColor),
scaleStateChangedCallback: _onScaleStateChanged, enablePanAlways: true,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
],
), ),
bottomNavigationBar: showingBottomSheet bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink() ? const SizedBox.shrink()

View File

@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
@@ -57,7 +56,6 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[ final actions = <Widget>[
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true), if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared) if (album != null && album.isActivityEnabled && album.isShared)
IconButton( IconButton(

View File

@@ -16,9 +16,8 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
final bool exact;
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution, this.exact = true}); LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
@override @override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) { Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -38,12 +37,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
} }
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
final request = this.request = LocalImageRequest(
localId: key.id,
size: key.size * devicePixelRatio,
assetType: key.assetType,
);
return loadRequest(request, decode); return loadRequest(request, decode);
} }
@@ -51,7 +45,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id && (!exact || size == other.size); return id == other.id;
} }
return false; return false;
} }
@@ -66,12 +60,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
LocalFullImageProvider({ LocalFullImageProvider({required this.id, required this.assetType, required this.size});
required this.id,
required this.assetType,
required this.size,
LocalThumbProvider? initialProvider,
});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -82,7 +71,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: id, assetType: assetType, exact: false)), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),

View File

@@ -2,7 +2,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256); const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(128); const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;

View File

@@ -121,7 +121,6 @@ class _FixedSegmentRow extends ConsumerWidget {
} }
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) { Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
final size = Size.square(tileHeight);
return FixedTimelineRow( return FixedTimelineRow(
dimension: tileHeight, dimension: tileHeight,
spacing: spacing, spacing: spacing,
@@ -135,7 +134,6 @@ class _FixedSegmentRow extends ConsumerWidget {
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i], asset: assets[i],
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
size: size,
), ),
), ),
], ],
@@ -146,9 +144,8 @@ class _FixedSegmentRow extends ConsumerWidget {
class _AssetTileWidget extends ConsumerWidget { class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset; final BaseAsset asset;
final int assetIndex; final int assetIndex;
final Size size;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size}); const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async { Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider); final multiSelectState = ref.read(multiSelectProvider);
@@ -206,7 +203,6 @@ class _AssetTileWidget extends ConsumerWidget {
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
size: size,
), ),
), ),
); );

View File

@@ -31,11 +31,6 @@ class Scrubber extends ConsumerStatefulWidget {
final double? monthSegmentSnappingOffset; final double? monthSegmentSnappingOffset;
final bool snapToMonth;
/// Whether an app bar is present, affects coordinate calculations
final bool hasAppBar;
Scrubber({ Scrubber({
super.key, super.key,
Key? scrollThumbKey, Key? scrollThumbKey,
@@ -44,8 +39,6 @@ class Scrubber extends ConsumerStatefulWidget {
this.topPadding = 0, this.topPadding = 0,
this.bottomPadding = 0, this.bottomPadding = 0,
this.monthSegmentSnappingOffset, this.monthSegmentSnappingOffset,
this.snapToMonth = true,
this.hasAppBar = true,
required this.child, required this.child,
}) : assert(child.scrollDirection == Axis.vertical); }) : assert(child.scrollDirection == Axis.vertical);
@@ -239,7 +232,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
} }
} }
if (_monthCount < kMinMonthsToEnableScrubberSnap || !widget.snapToMonth) { if (_monthCount < kMinMonthsToEnableScrubberSnap) {
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments // If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
setState(() { setState(() {
_thumbTopOffset = dragPosition; _thumbTopOffset = dragPosition;
@@ -266,28 +259,14 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
/// - If user drags to global Y position that's 100 pixels from the top /// - If user drags to global Y position that's 100 pixels from the top
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area) /// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
double _calculateDragPosition(DragUpdateDetails details) { double _calculateDragPosition(DragUpdateDetails details) {
if (widget.hasAppBar) {
final dragAreaTop = widget.topPadding;
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - dragAreaTop;
final relativePosition = details.globalPosition.dy - dragAreaTop;
// Make sure the position stays within the scrubber's bounds
return relativePosition.clamp(0.0, dragAreaHeight);
}
// Get the local position relative to the gesture detector
final RenderBox? renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final localPosition = renderBox.globalToLocal(details.globalPosition);
return localPosition.dy.clamp(0.0, _scrubberHeight);
}
// Fallback to current logic if render box is not available
final dragAreaTop = widget.topPadding; final dragAreaTop = widget.topPadding;
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - dragAreaTop;
final relativePosition = details.globalPosition.dy - dragAreaTop; final relativePosition = details.globalPosition.dy - dragAreaTop;
return relativePosition.clamp(0.0, _scrubberHeight);
// Make sure the position stays within the scrubber's bounds
return relativePosition.clamp(0.0, dragAreaHeight);
} }
/// Find the segment closest to the given position /// Find the segment closest to the given position
@@ -343,8 +322,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_isDragging = false; _isDragging = false;
}); });
ref.read(timelineStateProvider.notifier).setScrubbing(false);
// Reset scrubber tracking when drag ends // Reset scrubber tracking when drag ends
_currentScrubberDate = null; _currentScrubberDate = null;
_scrubberDebouncer?.dispose(); _scrubberDebouncer?.dispose();

View File

@@ -14,7 +14,6 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
@@ -39,7 +38,6 @@ class Timeline extends StatelessWidget {
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18), this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
this.groupBy, this.groupBy,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -50,13 +48,11 @@ class Timeline extends StatelessWidget {
final bool withStack; final bool withStack;
final GroupAssetsBy? groupBy; final GroupAssetsBy? groupBy;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder( body: LayoutBuilder(
builder: (_, constraints) => ProviderScope( builder: (_, constraints) => ProviderScope(
overrides: [ overrides: [
@@ -77,7 +73,6 @@ class Timeline extends StatelessWidget {
appBar: appBar, appBar: appBar,
bottomSheet: bottomSheet, bottomSheet: bottomSheet,
withScrubber: withScrubber, withScrubber: withScrubber,
snapToMonth: snapToMonth,
), ),
), ),
), ),
@@ -92,7 +87,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
this.snapToMonth = true,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -100,7 +94,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
final bool snapToMonth;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@@ -316,13 +309,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final Widget timeline; final Widget timeline;
if (widget.withScrubber) { if (widget.withScrubber) {
timeline = Scrubber( timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments, layoutSegments: segments,
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: topPadding, topPadding: topPadding,
bottomPadding: bottomPadding, bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid, child: grid,
); );
} else { } else {

View File

@@ -356,6 +356,7 @@ class ActionNotifier extends Notifier<void> {
Future<ActionResult> downloadAll(ActionSource source) async { Future<ActionResult> downloadAll(ActionSource source) async {
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false); final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
try { try {
final didEnqueue = await _service.downloadAll(assets); final didEnqueue = await _service.downloadAll(assets);
final enqueueCount = didEnqueue.where((e) => e).length; final enqueueCount = didEnqueue.where((e) => e).length;

View File

@@ -90,11 +90,7 @@ class DownloadRepository {
final isVideo = asset.isVideo; final isVideo = asset.isVideo;
final url = getOriginalUrlForRemoteId(id); final url = getOriginalUrlForRemoteId(id);
// on iOS it cannot link the image, check if the filename has .MP extension if (Platform.isAndroid || livePhotoVideoId == null || isVideo) {
// to avoid downloading the video part
final isAndroidMotionPhoto = asset.name.contains(".MP");
if (Platform.isAndroid || livePhotoVideoId == null || isVideo || isAndroidMotionPhoto) {
tasks[taskIndex++] = DownloadTask( tasks[taskIndex++] = DownloadTask(
taskId: id, taskId: id,
url: url, url: url,

View File

@@ -81,7 +81,6 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart'; import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
@@ -346,7 +345,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart // required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722 // auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'), RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -688,22 +688,6 @@ class CropImageRouteArgs {
} }
} }
/// generated route for
/// [DownloadInfoPage]
class DownloadInfoRoute extends PageRouteInfo<void> {
const DownloadInfoRoute({List<PageRouteInfo>? children})
: super(DownloadInfoRoute.name, initialChildren: children);
static const String name = 'DownloadInfoRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DownloadInfoPage();
},
);
}
/// generated route for /// generated route for
/// [DriftActivitiesPage] /// [DriftActivitiesPage]
class DriftActivitiesRoute extends PageRouteInfo<void> { class DriftActivitiesRoute extends PageRouteInfo<void> {

View File

@@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -10,7 +11,6 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart'; import 'package:immich_mobile/widgets/common/date_time_picker.dart';
@@ -199,11 +199,14 @@ class ActionService {
} }
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async { Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
int removedCount = 0;
final result = await _albumApiRepository.removeAssets(albumId, remoteIds); final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
if (result.removed.isNotEmpty) { if (result.removed.isNotEmpty) {
await _remoteAlbumRepository.removeAssets(albumId, result.removed); removedCount = await _remoteAlbumRepository.removeAssets(albumId, result.removed);
} }
return result.removed.length;
return removedCount;
} }
Future<bool> updateDescription(String assetId, String description) async { Future<bool> updateDescription(String assetId, String description) async {

View File

@@ -13,7 +13,6 @@ import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8; import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9; import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10; import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -39,12 +38,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v9.DatabaseAtV9(db); return v9.DatabaseAtV9(db);
case 10: case 10:
return v10.DatabaseAtV10(db); return v10.DatabaseAtV10(db);
case 11:
return v11.DatabaseAtV11(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
} }

File diff suppressed because it is too large Load Diff

View File

@@ -57,28 +57,28 @@ export class MediaRepository {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input); const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
return { buffer, format: RawExtractedFormat.Jpeg }; return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) { } catch (error: any) {
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`); this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message);
} }
try { try {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input); const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
return { buffer, format: RawExtractedFormat.Jpeg }; return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) { } catch (error: any) {
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`); this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message);
} }
try { try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input); const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return { buffer, format: RawExtractedFormat.Jxl }; return { buffer, format: RawExtractedFormat.Jxl };
} catch (error: any) { } catch (error: any) {
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`); this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message);
} }
try { try {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input); const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.Jpeg }; return { buffer, format: RawExtractedFormat.Jpeg };
} catch (error: any) { } catch (error: any) {
this.logger.debug(`Could not extract preview buffer from image: ${error}`); this.logger.debug('Could not extract preview buffer from image', error.message);
return null; return null;
} }
} }

View File

@@ -103,7 +103,7 @@ export class MetadataRepository {
readTags(path: string): Promise<ImmichTags> { readTags(path: string): Promise<ImmichTags> {
return this.exiftool.read(path).catch((error) => { return this.exiftool.read(path).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`); this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
return {}; return {};
}) as Promise<ImmichTags>; }) as Promise<ImmichTags>;
} }

View File

@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } }); await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
} }
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.warn(`Unable to sync oauth profile picture: ${error}\n${error?.stack}`); this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
} }
} }

View File

@@ -132,12 +132,12 @@ export class BackupService extends BaseService {
gzip.stdout.pipe(fileStream); gzip.stdout.pipe(fileStream);
pgdump.on('error', (err) => { pgdump.on('error', (err) => {
this.logger.error(`Backup failed with error: ${err}`); this.logger.error('Backup failed with error', err);
reject(err); reject(err);
}); });
gzip.on('error', (err) => { gzip.on('error', (err) => {
this.logger.error(`Gzip failed with error: ${err}`); this.logger.error('Gzip failed with error', err);
reject(err); reject(err);
}); });
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
}); });
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', '')); await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
} catch (error) { } catch (error) {
this.logger.error(`Database Backup Failure: ${error}`); this.logger.error('Database Backup Failure', error);
await this.storageRepository await this.storageRepository
.unlink(backupFilePath) .unlink(backupFilePath)
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`)); .catch((error) => this.logger.error('Failed to delete failed backup file', error));
throw error; throw error;
} }

View File

@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
job.paths.map((path) => job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId) this.processEntity(path, library.ownerId, job.libraryId)
.then((asset) => assetImports.push(asset)) .then((asset) => assetImports.push(asset))
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)), .catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)),
), ),
); );

View File

@@ -40,7 +40,7 @@ export class MemoryService extends BaseService {
try { try {
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
} catch (error) { } catch (error) {
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`); this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
} }
// update system metadata even when there is an error to minimize the chance of duplicates // update system metadata even when there is an error to minimize the chance of duplicates
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, { await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {

View File

@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
return destination; return destination;
} catch (error: any) { } catch (error: any) {
this.logger.error(`Unable to get template path for ${filename}: ${error}`); this.logger.error(`Unable to get template path for ${filename}`, error);
return asset.originalPath; return asset.originalPath;
} }
} }

View File

@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata)); this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
} }
} catch (error: Error | any) { } catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`); this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
return JobStatus.Failed; return JobStatus.Failed;
} }

View File

@@ -73,7 +73,7 @@ export const sendFile = async (
// log non-http errors // log non-http errors
if (error instanceof HttpException === false) { if (error instanceof HttpException === false) {
logger.error(`Unable to send file: ${error}`, error.stack); logger.error(`Unable to send file: ${error.name}`, error.stack);
} }
res.header('Cache-Control', 'none'); res.header('Cache-Control', 'none');

View File

@@ -240,7 +240,7 @@
use:zoomImageAction use:zoomImageAction
use:swipe={() => ({})} use:swipe={() => ({})}
onswipe={onSwipe} onswipe={onSwipe}
class="h-full w-full flex" class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }} transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
> >
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
@@ -255,7 +255,7 @@
bind:this={$photoViewerImgElement} bind:this={$photoViewerImgElement}
src={assetFileUrl} src={assetFileUrl}
alt={$getAltText(toTimelineAsset(asset))} alt={$getAltText(toTimelineAsset(asset))}
class="max-h-full max-w-full h-auto w-auto mx-auto my-auto {$slideshowState === SlideshowState.None class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain' ? 'object-contain'
: slideshowLookCssMapping[$slideshowLook]}" : slideshowLookCssMapping[$slideshowLook]}"
draggable="false" draggable="false"

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE;
handlePreAction?: (action: Action) => Promise<void>;
handleAction?: (action: Action) => void;
handleNext?: () => Promise<boolean>;
handlePrevious?: () => Promise<boolean>;
handleRandom?: () => Promise<AssetResponseDto | undefined>;
handleClose?: (asset: { id: string }) => Promise<void>;
}
let {
timelineManager = $bindable(),
showSkeleton = $bindable(false),
removeAction,
handlePreAction = $bindable(),
handleAction = $bindable(),
handleNext = $bindable(),
handlePrevious = $bindable(),
handleRandom = $bindable(),
handleClose = $bindable(),
}: Props = $props();
handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
release();
return !!laterAsset;
};
handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
release();
return !!earlierAsset;
};
handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
};
handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
break;
}
}
};
handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.updateAssets([action.asset]);
break;
}
case AssetAction.ADD: {
timelineManager.addAssets([action.asset]);
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.addAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
}
};
</script>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import AssetViewerActions from '$lib/components/photos-page/asset-viewer-actions.svelte';
import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
}
let {
timelineManager = $bindable(),
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
isShowDeleteConfirmation = $bindable(false),
}: Props = $props();
let handlePreAction = <(action: Action) => Promise<void>>$state();
let handleAction = <(action: Action) => void>$state();
let handleNext = <() => Promise<boolean>>$state();
let handlePrevious = <() => Promise<boolean>>$state();
let handleRandom = <() => Promise<AssetResponseDto | undefined>>$state();
let handleClose = <(asset: { id: string }) => Promise<void>>$state();
</script>
<AssetViewerActions
{timelineManager}
{removeAction}
bind:showSkeleton
bind:handlePreAction
bind:handleAction
bind:handleNext
bind:handlePrevious
bind:handleRandom
bind:handleClose
></AssetViewerActions>
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}

View File

@@ -3,7 +3,7 @@
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types'; import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiPlay } from '@mdi/js'; import { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
@@ -11,18 +11,31 @@
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
interface Props { interface Props {
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number; timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
timelineBottomOffset?: number; timelineBottomOffset?: number;
/** Total height of the scrubber component */
height?: number; height?: number;
/** Timeline manager instance that controls the timeline state */
timelineManager: TimelineManager; timelineManager: TimelineManager;
scrubOverallPercent?: number; /** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
scrubberMonthPercent?: number; timelineScrollPercent?: number;
scrubberMonth?: { year: number; month: number }; /** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
leadout?: boolean; viewportTopMonthScrollPercent?: number;
/** The year/month of the timeline month at the top of the viewport */
viewportTopMonth?: TimelineYearMonth;
/** Indicates whether the viewport is currently in the lead-out section (after all months) */
isInLeadOutSection?: boolean;
/** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */
scrubberWidth?: number; scrubberWidth?: number;
/** Callback fired when user interacts with the scrubber to navigate */
onScrub?: ScrubberListener; onScrub?: ScrubberListener;
/** Callback fired when keyboard events occur on the scrubber */
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
/** Callback fired when scrubbing starts */
startScrub?: ScrubberListener; startScrub?: ScrubberListener;
/** Callback fired when scrubbing stops */
stopScrub?: ScrubberListener; stopScrub?: ScrubberListener;
} }
@@ -31,10 +44,10 @@
timelineBottomOffset = 0, timelineBottomOffset = 0,
height = 0, height = 0,
timelineManager, timelineManager,
scrubOverallPercent = 0, timelineScrollPercent = 0,
scrubberMonthPercent = 0, viewportTopMonthScrollPercent = 0,
scrubberMonth = undefined, viewportTopMonth = undefined,
leadout = false, isInLeadOutSection = false,
onScrub = undefined, onScrub = undefined,
onScrubKeyDown = undefined, onScrubKeyDown = undefined,
startScrub = undefined, startScrub = undefined,
@@ -100,7 +113,7 @@
offset += scrubberMonthPercent * relativeBottomOffset; offset += scrubberMonthPercent * relativeBottomOffset;
} }
return offset; return offset;
} else if (leadout) { } else if (isInLeadOutSection) {
let offset = relativeTopOffset; let offset = relativeTopOffset;
for (const segment of segments) { for (const segment of segments) {
offset += segment.height; offset += segment.height;
@@ -111,7 +124,9 @@
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)); return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
} }
}; };
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent)); let scrollY = $derived(
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
);
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset); let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight)); let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
@@ -295,12 +310,24 @@
const scrollPercent = toTimelineY(hoverY); const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) { if (wasDragging === false && isDragging) {
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); void startScrub?.({
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
} }
if (wasDragging && !isDragging) { if (wasDragging && !isDragging) {
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); void stopScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
return; return;
} }
@@ -308,7 +335,11 @@
return; return;
} }
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY); void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
}; };
/* eslint-disable tscompat/tscompat */ /* eslint-disable tscompat/tscompat */
const getTouch = (event: TouchEvent) => { const getTouch = (event: TouchEvent) => {
@@ -412,7 +443,11 @@
} }
if (next) { if (next) {
event.preventDefault(); event.preventDefault();
void onScrub?.({ year: next.year, month: next.month }, -1, 0); void onScrub?.({
scrubberMonth: { year: next.year, month: next.month },
overallScrollPercent: -1,
scrubberMonthScrollPercent: 0,
});
return true; return true;
} }
} }
@@ -422,7 +457,11 @@
const next = segments[idx + 1]; const next = segments[idx + 1];
if (next) { if (next) {
event.preventDefault(); event.preventDefault();
void onScrub?.({ year: next.year, month: next.month }, -1, 0); void onScrub?.({
scrubberMonth: { year: next.year, month: next.month },
overallScrollPercent: -1,
scrubberMonthScrollPercent: 0,
});
return true; return true;
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { Checkbox, ConfirmModal, Label } from '@immich/ui';
import { mdiDeleteForeverOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
size: number;
onConfirm: () => void;
onCancel: () => void;
}
let { size, onConfirm, onCancel }: Props = $props();
let checked = $state(false);
const handleConfirm = () => {
if (checked) {
$showDeleteModal = false;
}
onConfirm();
};
</script>
<ConfirmModal
title={$t('permanently_delete_assets_count', { values: { count: size } })}
confirmText={$t('delete')}
icon={mdiDeleteForeverOutline}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
>
{#snippet promptSnippet()}
<p>
<FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}>
{#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage>
</p>
<p><b>{$t('cannot_undo_this_action')}</b></p>
<div class="pt-4 flex justify-center items-center gap-2">
<Checkbox id="confirm-deletion-input" bind:checked color="secondary" />
<Label label={$t('do_not_show_again')} for="confirm-deletion-input" />
</div>
{/snippet}
</ConfirmModal>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import ChangeDate, {
type AbsoluteResult,
type RelativeResult,
} from '$lib/components/shared-components/change-date.svelte';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { AssetVisibility } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
import DeleteAssetDialog from './delete-asset-dialog.svelte';
let { isViewing: showAssetViewer } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
isShowDeleteConfirmation: boolean;
onEscape?: () => void;
scrollToAsset: (asset: TimelineAsset) => boolean;
}
let {
timelineManager = $bindable(),
assetInteraction,
isShowDeleteConfirmation = $bindable(false),
onEscape,
scrollToAsset,
}: Props = $props();
let isShowSelectDate = $state(false);
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
);
assetInteraction.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
updateStackedAssetInTimeline(timelineManager, result);
onEscape?.();
};
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets();
};
let shiftKeyIsDown = $state(false);
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const onSelectStart = (e: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
e.preventDefault();
}
};
const isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: () => (isShowSelectDate = true) },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
{#if isShowDeleteConfirmation}
<DeleteAssetDialog
size={idsSelectedAssets.length}
onCancel={() => (isShowDeleteConfirmation = false)}
onConfirm={() => handlePromiseError(trashOrDelete(true))}
/>
{/if}
{#if isShowSelectDate}
<!-- remove for now confirmText={$t('navigate')} -->
<ChangeDate
withDuration={false}
title={$t('navigate_to_time')}
initialDate={DateTime.now()}
timezoneInput={false}
onConfirm={async (result: AbsoluteResult | RelativeResult) => {
isShowSelectDate = false;
if (result.mode === 'absolute') {
const asset = await timelineManager.getClosestAssetToDate(
(DateTime.fromISO(result.date) as DateTime<true>).toObject(),
);
if (asset) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}

View File

@@ -0,0 +1,318 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/timeline/base-components/skeleton.svelte';
import SelectableTimelineMonth from '$lib/components/timeline/internal-components/selectable-timeline-month.svelte';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: boolean;
/** `true` if this asset grid responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
styleMarginRightOverride?: string;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
header?: Snippet<[scrollToFunction: (top: number) => void]>;
children?: Snippet;
empty?: Snippet;
handleTimelineScroll?: () => void;
}
let {
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
enableRouting,
timelineManager = $bindable(),
assetInteraction,
withStacked = false,
showSkeleton = $bindable(true),
showArchiveIcon = false,
styleMarginRightOverride,
isShowDeleteConfirmation = $bindable(false),
onAssetOpen,
onSelect,
children,
empty,
header,
handleTimelineScroll = () => {},
}: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
const maxMd = $derived(mobileDevice.maxMd);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
const layoutOptions = maxMd
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
timelineManager.setLayoutOptions(layoutOptions);
});
const scrollTo = (top: number) => {
if (element) {
element.scrollTo({ top });
}
updateSlidingWindow();
};
const scrollBy = (y: number) => {
if (element) {
element.scrollBy(0, y);
}
updateSlidingWindow();
};
const scrollCompensation = (compensation: { heightDelta?: number; scrollTop?: number }) => {
const { heightDelta, scrollTop } = compensation;
if (heightDelta !== undefined) {
scrollBy(heightDelta);
} else if (scrollTop !== undefined) {
scrollTo(scrollTop);
}
timelineManager.clearScrollCompensation();
};
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => {
// the following method may trigger any layouts, so need to
// handle any scroll compensation that may have been set
const height = monthGroup!.findAssetAbsolutePosition(assetId);
// this is in a while loop, since scrollCompensations invoke scrolls
// which may load months, triggering more scrollCompensations. Call
// this in a loop, until no more layouts occur.
while (timelineManager.scrollCompensation.monthGroup) {
scrollCompensation(timelineManager.scrollCompensation);
}
return height;
};
const assetIsVisible = (assetTop: number): boolean => {
if (!element) {
return false;
}
const { clientHeight, scrollTop } = element;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
};
const scrollToAssetId = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(assetId, monthGroup);
// If the asset is already visible, then don't scroll.
if (assetIsVisible(height)) {
return true;
}
scrollTo(height);
return true;
};
export const scrollToAsset = (asset: TimelineAsset) => {
const monthGroup = timelineManager.getMonthGroupByAssetId(asset.id);
if (!monthGroup) {
return false;
}
const height = getAssetHeight(asset.id, monthGroup);
scrollTo(height);
return true;
};
const completeNav = async () => {
const scrollTarget = $gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollToAssetId(scrollTarget);
}
if (!scrolled) {
// if the asset is not found, scroll to the top
scrollTo(0);
}
showSkeleton = false;
};
beforeNavigate(() => (timelineManager.suspendTransitions = true));
afterNavigate((nav) => {
const { complete } = nav;
complete.then(completeNav, completeNav);
});
const updateIsScrolling = () => (timelineManager.scrolling = true);
// Yes, updateSlideWindow() is called by the onScroll event. However, if you also just scrolled
// by explicitly invoking element.scrollX functions, there may be a delay with enough time to
// set the intersecting property of the monthGroup to false, then true, which causes the DOM
// nodes to be recreated, causing bad perf, and also, disrupting focus of those elements.
// Also note: don't throttle, debounce, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => {
if (!enableRouting) {
showSkeleton = false;
}
});
</script>
<HotModuleReload
onAfterUpdate={(payload: UpdatePayload) => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
// this handler will run the navigation/scroll-to-asset handler when hmr is performed,
// preventing skeleton from showing after hmr
const finishHmr = () => {
const asset = $page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
}
void completeNav();
};
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('base-timeline-viewer.svelte'));
if (assetGridUpdate) {
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}
}}
/>
{@render header?.(scrollTo)}
<!-- Right margin MUST be equal to the width of scrubber -->
<section
id="asset-grid"
class={['scrollbar-hidden h-full overflow-y-auto outline-none', { 'm-0': isEmpty }, { 'ms-0': !isEmpty }]}
style:margin-right={styleMarginRightOverride}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
class:invisible={showSkeleton}
style:height={timelineManager.timelineHeight + 'px'}
>
<section
use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton}
style:position="absolute"
style:left="0"
style:right="0"
>
{@render children?.()}
{#if isEmpty}
<!-- (optional) empty placeholder -->
{@render empty?.()}
{/if}
</section>
{#each timelineManager.months as monthGroup (monthGroup.viewId)}
{@const display = monthGroup.intersecting}
{@const absoluteHeight = monthGroup.top}
{#if !monthGroup.isLoaded}
<div
style:height={monthGroup.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<Skeleton
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
title={monthGroup.monthGroupTitle}
/>
</div>
{:else if display}
<div
class="month-group"
style:height={monthGroup.height + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<SelectableTimelineMonth
{customThumbnailLayout}
{withStacked}
{showArchiveIcon}
{assetInteraction}
{timelineManager}
{isSelectionMode}
{singleSelect}
{monthGroup}
{onAssetOpen}
onSelect={(isSingleSelect: boolean, asset: TimelineAsset) => {
if (isSingleSelect) {
scrollTo(0);
}
onSelect?.(asset);
}}
onScrollCompensationMonthInDOM={scrollCompensation}
/>
</div>
{/if}
{/each}
<!-- spacer for lead-out -->
<div
class="h-[60px]"
style:position="absolute"
style:left="0"
style:right="0"
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
></div>
</section>
</section>
<style>
#asset-grid {
contain: strict;
scrollbar-width: none;
}
.month-group {
contain: layout size paint;
transform-style: flat;
backface-visibility: hidden;
transform-origin: center center;
}
</style>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import BaseTimelineViewer from '$lib/components/timeline/base-components/base-timeline-viewer.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import type { Snippet } from 'svelte';
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode?: boolean;
singleSelect?: boolean;
/** `true` if this timeline responds to navigation events; if `true`, then look at the
`AssetViewingStore.gridScrollTarget` and load and scroll to the asset specified, and
additionally, update the page location/url with the asset as the asset-grid is scrolled */
enableRouting: boolean;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
withStacked?: boolean;
showArchiveIcon?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (asset: TimelineAsset) => void;
children?: Snippet;
empty?: Snippet;
}
let {
customThumbnailLayout,
isSelectionMode = false,
singleSelect = false,
enableRouting,
timelineManager = $bindable(),
assetInteraction,
withStacked = false,
showArchiveIcon = false,
showSkeleton = $bindable(true),
isShowDeleteConfirmation = $bindable(false),
onAssetOpen,
onSelect = () => {},
children,
empty,
}: Props = $props();
const VIEWPORT_MULTIPLIER = 2; // Used to determine if timeline is "small"
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
// Note: There may be multiple months visible within the viewport at any given time.
let viewportTopMonthScrollPercent = $state(0);
// The timeline month intersecting the top position of the viewport
let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined);
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
// Indicates whether the viewport is currently in the lead-out section (after all months)
let isInLeadOutSection = $state(false);
// Width of the scrubber component in pixels, used to adjust timeline margins
let scrubberWidth: number = $state(0);
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function updates the scrubber position based on the current scroll position in the timeline
const handleTimelineScroll = () => {
isInLeadOutSection = false;
// Handle edge cases: small timeline (limited scroll) or lead-in area scrolling
const top = timelineManager.visibleWindow.top;
if (isSmallTimeline() || top < timelineManager.topSectionHeight) {
calculateTimelineScrollPercent();
return;
}
// Handle normal month scrolling
handleMonthScroll();
};
const handleMonthScroll = () => {
const scrollPosition = timelineManager.visibleWindow.top;
const months = timelineManager.months;
const maxScrollPercent = timelineManager.getMaxScrollPercent();
// Find the month at the current scroll position
const searchResult = findMonthAtScrollPosition(months, scrollPosition, maxScrollPercent);
if (searchResult) {
viewportTopMonth = searchResult.month;
viewportTopMonthScrollPercent = searchResult.monthScrollPercent;
isInLeadOutSection = false;
return;
}
// We're in lead-out section
isInLeadOutSection = true;
timelineScrollPercent = 1;
resetScrubberMonth();
};
const resetScrubberMonth = () => {
viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0;
};
const calculateTimelineScrollPercent = () => {
const maxScroll = timelineManager.getMaxScroll();
timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll);
resetScrubberMonth();
};
const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => {
const maxScroll = timelineManager.getMaxScroll();
const offset = maxScroll * percent;
scrollTo?.(offset);
};
const findMonthGroup = (target: TimelineYearMonth) => {
return timelineManager.months.find(
({ yearMonth }) => yearMonth.year === target.year && yearMonth.month === target.month,
);
};
const isSmallTimeline = () => {
return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER;
};
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction
const onScrub: ScrubberListener = (scrubberData) => {
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent, scrollToFunction } = scrubberData;
// Handle edge case or no month selected
if (!scrubberMonth || isSmallTimeline()) {
handleOverallPercentScroll(overallScrollPercent, scrollToFunction);
return;
}
// Find and scroll to the selected month
const monthGroup = findMonthGroup(scrubberMonth);
if (monthGroup) {
scrollToPositionWithinMonth(monthGroup, scrubberMonthScrollPercent, scrollToFunction);
}
};
const scrollToPositionWithinMonth = (
monthGroup: MonthGroup,
monthGroupScrollPercent: number,
handleScrollTop?: (top: number) => void,
) => {
const topOffset = monthGroup.top;
const maxScrollPercent = timelineManager.getMaxScrollPercent();
const delta = monthGroup.height * monthGroupScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent;
handleScrollTop?.(scrollToTop);
};
let baseTimelineViewer: BaseTimelineViewer | undefined = $state();
export const scrollToAsset = (asset: TimelineAsset) => baseTimelineViewer?.scrollToAsset(asset) ?? false;
</script>
<BaseTimelineViewer
bind:this={baseTimelineViewer}
{customThumbnailLayout}
{isSelectionMode}
{singleSelect}
{enableRouting}
{timelineManager}
{assetInteraction}
{withStacked}
{showArchiveIcon}
{showSkeleton}
{isShowDeleteConfirmation}
styleMarginRightOverride={scrubberWidth + 'px'}
{onAssetOpen}
{onSelect}
{children}
{empty}
{handleTimelineScroll}
>
{#snippet header(scrollToFunction)}
{#if timelineManager.months.length > 0}
<Scrubber
{timelineManager}
height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={timelineManager.bottomSectionHeight}
{isInLeadOutSection}
{timelineScrollPercent}
{viewportTopMonthScrollPercent}
{viewportTopMonth}
onScrub={(scrubberData) => onScrub({ ...scrubberData, scrollToFunction })}
bind:scrubberWidth
/>
{/if}
{/snippet}
</BaseTimelineViewer>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
interface Props {
height: number;
title: string;
}
let { height = 0, title }: Props = $props();
</script>
<div class="overflow-clip" style:height={height + 'px'}>
<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"
>
{title}
</div>
<div class="animate-pulse absolute h-full w-full" data-skeleton="true"></div>
</div>
<style>
[data-skeleton] {
background-image: url('/light_skeleton.png');
background-repeat: repeat;
background-size: 235px, 235px;
}
@media (max-width: 767px) {
[data-skeleton] {
background-size: 100px, 100px;
}
}
:global(.dark) [data-skeleton] {
background-image: url('/dark_skeleton.png');
}
@keyframes delayedVisibility {
to {
visibility: visible;
}
}
[data-skeleton] {
visibility: hidden;
animation:
0s linear 0.1s forwards delayedVisibility,
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { flip } from 'svelte/animate';
import { fly, scale } from 'svelte/transition';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { Snippet } from 'svelte';
let { isUploading } = uploadAssetsStore;
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
onHover: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
// these should be replaced with reactive properties in timeline-manager.svelte.ts
isDayGroupSelected: (dayGroup: DayGroup) => boolean;
isAssetSelected: (asset: TimelineAsset) => boolean;
isAssetSelectionCandidate: (asset: TimelineAsset) => boolean;
isAssetDisabled: (asset: TimelineAsset) => boolean;
}
let {
customThumbnailLayout,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup,
timelineManager,
onScrollCompensationMonthInDOM,
onHover,
onAssetOpen,
onAssetSelect,
onDayGroupSelect,
isDayGroupSelected,
isAssetSelected,
isAssetSelectionCandidate,
isAssetDisabled,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
return intersectables.filter((intersectable) => intersectable.intersecting);
}
$effect.root(() => {
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
}
});
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
hoveredDayGroup = dayGroup.groupTitle;
}}
onmouseleave={() => {
isMouseOverGroup = false;
hoveredDayGroup = null;
}}
>
<!-- Date group title -->
<div
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"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect && ((hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) || isDayGroupSelected(dayGroup))}
<div
transition:fly={{ x: -24, duration: 200, opacity: 0.5 }}
class="inline-block pe-2 hover:cursor-pointer"
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected(dayGroup)}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={dayGroup.groupTitleFull}>
{dayGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<div
data-image-grid
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={() => onAssetOpen?.(dayGroup, assetSnapshot(asset))}
onSelect={() => onAssetSelect(dayGroup, assetSnapshot(asset))}
onMouseEvent={() => onHover(dayGroup, assetSnapshot(asset))}
selected={isAssetSelected(asset)}
selectionCandidate={isAssetSelectionCandidate(asset)}
disabled={isAssetDisabled(asset)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customThumbnailLayout}
{@render customThumbnailLayout(asset)}
{/if}
</div>
{/each}
</div>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation';
import TimelineMonth from '$lib/components/timeline/base-components/timeline-month.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import { searchStore } from '$lib/stores/search.svelte';
import type { Snippet } from 'svelte';
interface Props {
customThumbnailLayout?: Snippet<[TimelineAsset]>;
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
onAssetOpen?: (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => void;
onSelect?: (isSingleSelect: boolean, asset: TimelineAsset) => void;
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
}
let {
customThumbnailLayout,
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
timelineManager,
onAssetOpen,
onSelect,
onScrollCompensationMonthInDOM,
}: Props = $props();
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let shiftKeyIsDown = $state(false);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
if (!lastAssetMouseEvent || !lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
if (shiftKeyIsDown && lastAssetMouseEvent) {
void selectAssetCandidates(lastAssetMouseEvent);
}
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const defaultAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset) => {
if (isSelectionMode || assetInteraction.selectionActive) {
handleAssetSelect(dayGroup, asset);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleOnAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset) => {
if (onAssetOpen) {
onAssetOpen(dayGroup, asset, () => defaultAssetOpen(dayGroup, asset));
return;
}
defaultAssetOpen(dayGroup, asset);
};
// called when clicking asset with shift key pressed or with mouse
const handleAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
void onSelectAssets(asset);
const assetsInDayGroup = dayGroup.getAssets();
const groupTitle = dayGroup.groupTitle;
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleOnHover = (dayGroup: DayGroup, asset: TimelineAsset) => {
if (assetInteraction.selectionActive) {
void selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const handleDayGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
}
} else {
assetInteraction.addGroupToMultiselectGroup(group);
for (const asset of assets) {
handleSelectAsset(asset);
}
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
onSelect?.(singleSelect, asset);
if (singleSelect) {
// onScrollToTop();
return;
}
const rangeSelection = assetInteraction.assetSelectionCandidates.length > 0;
const deselect = assetInteraction.hasSelectedAsset(asset.id);
// Select/deselect already loaded assets
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
handleSelectAsset(candidate);
}
handleSelectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
if (assetInteraction.assetSelectionStart && rangeSelection) {
let startBucket = timelineManager.getMonthGroupByAssetId(assetInteraction.assetSelectionStart.id);
let endBucket = timelineManager.getMonthGroupByAssetId(asset.id);
if (startBucket === null || endBucket === null) {
return;
}
// Select/deselect assets in range (start,end)
let started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === endBucket) {
break;
}
if (started) {
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
for (const asset of monthGroup.assetsIterator()) {
if (deselect) {
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
handleSelectAsset(asset);
}
}
}
if (monthGroup === startBucket) {
started = true;
}
}
// Update date group selection in range [start,end]
started = false;
for (const monthGroup of timelineManager.months) {
if (monthGroup === startBucket) {
started = true;
}
if (started) {
// Split month group into day groups and check each group
for (const dayGroup of monthGroup.dayGroups) {
const dayGroupTitle = dayGroup.groupTitle;
if (dayGroup.getAssets().every((a) => assetInteraction.hasSelectedAsset(a.id))) {
assetInteraction.addGroupToMultiselectGroup(dayGroupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(dayGroupTitle);
}
}
}
if (monthGroup === endBucket) {
break;
}
}
}
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const selectAssetCandidates = async (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
const assets = assetsSnapshot(await timelineManager.retrieveRange(startAsset, endAsset));
assetInteraction.setAssetSelectionCandidates(assets);
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
<TimelineMonth
{customThumbnailLayout}
{singleSelect}
{withStacked}
{showArchiveIcon}
{monthGroup}
{timelineManager}
{onScrollCompensationMonthInDOM}
onHover={handleOnHover}
onAssetOpen={handleOnAssetOpen}
onAssetSelect={handleAssetSelect}
onDayGroupSelect={handleDayGroupSelect}
isDayGroupSelected={(dayGroup: DayGroup) => assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
isAssetSelected={(asset) => assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
isAssetSelectionCandidate={(asset) => assetInteraction.hasSelectionCandidate(asset.id)}
isAssetDisabled={(asset) => timelineManager.albumAssets.has(asset.id)}
/>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AssetAction } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { navigate } from '$lib/utils/navigation';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
let { asset: viewingAsset, gridScrollTarget, mutex, preloadAssets } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
showSkeleton: boolean;
withStacked?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
removeAction?:
| AssetAction.UNARCHIVE
| AssetAction.ARCHIVE
| AssetAction.FAVORITE
| AssetAction.UNFAVORITE
| AssetAction.SET_VISIBILITY_TIMELINE
| null;
}
let {
timelineManager,
showSkeleton = $bindable(false),
removeAction,
withStacked = false,
isShared = false,
album = null,
person = null,
}: Props = $props();
const handlePrevious = async () => {
const release = await mutex.acquire();
const laterAsset = await timelineManager.getLaterAsset($viewingAsset);
if (laterAsset) {
const preloadAsset = await timelineManager.getLaterAsset(laterAsset);
const asset = await getAssetInfo({ ...authManager.params, id: laterAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: laterAsset.id });
}
release();
return !!laterAsset;
};
const handleNext = async () => {
const release = await mutex.acquire();
const earlierAsset = await timelineManager.getEarlierAsset($viewingAsset);
if (earlierAsset) {
const preloadAsset = await timelineManager.getEarlierAsset(earlierAsset);
const asset = await getAssetInfo({ ...authManager.params, id: earlierAsset.id });
assetViewingStore.setAsset(asset, preloadAsset ? [preloadAsset] : []);
await navigate({ targetRoute: 'current', assetId: earlierAsset.id });
}
release();
return !!earlierAsset;
};
const handleRandom = async () => {
const randomAsset = await timelineManager.getRandomAsset();
if (randomAsset) {
const asset = await getAssetInfo({ ...authManager.params, id: randomAsset.id });
assetViewingStore.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: randomAsset.id });
return asset;
}
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
showSkeleton = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
};
const handlePreAction = async (action: Action) => {
switch (action.type) {
case removeAction:
case AssetAction.TRASH:
case AssetAction.RESTORE:
case AssetAction.DELETE:
case AssetAction.ARCHIVE:
case AssetAction.SET_VISIBILITY_LOCKED:
case AssetAction.SET_VISIBILITY_TIMELINE: {
// find the next asset to show or close the viewer
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await handleNext()) || (await handlePrevious()) || (await handleClose(action.asset));
// delete after find the next one
timelineManager.removeAssets([action.asset.id]);
break;
}
}
};
const handleAction = (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.updateAssets([action.asset]);
break;
}
case AssetAction.ADD: {
timelineManager.addAssets([action.asset]);
break;
}
case AssetAction.UNSTACK: {
updateUnstackedAssetInTimeline(timelineManager, action.assets);
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.addAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack?.primaryAssetId)
.map((asset) => asset.id),
});
}
break;
}
case AssetAction.SET_STACK_PRIMARY_ASSET: {
//Have to unstack then restack assets in timeline in order for the currently removed new primary asset to be made visible.
updateUnstackedAssetInTimeline(
timelineManager,
action.stack.assets.map((asset) => toTimelineAsset(asset)),
);
updateStackedAssetInTimeline(timelineManager, {
stack: action.stack,
toDeleteIds: action.stack.assets
.filter((asset) => asset.id !== action.stack.primaryAssetId)
.map((asset) => asset.id),
});
break;
}
}
};
</script>
{#await import('../../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
{withStacked}
asset={$viewingAsset}
preloadAssets={$preloadAssets}
{isShared}
{album}
{person}
preAction={handlePreAction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
/>
{/await}

View File

@@ -1,36 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onMount } from 'svelte';
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
type Props = { interface Props {
onBeforeUpdate?: (payload: UpdatePayload) => void; onAfterUpdate: (payload: UpdatePayload) => void;
onAfterUpdate?: (payload: UpdatePayload) => void; }
};
let { onBeforeUpdate, onAfterUpdate }: Props = $props(); let { onAfterUpdate }: Props = $props();
const unsubscribes: (() => void)[] = [];
onMount(() => { onMount(() => {
const hot = import.meta.hot; const hot = import.meta.hot;
if (!hot) { if (hot) {
return;
}
if (onBeforeUpdate) {
hot.on('vite:beforeUpdate', onBeforeUpdate);
unsubscribes.push(() => hot.off('vite:beforeUpdate', onBeforeUpdate));
}
if (onAfterUpdate) {
hot.on('vite:afterUpdate', onAfterUpdate); hot.on('vite:afterUpdate', onAfterUpdate);
unsubscribes.push(() => hot.off('vite:afterUpdate', onAfterUpdate)); return () => hot.off('vite:afterUpdate', onAfterUpdate);
}
});
onDestroy(() => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
} }
}); });
</script> </script>

View File

@@ -13,6 +13,7 @@ export class DayGroup {
readonly monthGroup: MonthGroup; readonly monthGroup: MonthGroup;
readonly index: number; readonly index: number;
readonly groupTitle: string; readonly groupTitle: string;
readonly groupTitleFull: string;
readonly day: number; readonly day: number;
viewerAssets: ViewerAsset[] = $state([]); viewerAssets: ViewerAsset[] = $state([]);
@@ -26,11 +27,12 @@ export class DayGroup {
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) { constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) {
this.index = index; this.index = index;
this.monthGroup = monthGroup; this.monthGroup = monthGroup;
this.day = day; this.day = day;
this.groupTitle = groupTitle; this.groupTitle = groupTitle;
this.groupTitleFull = groupTitleFull;
} }
get top() { get top() {

View File

@@ -143,3 +143,24 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
} }
} }
} }
export function findClosestGroupForDate(timelineManager: TimelineManager, targetYearMonth: TimelineYearMonth) {
let closestMonth: MonthGroup | undefined;
let minDifference = Number.MAX_SAFE_INTEGER;
for (const month of timelineManager.months) {
const { year, month: monthNum } = month.yearMonth;
// Calculate the absolute difference in months
const yearDiff = Math.abs(year - targetYearMonth.year);
const monthDiff = Math.abs(monthNum - targetYearMonth.month);
const totalDiff = yearDiff * 12 + monthDiff;
if (totalDiff < minDifference) {
minDifference = totalDiff;
closestMonth = month;
}
}
return closestMonth;
}

View File

@@ -4,6 +4,7 @@ import { CancellableTask } from '$lib/utils/cancellable-task';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
formatGroupTitle, formatGroupTitle,
formatGroupTitleFull,
formatMonthGroupTitle, formatMonthGroupTitle,
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
@@ -222,7 +223,8 @@ export class MonthGroup {
addContext.setDayGroup(dayGroup, localDateTime); addContext.setDayGroup(dayGroup, localDateTime);
} else { } else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle); const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull);
this.dayGroups.push(dayGroup); this.dayGroups.push(dayGroup);
addContext.setDayGroup(dayGroup, localDateTime); addContext.setDayGroup(dayGroup, localDateTime);
addContext.newDayGroups.add(dayGroup); addContext.newDayGroups.add(dayGroup);

View File

@@ -41,6 +41,7 @@ export class TimelineManager {
isInitialized = $state(false); isInitialized = $state(false);
months: MonthGroup[] = $state([]); months: MonthGroup[] = $state([]);
topSectionHeight = $state(0); topSectionHeight = $state(0);
bottomSectionHeight = $state(60);
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight); timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0)); assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
@@ -552,4 +553,13 @@ export class TimelineManager {
getAssetOrder() { getAssetOrder() {
return this.#options.order ?? AssetOrder.Desc; return this.#options.order ?? AssetOrder.Desc;
} }
getMaxScrollPercent() {
const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight;
return (totalHeight - this.viewportHeight) / totalHeight;
}
getMaxScroll() {
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
}
} }

View File

@@ -23,11 +23,12 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number; millisecond: number;
}; };
export type ScrubberListener = ( export type ScrubberListener = (scrubberData: {
scrubberMonth: { year: number; month: number }, scrubberMonth: { year: number; month: number };
overallScrollPercent: number, overallScrollPercent: number;
scrubberMonthScrollPercent: number, scrubberMonthScrollPercent: number;
) => void | Promise<void>; scrollToFunction?: (top: number) => void;
}) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others // used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> => export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
@@ -151,6 +152,14 @@ export function formatGroupTitle(_date: DateTime): string {
return getDateLocaleString(date, { locale: get(locale) }); return getDateLocaleString(date, { locale: get(locale) });
} }
export const formatGroupTitleFull = (_date: DateTime): string => {
if (!_date.isValid) {
return _date.toString();
}
const date = _date as DateTime<true>;
return getDateLocaleString(date);
};
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
@@ -234,3 +243,79 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
} }
return result; return result;
} }
export interface MonthGroupForSearch {
yearMonth: TimelineYearMonth;
top: number;
height: number;
}
export interface BinarySearchResult {
month: TimelineYearMonth;
monthScrollPercent: number;
}
export function findMonthAtScrollPosition(
months: MonthGroupForSearch[],
scrollPosition: number,
maxScrollPercent: number,
): BinarySearchResult | null {
const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks
const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month
if (months.length === 0) {
return null;
}
// Check if we're before the first month
const firstMonthTop = months[0].top * maxScrollPercent;
if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) {
return null;
}
// Check if we're after the last month
const lastMonth = months.at(-1)!;
const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent;
if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) {
return null;
}
// Binary search to find the month containing the scroll position
let left = 0;
let right = months.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const month = months[mid];
const monthTop = month.top * maxScrollPercent;
const monthBottom = monthTop + month.height * maxScrollPercent;
if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) {
// Found the month containing the scroll position
const distanceIntoMonth = scrollPosition - monthTop;
const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent));
// Handle month boundary edge case
if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) {
return {
month: months[mid + 1].yearMonth,
monthScrollPercent: 0,
};
}
return {
month: month.yearMonth,
monthScrollPercent,
};
}
if (scrollPosition < monthTop) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// Shouldn't reach here, but return null if we do
return null;
}

View File

@@ -110,17 +110,7 @@
return !!asset.latitude && !!asset.longitude; return !!asset.latitude && !!asset.longitude;
}; };
const handleThumbnailClick = ( const handleAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => {
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {
if (hasGps(asset)) { if (hasGps(asset)) {
locationUpdated = true; locationUpdated = true;
setTimeout(() => { setTimeout(() => {
@@ -129,7 +119,7 @@
location = { latitude: asset.latitude!, longitude: asset.longitude! }; location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id); void setQueryValue('at', asset.id);
} else { } else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset); defaultAssetOpen();
} }
}; };
</script> </script>
@@ -193,9 +183,9 @@
removeAction={AssetAction.ARCHIVE} removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape} onEscape={handleEscape}
withStacked withStacked
onThumbnailClick={handleThumbnailClick} onAssetOpen={handleAssetOpen}
> >
{#snippet customLayout(asset: TimelineAsset)} {#snippet customThumbnailLayout(asset: TimelineAsset)}
{#if hasGps(asset)} {#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black"> <div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')} {asset.city || $t('gps')}