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",
"redis",
"database",
"immich-machine-learning"
"immich-machine-learning",
"init"
],
"dockerComposeFile": [
"../docker/docker-compose.dev.yml",

1
.gitignore vendored
View File

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

View File

@@ -1353,6 +1353,8 @@
"my_albums": "My albums",
"name": "Name",
"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_videos_upload": "Use cellular data to backup videos",
"network_requirements": "Network Requirements",

View File

@@ -3,19 +3,14 @@ package app.alextran.immich.images
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.content.res.Resources
import android.graphics.*
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal
import android.os.OperationCanceledException
import android.provider.DocumentsContract
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.system.Int64Ref
import android.util.Size
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer
import kotlin.math.*
import java.util.concurrent.Executors
@@ -177,7 +172,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
@@ -190,7 +185,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
@@ -220,72 +215,4 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
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, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340C92E7326630050078A /* AssetResolver.swift */; };
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CB2E7326630050078A /* Thumbhash.swift */; };
FEC340D32E7326630050078A /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CF2E7326630050078A /* Request.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 */; };
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -117,11 +115,9 @@
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>"; };
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>"; };
FEC340CB2E7326630050078A /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailResolver.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>"; };
FEAFA8722E4D42F4001E47FE /* 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>"; };
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -251,7 +247,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FEC340D02E7326630050078A /* Resolvers */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -266,6 +261,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FED3B1952E253E9B0030FD97 /* Images */,
);
path = Runner;
sourceTree = "<group>";
@@ -300,34 +296,16 @@
path = ShareExtension;
sourceTree = "<group>";
};
FEC340CA2E7326630050078A /* Assets */ = {
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
FEC340C92E7326630050078A /* AssetResolver.swift */,
);
path = Assets;
sourceTree = "<group>";
};
FEC340CE2E7326630050078A /* Images */ = {
isa = PBXGroup;
children = (
FEC340CB2E7326630050078A /* Thumbhash.swift */,
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */,
FEC340CD2E7326630050078A /* Thumbnails.g.swift */,
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
);
path = Images;
sourceTree = "<group>";
};
FEC340D02E7326630050078A /* Resolvers */ = {
isa = PBXGroup;
children = (
FEC340CA2E7326630050078A /* Assets */,
FEC340CE2E7326630050078A /* Images */,
FEC340CF2E7326630050078A /* Request.swift */,
);
path = Resolvers;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -595,16 +573,14 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.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;
};

View File

@@ -15,7 +15,7 @@ import UIKit
) -> Bool {
// Required for flutter_local_notification
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
}
GeneratedPluginRegistrant.register(with: self)
@@ -53,7 +53,7 @@ import UIKit
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailResolver())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
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)();
// Used for mark & sweep
BoolColumn get marker_ => boolean().nullable()();
@override
Set<Column> get primaryKey => {assetId, albumId};
}

View File

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

View File

@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 11;
int get schemaVersion => 10;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -156,9 +156,6 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
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,
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({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
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, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -4718,11 +4328,6 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from9To10(migrator, schema);
return 10;
case 10:
final schema = Schema11(database: database);
final migrator = i1.Migrator(database, schema);
await from10To11(migrator, schema);
return 11;
default:
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, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -4751,6 +4355,5 @@ i1.OnUpgrade stepByStep({
from7To8: from7To8,
from8To9: from8To9,
from9To10: from9To10,
from10To11: from10To11,
),
);

View File

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

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/album/local_album.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) {
for (final id in ids) {
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
for (final slice in ids.slices(32000)) {
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) {
return _db.batch((batch) {
for (final assetId in assetIds) {
batch.deleteWhere(
_db.remoteAlbumAssetEntity,
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
);
}
});
Future<int> removeAssets(String albumId, List<String> assetIds) {
return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {

View File

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

View File

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

View File

@@ -93,11 +93,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final user in data) {
batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
}
});
await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
} catch (error, stack) {
_logger.severe('Error: SyncUserDeleteV1', error, stack);
rethrow;
@@ -162,11 +158,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final asset in data) {
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
}
});
await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
} catch (error, stack) {
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
rethrow;
@@ -251,11 +243,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final album in data) {
batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
}
});
await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
} catch (error, stack) {
_logger.severe('Error: deleteAlbumsV1', error, stack);
rethrow;
@@ -391,11 +379,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
try {
await _db.batch((batch) {
for (final memory in data) {
batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
}
});
await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
} catch (error, stack) {
_logger.severe('Error: deleteMemoriesV1', error, stack);
rethrow;
@@ -459,11 +443,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final stack in data) {
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
}
});
await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
} catch (error, stack) {
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
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,
appBar: null,
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:flutter/material.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/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/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DownloadActionButton extends ConsumerWidget {
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) {
return;
}
try {
await ref.read(actionProvider.notifier).downloadAll(source);
final result = await ref.read(actionProvider.notifier).downloadAll(source);
ref.read(multiSelectProvider.notifier).reset();
Future.delayed(const Duration(seconds: 1), () async {
await backgroundSyncManager.syncLocal();
await backgroundSyncManager.hashAssets();
});
} finally {
ref.read(multiSelectProvider.notifier).reset();
if (!context.mounted) {
return;
}
if (!result.success) {
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
Widget build(BuildContext context, WidgetRef ref) {
final backgroundManager = ref.watch(backgroundSyncProvider);
return BaseActionButton(
iconData: Icons.download,
maxWidth: 95,
label: "download".t(context: context),
menuItem: menuItem,
onPressed: () => _onTap(context, ref, backgroundManager),
onPressed: () => _onTap(context, ref),
);
}
}

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/platform_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.widget.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(),
extendBody: true,
extendBodyBehindAppBar: true,
floatingActionButton: const DownloadStatusFloatingButton(),
body: Stack(
children: [
PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
],
body: PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
bottomNavigationBar: showingBottomSheet
? 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/translate_extensions.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/motion_photo_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 actions = <Widget>[
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(

View File

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

View File

@@ -2,7 +2,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0;
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 int kTimelineColumnCount = 3;

View File

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

View File

@@ -31,11 +31,6 @@ class Scrubber extends ConsumerStatefulWidget {
final double? monthSegmentSnappingOffset;
final bool snapToMonth;
/// Whether an app bar is present, affects coordinate calculations
final bool hasAppBar;
Scrubber({
super.key,
Key? scrollThumbKey,
@@ -44,8 +39,6 @@ class Scrubber extends ConsumerStatefulWidget {
this.topPadding = 0,
this.bottomPadding = 0,
this.monthSegmentSnappingOffset,
this.snapToMonth = true,
this.hasAppBar = true,
required this.child,
}) : 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
setState(() {
_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
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
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 dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
final dragAreaHeight = dragAreaBottom - 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
@@ -343,8 +322,6 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
_isDragging = false;
});
ref.read(timelineStateProvider.notifier).setScrubbing(false);
// Reset scrubber tracking when drag ends
_currentScrubberDate = null;
_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/extensions/asyncvalue_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/timeline/scrubber.widget.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.groupBy,
this.withScrubber = true,
this.snapToMonth = true,
});
final Widget? topSliverWidget;
@@ -50,13 +48,11 @@ class Timeline extends StatelessWidget {
final bool withStack;
final GroupAssetsBy? groupBy;
final bool withScrubber;
final bool snapToMonth;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
floatingActionButton: const DownloadStatusFloatingButton(),
body: LayoutBuilder(
builder: (_, constraints) => ProviderScope(
overrides: [
@@ -77,7 +73,6 @@ class Timeline extends StatelessWidget {
appBar: appBar,
bottomSheet: bottomSheet,
withScrubber: withScrubber,
snapToMonth: snapToMonth,
),
),
),
@@ -92,7 +87,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.snapToMonth = true,
});
final Widget? topSliverWidget;
@@ -100,7 +94,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool snapToMonth;
@override
ConsumerState createState() => _SliverTimelineState();
@@ -316,13 +309,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final Widget timeline;
if (widget.withScrubber) {
timeline = Scrubber(
snapToMonth: widget.snapToMonth,
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: bottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
hasAppBar: widget.appBar != null,
child: grid,
);
} else {

View File

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

View File

@@ -90,11 +90,7 @@ class DownloadRepository {
final isVideo = asset.isVideo;
final url = getOriginalUrlForRemoteId(id);
// on iOS it cannot link the image, check if the filename has .MP extension
// to avoid downloading the video part
final isAndroidMotionPhoto = asset.name.contains(".MP");
if (Platform.isAndroid || livePhotoVideoId == null || isVideo || isAndroidMotionPhoto) {
if (Platform.isAndroid || livePhotoVideoId == null || isVideo) {
tasks[taskIndex++] = DownloadTask(
taskId: id,
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/main_timeline.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_album.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: DriftBackupAssetDetailRoute.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
// auto_route_library#1722
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
/// [DriftActivitiesPage]
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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.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/repositories/asset_api.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/routing/router.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 {
int removedCount = 0;
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
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 {

View File

@@ -13,7 +13,6 @@ import 'schema_v7.dart' as v7;
import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -39,12 +38,10 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v9.DatabaseAtV9(db);
case 10:
return v10.DatabaseAtV10(db);
case 11:
return v11.DatabaseAtV11(db);
default:
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);
return { buffer, format: RawExtractedFormat.Jpeg };
} 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 {
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
return { buffer, format: RawExtractedFormat.Jpeg };
} 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 {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
return { buffer, format: RawExtractedFormat.Jxl };
} 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 {
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
return { buffer, format: RawExtractedFormat.Jpeg };
} 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;
}
}

View File

@@ -103,7 +103,7 @@ export class MetadataRepository {
readTags(path: string): Promise<ImmichTags> {
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 {};
}) as Promise<ImmichTags>;
}

View File

@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
}
} 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);
pgdump.on('error', (err) => {
this.logger.error(`Backup failed with error: ${err}`);
this.logger.error('Backup failed with error', err);
reject(err);
});
gzip.on('error', (err) => {
this.logger.error(`Gzip failed with error: ${err}`);
this.logger.error('Gzip failed with error', err);
reject(err);
});
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
});
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
} catch (error) {
this.logger.error(`Database Backup Failure: ${error}`);
this.logger.error('Database Backup Failure', error);
await this.storageRepository
.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;
}

View File

@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId)
.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 {
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
} 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
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {

View File

@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
return destination;
} 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;
}
}

View File

@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
}
} 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;
}

View File

@@ -73,7 +73,7 @@ export const sendFile = async (
// log non-http errors
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');

View File

@@ -240,7 +240,7 @@
use:zoomImageAction
use:swipe={() => ({})}
onswipe={onSwipe}
class="h-full w-full flex"
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
>
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
@@ -255,7 +255,7 @@
bind:this={$photoViewerImgElement}
src={assetFileUrl}
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'
: slideshowLookCssMapping[$slideshowLook]}"
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 { mobileDevice } from '$lib/stores/mobile-device.svelte';
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 { mdiPlay } from '@mdi/js';
import { clamp } from 'lodash-es';
@@ -11,18 +11,31 @@
import { fade, fly } from 'svelte/transition';
interface Props {
/** Offset from the top of the timeline (e.g., for headers) */
timelineTopOffset?: number;
/** Offset from the bottom of the timeline (e.g., for footers) */
timelineBottomOffset?: number;
/** Total height of the scrubber component */
height?: number;
/** Timeline manager instance that controls the timeline state */
timelineManager: TimelineManager;
scrubOverallPercent?: number;
scrubberMonthPercent?: number;
scrubberMonth?: { year: number; month: number };
leadout?: boolean;
/** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
timelineScrollPercent?: number;
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
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;
/** Callback fired when user interacts with the scrubber to navigate */
onScrub?: ScrubberListener;
/** Callback fired when keyboard events occur on the scrubber */
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
/** Callback fired when scrubbing starts */
startScrub?: ScrubberListener;
/** Callback fired when scrubbing stops */
stopScrub?: ScrubberListener;
}
@@ -31,10 +44,10 @@
timelineBottomOffset = 0,
height = 0,
timelineManager,
scrubOverallPercent = 0,
scrubberMonthPercent = 0,
scrubberMonth = undefined,
leadout = false,
timelineScrollPercent = 0,
viewportTopMonthScrollPercent = 0,
viewportTopMonth = undefined,
isInLeadOutSection = false,
onScrub = undefined,
onScrubKeyDown = undefined,
startScrub = undefined,
@@ -100,7 +113,7 @@
offset += scrubberMonthPercent * relativeBottomOffset;
}
return offset;
} else if (leadout) {
} else if (isInLeadOutSection) {
let offset = relativeTopOffset;
for (const segment of segments) {
offset += segment.height;
@@ -111,7 +124,9 @@
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 relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
@@ -295,12 +310,24 @@
const scrollPercent = toTimelineY(hoverY);
if (wasDragging === false && isDragging) {
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
void startScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
}
if (wasDragging && !isDragging) {
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
void stopScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
return;
}
@@ -308,7 +335,11 @@
return;
}
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
void onScrub?.({
scrubberMonth: segmentDate!,
overallScrollPercent: scrollPercent,
scrubberMonthScrollPercent: monthGroupPercentY,
});
};
/* eslint-disable tscompat/tscompat */
const getTouch = (event: TouchEvent) => {
@@ -412,7 +443,11 @@
}
if (next) {
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;
}
}
@@ -422,7 +457,11 @@
const next = segments[idx + 1];
if (next) {
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;
}
}

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

View File

@@ -13,6 +13,7 @@ export class DayGroup {
readonly monthGroup: MonthGroup;
readonly index: number;
readonly groupTitle: string;
readonly groupTitleFull: string;
readonly day: number;
viewerAssets: ViewerAsset[] = $state([]);
@@ -26,11 +27,12 @@ export class DayGroup {
#col = $state(0);
#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.monthGroup = monthGroup;
this.day = day;
this.groupTitle = groupTitle;
this.groupTitleFull = groupTitleFull;
}
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 {
formatGroupTitle,
formatGroupTitleFull,
formatMonthGroupTitle,
fromTimelinePlainDate,
fromTimelinePlainDateTime,
@@ -222,7 +223,8 @@ export class MonthGroup {
addContext.setDayGroup(dayGroup, localDateTime);
} else {
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);
addContext.setDayGroup(dayGroup, localDateTime);
addContext.newDayGroups.add(dayGroup);

View File

@@ -41,6 +41,7 @@ export class TimelineManager {
isInitialized = $state(false);
months: MonthGroup[] = $state([]);
topSectionHeight = $state(0);
bottomSectionHeight = $state(60);
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
@@ -552,4 +553,13 @@ export class TimelineManager {
getAssetOrder() {
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;
};
export type ScrubberListener = (
scrubberMonth: { year: number; month: number },
overallScrollPercent: number,
scrubberMonthScrollPercent: number,
) => void | Promise<void>;
export type ScrubberListener = (scrubberData: {
scrubberMonth: { year: number; month: number };
overallScrollPercent: number;
scrubberMonthScrollPercent: number;
scrollToFunction?: (top: number) => void;
}) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others
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) });
}
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 =>
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;
}
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;
};
const handleThumbnailClick = (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => {
const handleAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => {
if (hasGps(asset)) {
locationUpdated = true;
setTimeout(() => {
@@ -129,7 +119,7 @@
location = { latitude: asset.latitude!, longitude: asset.longitude! };
void setQueryValue('at', asset.id);
} else {
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
defaultAssetOpen();
}
};
</script>
@@ -193,9 +183,9 @@
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
onThumbnailClick={handleThumbnailClick}
onAssetOpen={handleAssetOpen}
>
{#snippet customLayout(asset: TimelineAsset)}
{#snippet customThumbnailLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<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')}