Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7404e7495 | |||
| 7cd5b7f64d | |||
| fb798a3492 | |||
| 73d650de23 | |||
| a07f3c2ba4 | |||
| 9ccd98d871 | |||
| 188dcbf7d0 | |||
| 51d106d192 | |||
| 3e427e42cb | |||
| f6a99602e9 | |||
| 52363cf0fb | |||
| 86df09a0e4 | |||
| e1e24f3d60 | |||
| 33d76fb386 | |||
| 642065f506 | |||
| de897f6069 | |||
| 68f3ed89c5 | |||
| 78516a97b3 | |||
| b8a17c3c26 |
@@ -5,8 +5,7 @@
|
|||||||
"immich-server",
|
"immich-server",
|
||||||
"redis",
|
"redis",
|
||||||
"database",
|
"database",
|
||||||
"immich-machine-learning",
|
"immich-machine-learning"
|
||||||
"init"
|
|
||||||
],
|
],
|
||||||
"dockerComposeFile": [
|
"dockerComposeFile": [
|
||||||
"../docker/docker-compose.dev.yml",
|
"../docker/docker-compose.dev.yml",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mobile/libisar.dylib
|
|||||||
mobile/openapi/test
|
mobile/openapi/test
|
||||||
mobile/openapi/doc
|
mobile/openapi/doc
|
||||||
mobile/openapi/.openapi-generator/FILES
|
mobile/openapi/.openapi-generator/FILES
|
||||||
|
mobile/ios/build
|
||||||
|
|
||||||
open-api/typescript-sdk/build
|
open-api/typescript-sdk/build
|
||||||
mobile/android/fastlane/report.xml
|
mobile/android/fastlane/report.xml
|
||||||
|
|||||||
@@ -1353,8 +1353,6 @@
|
|||||||
"my_albums": "My albums",
|
"my_albums": "My albums",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"name_or_nickname": "Name or nickname",
|
"name_or_nickname": "Name or nickname",
|
||||||
"navigate": "Navigate",
|
|
||||||
"navigate_to_time": "Navigate to Time",
|
|
||||||
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
"network_requirement_photos_upload": "Use cellular data to backup photos",
|
||||||
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
"network_requirement_videos_upload": "Use cellular data to backup videos",
|
||||||
"network_requirements": "Network Requirements",
|
"network_requirements": "Network Requirements",
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ package app.alextran.immich.images
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.res.Resources
|
||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import android.provider.MediaStore.Images
|
import android.provider.MediaStore.Images
|
||||||
import android.provider.MediaStore.Video
|
import android.provider.MediaStore.Video
|
||||||
|
import android.system.Int64Ref
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@@ -172,7 +177,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
||||||
} else {
|
} else {
|
||||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
||||||
@@ -185,7 +190,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
|||||||
signal.throwIfCanceled()
|
signal.throwIfCanceled()
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
|
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||||
resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
|
||||||
} else {
|
} else {
|
||||||
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||||
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
|
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
|
||||||
@@ -215,4 +220,72 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
|||||||
ref.get()
|
ref.get()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2006 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
fun loadThumbnail(uri: Uri, size: Size, signal: CancellationSignal?): Bitmap {
|
||||||
|
// Convert to Point, since that's what the API is defined as
|
||||||
|
val opts = Bundle()
|
||||||
|
if (size.width < 512 && size.height < 512) {
|
||||||
|
opts.putParcelable(ContentResolver.EXTRA_SIZE, Point(size.width, size.height))
|
||||||
|
}
|
||||||
|
val orientation = Int64Ref(0)
|
||||||
|
|
||||||
|
var bitmap =
|
||||||
|
ImageDecoder.decodeBitmap(
|
||||||
|
ImageDecoder.createSource {
|
||||||
|
val afd =
|
||||||
|
resolver.openTypedAssetFile(uri, "image/*", opts, signal)
|
||||||
|
?: throw Resources.NotFoundException("Asset $uri not found")
|
||||||
|
val extras = afd.extras
|
||||||
|
orientation.value =
|
||||||
|
(extras?.getInt(DocumentsContract.EXTRA_ORIENTATION, 0) ?: 0).toLong()
|
||||||
|
afd
|
||||||
|
}
|
||||||
|
) { decoder: ImageDecoder, info: ImageDecoder.ImageInfo, _: ImageDecoder.Source ->
|
||||||
|
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE)
|
||||||
|
// One last-ditch check to see if we've been canceled.
|
||||||
|
signal?.throwIfCanceled()
|
||||||
|
|
||||||
|
// We requested a rough thumbnail size, but the remote size may have
|
||||||
|
// returned something giant, so defensively scale down as needed.
|
||||||
|
// This is modified from the original to target the smaller edge instead of the larger edge.
|
||||||
|
val widthSample = info.size.width.toDouble() / size.width
|
||||||
|
val heightSample = info.size.height.toDouble() / size.height
|
||||||
|
val sample = min(widthSample, heightSample)
|
||||||
|
if (sample > 1) {
|
||||||
|
val width = (info.size.width / sample).toInt()
|
||||||
|
val height = (info.size.height / sample).toInt()
|
||||||
|
decoder.setTargetSize(width, height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the bitmap if requested. We use a side-channel to
|
||||||
|
// communicate the orientation, since EXIF thumbnails don't contain
|
||||||
|
// the rotation flags of the original image.
|
||||||
|
if (orientation.value != 0L) {
|
||||||
|
val width = bitmap.getWidth()
|
||||||
|
val height = bitmap.getHeight()
|
||||||
|
|
||||||
|
val m = Matrix()
|
||||||
|
m.setRotate(orientation.value.toFloat(), (width / 2).toFloat(), (height / 2).toFloat())
|
||||||
|
bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
@@ -29,9 +29,11 @@
|
|||||||
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
|
||||||
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
|
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340C92E7326630050078A /* AssetResolver.swift */; };
|
||||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
|
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CB2E7326630050078A /* Thumbhash.swift */; };
|
||||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.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 */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -115,9 +117,11 @@
|
|||||||
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||||
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
|
||||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
FEC340C92E7326630050078A /* AssetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResolver.swift; sourceTree = "<group>"; };
|
||||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
|
FEC340CB2E7326630050078A /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
|
||||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.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>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -247,6 +251,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FEC340D02E7326630050078A /* Resolvers */,
|
||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
@@ -261,7 +266,6 @@
|
|||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
FED3B1952E253E9B0030FD97 /* Images */,
|
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -296,16 +300,34 @@
|
|||||||
path = ShareExtension;
|
path = ShareExtension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
FEC340CA2E7326630050078A /* Assets */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
|
FEC340C92E7326630050078A /* AssetResolver.swift */,
|
||||||
FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
|
);
|
||||||
FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
|
path = Assets;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FEC340CE2E7326630050078A /* Images */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FEC340CB2E7326630050078A /* Thumbhash.swift */,
|
||||||
|
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */,
|
||||||
|
FEC340CD2E7326630050078A /* Thumbnails.g.swift */,
|
||||||
);
|
);
|
||||||
path = Images;
|
path = Images;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
FEC340D02E7326630050078A /* Resolvers */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FEC340CA2E7326630050078A /* Assets */,
|
||||||
|
FEC340CE2E7326630050078A /* Images */,
|
||||||
|
FEC340CF2E7326630050078A /* Request.swift */,
|
||||||
|
);
|
||||||
|
path = Resolvers;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -573,14 +595,16 @@
|
|||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
|
||||||
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
|
|
||||||
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
|
||||||
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
|
|
||||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
|
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
|
||||||
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
|
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
|
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
|
||||||
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
|
||||||
|
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */,
|
||||||
|
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */,
|
||||||
|
FEC340D32E7326630050078A /* Request.swift in Sources */,
|
||||||
|
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */,
|
||||||
|
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import UIKit
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Required for flutter_local_notification
|
// Required for flutter_local_notification
|
||||||
if #available(iOS 10.0, *) {
|
if #available(iOS 10.0, *) {
|
||||||
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
@@ -53,7 +53,7 @@ import UIKit
|
|||||||
|
|
||||||
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
|
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
|
||||||
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
|
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
|
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailResolver())
|
||||||
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
|
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ class LocalAlbumAssetEntity extends Table with DriftDefaultsMixin {
|
|||||||
|
|
||||||
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
|
TextColumn get albumId => text().references(LocalAlbumEntity, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|
||||||
|
// Used for mark & sweep
|
||||||
|
BoolColumn get marker_ => boolean().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {assetId, albumId};
|
Set<Column> get primaryKey => {assetId, albumId};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ typedef $$LocalAlbumAssetEntityTableCreateCompanionBuilder =
|
|||||||
i1.LocalAlbumAssetEntityCompanion Function({
|
i1.LocalAlbumAssetEntityCompanion Function({
|
||||||
required String assetId,
|
required String assetId,
|
||||||
required String albumId,
|
required String albumId,
|
||||||
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
|
typedef $$LocalAlbumAssetEntityTableUpdateCompanionBuilder =
|
||||||
i1.LocalAlbumAssetEntityCompanion Function({
|
i1.LocalAlbumAssetEntityCompanion Function({
|
||||||
i0.Value<String> assetId,
|
i0.Value<String> assetId,
|
||||||
i0.Value<String> albumId,
|
i0.Value<String> albumId,
|
||||||
|
i0.Value<bool?> marker_,
|
||||||
});
|
});
|
||||||
|
|
||||||
final class $$LocalAlbumAssetEntityTableReferences
|
final class $$LocalAlbumAssetEntityTableReferences
|
||||||
@@ -113,6 +115,11 @@ class $$LocalAlbumAssetEntityTableFilterComposer
|
|||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.ColumnFilters<bool> get marker_ => $composableBuilder(
|
||||||
|
column: $table.marker_,
|
||||||
|
builder: (column) => i0.ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableFilterComposer get assetId {
|
i3.$$LocalAssetEntityTableFilterComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
|
final i3.$$LocalAssetEntityTableFilterComposer composer = $composerBuilder(
|
||||||
composer: this,
|
composer: this,
|
||||||
@@ -177,6 +184,11 @@ class $$LocalAlbumAssetEntityTableOrderingComposer
|
|||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.ColumnOrderings<bool> get marker_ => $composableBuilder(
|
||||||
|
column: $table.marker_,
|
||||||
|
builder: (column) => i0.ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
|
i3.$$LocalAssetEntityTableOrderingComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableOrderingComposer composer =
|
final i3.$$LocalAssetEntityTableOrderingComposer composer =
|
||||||
$composerBuilder(
|
$composerBuilder(
|
||||||
@@ -243,6 +255,9 @@ class $$LocalAlbumAssetEntityTableAnnotationComposer
|
|||||||
super.$addJoinBuilderToRootComposer,
|
super.$addJoinBuilderToRootComposer,
|
||||||
super.$removeJoinBuilderFromRootComposer,
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
});
|
});
|
||||||
|
i0.GeneratedColumn<bool> get marker_ =>
|
||||||
|
$composableBuilder(column: $table.marker_, builder: (column) => column);
|
||||||
|
|
||||||
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
|
i3.$$LocalAssetEntityTableAnnotationComposer get assetId {
|
||||||
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
|
final i3.$$LocalAssetEntityTableAnnotationComposer composer =
|
||||||
$composerBuilder(
|
$composerBuilder(
|
||||||
@@ -344,15 +359,21 @@ class $$LocalAlbumAssetEntityTableTableManager
|
|||||||
({
|
({
|
||||||
i0.Value<String> assetId = const i0.Value.absent(),
|
i0.Value<String> assetId = const i0.Value.absent(),
|
||||||
i0.Value<String> albumId = const i0.Value.absent(),
|
i0.Value<String> albumId = const i0.Value.absent(),
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
}) => i1.LocalAlbumAssetEntityCompanion(
|
}) => i1.LocalAlbumAssetEntityCompanion(
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
({required String assetId, required String albumId}) =>
|
({
|
||||||
i1.LocalAlbumAssetEntityCompanion.insert(
|
required String assetId,
|
||||||
|
required String albumId,
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
|
}) => i1.LocalAlbumAssetEntityCompanion.insert(
|
||||||
assetId: assetId,
|
assetId: assetId,
|
||||||
albumId: albumId,
|
albumId: albumId,
|
||||||
|
marker_: marker_,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
.map(
|
.map(
|
||||||
@@ -477,8 +498,22 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
|||||||
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
|
'REFERENCES local_album_entity (id) ON DELETE CASCADE',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
static const i0.VerificationMeta _marker_Meta = const i0.VerificationMeta(
|
||||||
|
'marker_',
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<i0.GeneratedColumn> get $columns => [assetId, albumId];
|
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_];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@override
|
@override
|
||||||
@@ -507,6 +542,12 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
|||||||
} else if (isInserting) {
|
} else if (isInserting) {
|
||||||
context.missing(_albumIdMeta);
|
context.missing(_albumIdMeta);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('marker')) {
|
||||||
|
context.handle(
|
||||||
|
_marker_Meta,
|
||||||
|
marker_.isAcceptableOrUnknown(data['marker']!, _marker_Meta),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +568,10 @@ class $LocalAlbumAssetEntityTable extends i2.LocalAlbumAssetEntity
|
|||||||
i0.DriftSqlType.string,
|
i0.DriftSqlType.string,
|
||||||
data['${effectivePrefix}album_id'],
|
data['${effectivePrefix}album_id'],
|
||||||
)!,
|
)!,
|
||||||
|
marker_: attachedDatabase.typeMapping.read(
|
||||||
|
i0.DriftSqlType.bool,
|
||||||
|
data['${effectivePrefix}marker'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,15 +590,20 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
|
implements i0.Insertable<i1.LocalAlbumAssetEntityData> {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final String albumId;
|
final String albumId;
|
||||||
|
final bool? marker_;
|
||||||
const LocalAlbumAssetEntityData({
|
const LocalAlbumAssetEntityData({
|
||||||
required this.assetId,
|
required this.assetId,
|
||||||
required this.albumId,
|
required this.albumId,
|
||||||
|
this.marker_,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
|
||||||
final map = <String, i0.Expression>{};
|
final map = <String, i0.Expression>{};
|
||||||
map['asset_id'] = i0.Variable<String>(assetId);
|
map['asset_id'] = i0.Variable<String>(assetId);
|
||||||
map['album_id'] = i0.Variable<String>(albumId);
|
map['album_id'] = i0.Variable<String>(albumId);
|
||||||
|
if (!nullToAbsent || marker_ != null) {
|
||||||
|
map['marker'] = i0.Variable<bool>(marker_);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +615,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
return LocalAlbumAssetEntityData(
|
return LocalAlbumAssetEntityData(
|
||||||
assetId: serializer.fromJson<String>(json['assetId']),
|
assetId: serializer.fromJson<String>(json['assetId']),
|
||||||
albumId: serializer.fromJson<String>(json['albumId']),
|
albumId: serializer.fromJson<String>(json['albumId']),
|
||||||
|
marker_: serializer.fromJson<bool?>(json['marker_']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -573,13 +624,18 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'assetId': serializer.toJson<String>(assetId),
|
'assetId': serializer.toJson<String>(assetId),
|
||||||
'albumId': serializer.toJson<String>(albumId),
|
'albumId': serializer.toJson<String>(albumId),
|
||||||
|
'marker_': serializer.toJson<bool?>(marker_),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
i1.LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) =>
|
i1.LocalAlbumAssetEntityData copyWith({
|
||||||
i1.LocalAlbumAssetEntityData(
|
String? assetId,
|
||||||
|
String? albumId,
|
||||||
|
i0.Value<bool?> marker_ = const i0.Value.absent(),
|
||||||
|
}) => i1.LocalAlbumAssetEntityData(
|
||||||
assetId: assetId ?? this.assetId,
|
assetId: assetId ?? this.assetId,
|
||||||
albumId: albumId ?? this.albumId,
|
albumId: albumId ?? this.albumId,
|
||||||
|
marker_: marker_.present ? marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
LocalAlbumAssetEntityData copyWithCompanion(
|
LocalAlbumAssetEntityData copyWithCompanion(
|
||||||
i1.LocalAlbumAssetEntityCompanion data,
|
i1.LocalAlbumAssetEntityCompanion data,
|
||||||
@@ -587,6 +643,7 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
return LocalAlbumAssetEntityData(
|
return LocalAlbumAssetEntityData(
|
||||||
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
assetId: data.assetId.present ? data.assetId.value : this.assetId,
|
||||||
albumId: data.albumId.present ? data.albumId.value : this.albumId,
|
albumId: data.albumId.present ? data.albumId.value : this.albumId,
|
||||||
|
marker_: data.marker_.present ? data.marker_.value : this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,51 +651,60 @@ class LocalAlbumAssetEntityData extends i0.DataClass
|
|||||||
String toString() {
|
String toString() {
|
||||||
return (StringBuffer('LocalAlbumAssetEntityData(')
|
return (StringBuffer('LocalAlbumAssetEntityData(')
|
||||||
..write('assetId: $assetId, ')
|
..write('assetId: $assetId, ')
|
||||||
..write('albumId: $albumId')
|
..write('albumId: $albumId, ')
|
||||||
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(assetId, albumId);
|
int get hashCode => Object.hash(assetId, albumId, marker_);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
(other is i1.LocalAlbumAssetEntityData &&
|
(other is i1.LocalAlbumAssetEntityData &&
|
||||||
other.assetId == this.assetId &&
|
other.assetId == this.assetId &&
|
||||||
other.albumId == this.albumId);
|
other.albumId == this.albumId &&
|
||||||
|
other.marker_ == this.marker_);
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalAlbumAssetEntityCompanion
|
class LocalAlbumAssetEntityCompanion
|
||||||
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
|
extends i0.UpdateCompanion<i1.LocalAlbumAssetEntityData> {
|
||||||
final i0.Value<String> assetId;
|
final i0.Value<String> assetId;
|
||||||
final i0.Value<String> albumId;
|
final i0.Value<String> albumId;
|
||||||
|
final i0.Value<bool?> marker_;
|
||||||
const LocalAlbumAssetEntityCompanion({
|
const LocalAlbumAssetEntityCompanion({
|
||||||
this.assetId = const i0.Value.absent(),
|
this.assetId = const i0.Value.absent(),
|
||||||
this.albumId = const i0.Value.absent(),
|
this.albumId = const i0.Value.absent(),
|
||||||
|
this.marker_ = const i0.Value.absent(),
|
||||||
});
|
});
|
||||||
LocalAlbumAssetEntityCompanion.insert({
|
LocalAlbumAssetEntityCompanion.insert({
|
||||||
required String assetId,
|
required String assetId,
|
||||||
required String albumId,
|
required String albumId,
|
||||||
|
this.marker_ = const i0.Value.absent(),
|
||||||
}) : assetId = i0.Value(assetId),
|
}) : assetId = i0.Value(assetId),
|
||||||
albumId = i0.Value(albumId);
|
albumId = i0.Value(albumId);
|
||||||
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
|
static i0.Insertable<i1.LocalAlbumAssetEntityData> custom({
|
||||||
i0.Expression<String>? assetId,
|
i0.Expression<String>? assetId,
|
||||||
i0.Expression<String>? albumId,
|
i0.Expression<String>? albumId,
|
||||||
|
i0.Expression<bool>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i0.RawValuesInsertable({
|
return i0.RawValuesInsertable({
|
||||||
if (assetId != null) 'asset_id': assetId,
|
if (assetId != null) 'asset_id': assetId,
|
||||||
if (albumId != null) 'album_id': albumId,
|
if (albumId != null) 'album_id': albumId,
|
||||||
|
if (marker_ != null) 'marker': marker_,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
i1.LocalAlbumAssetEntityCompanion copyWith({
|
i1.LocalAlbumAssetEntityCompanion copyWith({
|
||||||
i0.Value<String>? assetId,
|
i0.Value<String>? assetId,
|
||||||
i0.Value<String>? albumId,
|
i0.Value<String>? albumId,
|
||||||
|
i0.Value<bool?>? marker_,
|
||||||
}) {
|
}) {
|
||||||
return i1.LocalAlbumAssetEntityCompanion(
|
return i1.LocalAlbumAssetEntityCompanion(
|
||||||
assetId: assetId ?? this.assetId,
|
assetId: assetId ?? this.assetId,
|
||||||
albumId: albumId ?? this.albumId,
|
albumId: albumId ?? this.albumId,
|
||||||
|
marker_: marker_ ?? this.marker_,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,6 +717,9 @@ class LocalAlbumAssetEntityCompanion
|
|||||||
if (albumId.present) {
|
if (albumId.present) {
|
||||||
map['album_id'] = i0.Variable<String>(albumId.value);
|
map['album_id'] = i0.Variable<String>(albumId.value);
|
||||||
}
|
}
|
||||||
|
if (marker_.present) {
|
||||||
|
map['marker'] = i0.Variable<bool>(marker_.value);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,7 +727,8 @@ class LocalAlbumAssetEntityCompanion
|
|||||||
String toString() {
|
String toString() {
|
||||||
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
|
return (StringBuffer('LocalAlbumAssetEntityCompanion(')
|
||||||
..write('assetId: $assetId, ')
|
..write('assetId: $assetId, ')
|
||||||
..write('albumId: $albumId')
|
..write('albumId: $albumId, ')
|
||||||
|
..write('marker_: $marker_')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 10;
|
int get schemaVersion => 11;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -156,6 +156,9 @@ class Drift extends $Drift implements IDatabaseRepository {
|
|||||||
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
await m.addColumn(v10.userEntity, v10.userEntity.avatarColor);
|
||||||
await m.alterTable(TableMigration(v10.userEntity));
|
await m.alterTable(TableMigration(v10.userEntity));
|
||||||
},
|
},
|
||||||
|
from10To11: (m, v11) async {
|
||||||
|
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4270,6 +4270,395 @@ i1.GeneratedColumn<String> _column_94(String aliasedName) =>
|
|||||||
true,
|
true,
|
||||||
type: i1.DriftSqlType.string,
|
type: i1.DriftSqlType.string,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final class Schema11 extends i0.VersionedSchema {
|
||||||
|
Schema11({required super.database}) : super(version: 11);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
userEntity,
|
||||||
|
remoteAssetEntity,
|
||||||
|
stackEntity,
|
||||||
|
localAssetEntity,
|
||||||
|
remoteAlbumEntity,
|
||||||
|
localAlbumEntity,
|
||||||
|
localAlbumAssetEntity,
|
||||||
|
idxLocalAssetChecksum,
|
||||||
|
idxRemoteAssetOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerChecksum,
|
||||||
|
uQRemoteAssetsOwnerLibraryChecksum,
|
||||||
|
idxRemoteAssetChecksum,
|
||||||
|
authUserEntity,
|
||||||
|
userMetadataEntity,
|
||||||
|
partnerEntity,
|
||||||
|
remoteExifEntity,
|
||||||
|
remoteAlbumAssetEntity,
|
||||||
|
remoteAlbumUserEntity,
|
||||||
|
memoryEntity,
|
||||||
|
memoryAssetEntity,
|
||||||
|
personEntity,
|
||||||
|
assetFaceEntity,
|
||||||
|
storeEntity,
|
||||||
|
idxLatLng,
|
||||||
|
];
|
||||||
|
late final Shape20 userEntity = Shape20(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_91,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape17 remoteAssetEntity = Shape17(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_13,
|
||||||
|
_column_14,
|
||||||
|
_column_15,
|
||||||
|
_column_16,
|
||||||
|
_column_17,
|
||||||
|
_column_18,
|
||||||
|
_column_19,
|
||||||
|
_column_20,
|
||||||
|
_column_21,
|
||||||
|
_column_86,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape3 stackEntity = Shape3(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'stack_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape2 localAssetEntity = Shape2(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_1,
|
||||||
|
_column_8,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_10,
|
||||||
|
_column_11,
|
||||||
|
_column_12,
|
||||||
|
_column_0,
|
||||||
|
_column_22,
|
||||||
|
_column_14,
|
||||||
|
_column_23,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape9 remoteAlbumEntity = Shape9(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_56,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_57,
|
||||||
|
_column_58,
|
||||||
|
_column_59,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape19 localAlbumEntity = Shape19(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_5,
|
||||||
|
_column_31,
|
||||||
|
_column_32,
|
||||||
|
_column_90,
|
||||||
|
_column_33,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape22 localAlbumAssetEntity = Shape22(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'local_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_34, _column_35, _column_33],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLocalAssetChecksum = i1.Index(
|
||||||
|
'idx_local_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_owner_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
|
||||||
|
'UQ_remote_assets_owner_library_checksum',
|
||||||
|
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
|
||||||
|
);
|
||||||
|
final i1.Index idxRemoteAssetChecksum = i1.Index(
|
||||||
|
'idx_remote_asset_checksum',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
|
||||||
|
);
|
||||||
|
late final Shape21 authUserEntity = Shape21(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'auth_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_1,
|
||||||
|
_column_3,
|
||||||
|
_column_2,
|
||||||
|
_column_84,
|
||||||
|
_column_85,
|
||||||
|
_column_92,
|
||||||
|
_column_93,
|
||||||
|
_column_7,
|
||||||
|
_column_94,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape4 userMetadataEntity = Shape4(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_metadata_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
|
||||||
|
columns: [_column_25, _column_26, _column_27],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape5 partnerEntity = Shape5(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'partner_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
|
||||||
|
columns: [_column_28, _column_29, _column_30],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape8 remoteExifEntity = Shape8(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_exif_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_36,
|
||||||
|
_column_37,
|
||||||
|
_column_38,
|
||||||
|
_column_39,
|
||||||
|
_column_40,
|
||||||
|
_column_41,
|
||||||
|
_column_11,
|
||||||
|
_column_10,
|
||||||
|
_column_42,
|
||||||
|
_column_43,
|
||||||
|
_column_44,
|
||||||
|
_column_45,
|
||||||
|
_column_46,
|
||||||
|
_column_47,
|
||||||
|
_column_48,
|
||||||
|
_column_49,
|
||||||
|
_column_50,
|
||||||
|
_column_51,
|
||||||
|
_column_52,
|
||||||
|
_column_53,
|
||||||
|
_column_54,
|
||||||
|
_column_55,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape7 remoteAlbumAssetEntity = Shape7(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
|
||||||
|
columns: [_column_36, _column_60],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape10 remoteAlbumUserEntity = Shape10(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'remote_album_user_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
|
||||||
|
columns: [_column_60, _column_25, _column_61],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape11 memoryEntity = Shape11(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_18,
|
||||||
|
_column_15,
|
||||||
|
_column_8,
|
||||||
|
_column_62,
|
||||||
|
_column_63,
|
||||||
|
_column_64,
|
||||||
|
_column_65,
|
||||||
|
_column_66,
|
||||||
|
_column_67,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape12 memoryAssetEntity = Shape12(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'memory_asset_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
|
||||||
|
columns: [_column_36, _column_68],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape14 personEntity = Shape14(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'person_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_9,
|
||||||
|
_column_5,
|
||||||
|
_column_15,
|
||||||
|
_column_1,
|
||||||
|
_column_69,
|
||||||
|
_column_71,
|
||||||
|
_column_72,
|
||||||
|
_column_73,
|
||||||
|
_column_74,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape15 assetFaceEntity = Shape15(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'asset_face_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [
|
||||||
|
_column_0,
|
||||||
|
_column_36,
|
||||||
|
_column_76,
|
||||||
|
_column_77,
|
||||||
|
_column_78,
|
||||||
|
_column_79,
|
||||||
|
_column_80,
|
||||||
|
_column_81,
|
||||||
|
_column_82,
|
||||||
|
_column_83,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape18 storeEntity = Shape18(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'store_entity',
|
||||||
|
withoutRowId: true,
|
||||||
|
isStrict: true,
|
||||||
|
tableConstraints: ['PRIMARY KEY(id)'],
|
||||||
|
columns: [_column_87, _column_88, _column_89],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
final i1.Index idxLatLng = i1.Index(
|
||||||
|
'idx_lat_lng',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape22 extends i0.VersionedTable {
|
||||||
|
Shape22({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get assetId =>
|
||||||
|
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get albumId =>
|
||||||
|
columnsByName['album_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<bool> get marker_ =>
|
||||||
|
columnsByName['marker']! as i1.GeneratedColumn<bool>;
|
||||||
|
}
|
||||||
|
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
@@ -4280,6 +4669,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
@@ -4328,6 +4718,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from9To10(migrator, schema);
|
await from9To10(migrator, schema);
|
||||||
return 10;
|
return 10;
|
||||||
|
case 10:
|
||||||
|
final schema = Schema11(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from10To11(migrator, schema);
|
||||||
|
return 11;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
@@ -4344,6 +4739,7 @@ i1.OnUpgrade stepByStep({
|
|||||||
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
required Future<void> Function(i1.Migrator m, Schema8 schema) from7To8,
|
||||||
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
|
||||||
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
@@ -4355,5 +4751,6 @@ i1.OnUpgrade stepByStep({
|
|||||||
from7To8: from7To8,
|
from7To8: from7To8,
|
||||||
from8To9: from8To9,
|
from8To9: from8To9,
|
||||||
from9To10: from9To10,
|
from9To10: from9To10,
|
||||||
|
from10To11: from10To11,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,17 +72,33 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
return Future.value();
|
return Future.value();
|
||||||
}
|
}
|
||||||
|
|
||||||
final deleteSmt = _db.localAssetEntity.delete();
|
return _db.transaction(() async {
|
||||||
deleteSmt.where((localAsset) {
|
await _db.managers.localAlbumAssetEntity
|
||||||
final subQuery = _db.localAlbumAssetEntity.selectOnly()
|
.filter((row) => row.albumId.id.equals(albumId))
|
||||||
..addColumns([_db.localAlbumAssetEntity.assetId])
|
.update((album) => album(marker_: const Value(true)));
|
||||||
..join([innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id))]);
|
|
||||||
subQuery.where(
|
await _db.batch((batch) {
|
||||||
_db.localAlbumEntity.id.equals(albumId) & _db.localAlbumAssetEntity.assetId.isNotIn(assetIdsToKeep),
|
for (final assetId in assetIdsToKeep) {
|
||||||
|
batch.update(
|
||||||
|
_db.localAlbumAssetEntity,
|
||||||
|
const LocalAlbumAssetEntityCompanion(marker_: Value(null)),
|
||||||
|
where: (row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
|
||||||
);
|
);
|
||||||
return localAsset.id.isInQuery(subQuery);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final query = _db.localAssetEntity.delete()
|
||||||
|
..where(
|
||||||
|
(row) => row.id.isInQuery(
|
||||||
|
_db.localAlbumAssetEntity.selectOnly()
|
||||||
|
..addColumns([_db.localAlbumAssetEntity.assetId])
|
||||||
|
..where(
|
||||||
|
_db.localAlbumAssetEntity.albumId.equals(albumId) & _db.localAlbumAssetEntity.marker_.isNotNull(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await query.go();
|
||||||
});
|
});
|
||||||
await deleteSmt.go();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> upsert(
|
Future<void> upsert(
|
||||||
@@ -198,10 +214,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
// List<String>
|
// List<String>
|
||||||
await _db.batch((batch) async {
|
await _db.batch((batch) async {
|
||||||
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
|
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
|
||||||
batch.deleteWhere(
|
for (final albumId in albumIds.cast<String?>().nonNulls) {
|
||||||
_db.localAlbumAssetEntity,
|
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
|
||||||
(f) => f.albumId.isNotIn(albumIds.cast<String?>().nonNulls) & f.assetId.equals(assetId),
|
}
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await _db.batch((batch) async {
|
await _db.batch((batch) async {
|
||||||
@@ -288,13 +303,15 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
return transaction(() async {
|
return transaction(() async {
|
||||||
if (assetsToUnLink.isNotEmpty) {
|
if (assetsToUnLink.isNotEmpty) {
|
||||||
await _db.batch(
|
await _db.batch((batch) {
|
||||||
(batch) => batch.deleteWhere(
|
for (final assetId in assetsToUnLink) {
|
||||||
|
batch.deleteWhere(
|
||||||
_db.localAlbumAssetEntity,
|
_db.localAlbumAssetEntity,
|
||||||
(f) => f.assetId.isIn(assetsToUnLink) & f.albumId.equals(albumId),
|
(row) => row.assetId.equals(assetId) & row.albumId.equals(albumId),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await _deleteAssets(assetsToDelete);
|
await _deleteAssets(assetsToDelete);
|
||||||
});
|
});
|
||||||
@@ -320,7 +337,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
|
for (final id in ids) {
|
||||||
|
batch.deleteWhere(_db.localAssetEntity, (row) => row.id.equals(id));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
@@ -58,8 +57,8 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _db.batch((batch) {
|
return _db.batch((batch) {
|
||||||
for (final slice in ids.slices(32000)) {
|
for (final id in ids) {
|
||||||
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice));
|
batch.deleteWhere(_db.localAssetEntity, (e) => e.id.equals(id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,15 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> removeAssets(String albumId, List<String> assetIds) {
|
Future<void> removeAssets(String albumId, List<String> assetIds) {
|
||||||
return _db.remoteAlbumAssetEntity.deleteWhere((tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds));
|
return _db.batch((batch) {
|
||||||
|
for (final assetId in assetIds) {
|
||||||
|
batch.deleteWhere(
|
||||||
|
_db.remoteAlbumAssetEntity,
|
||||||
|
(row) => row.albumId.equals(albumId) & row.assetId.equals(assetId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
|
||||||
|
|||||||
@@ -160,7 +160,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(List<String> ids) {
|
Future<void> delete(List<String> ids) {
|
||||||
return _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(ids));
|
return _db.batch((batch) {
|
||||||
|
for (final id in ids) {
|
||||||
|
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateLocation(List<String> ids, LatLng location) {
|
Future<void> updateLocation(List<String> ids, LatLng location) {
|
||||||
@@ -199,7 +203,11 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
.map((row) => row.id)
|
.map((row) => row.id)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
|
final companion = StackEntityCompanion(ownerId: Value(userId), primaryAssetId: Value(stack.primaryAssetId));
|
||||||
@@ -219,15 +227,21 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> unStack(List<String> stackIds) {
|
Future<void> unStack(List<String> stackIds) {
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds));
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// TODO: delete this after adding foreign key on stackId
|
// TODO: delete this after adding foreign key on stackId
|
||||||
await _db.batch((batch) {
|
await _db.batch((batch) {
|
||||||
|
for (final stackId in stackIds) {
|
||||||
batch.update(
|
batch.update(
|
||||||
_db.remoteAssetEntity,
|
_db.remoteAssetEntity,
|
||||||
const RemoteAssetEntityCompanion(stackId: Value(null)),
|
const RemoteAssetEntityCompanion(stackId: Value(null)),
|
||||||
where: (e) => e.stackId.isIn(stackIds),
|
where: (e) => e.stackId.equals(stackId),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class SearchApiRepository extends ApiRepository {
|
|||||||
personIds: filter.people.map((e) => e.id).toList(),
|
personIds: filter.people.map((e) => e.id).toList(),
|
||||||
type: type,
|
type: type,
|
||||||
page: page,
|
page: page,
|
||||||
size: 1000,
|
size: 100,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
Future<void> deleteUsersV1(Iterable<SyncUserDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.userEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.userId)));
|
await _db.batch((batch) {
|
||||||
|
for (final user in data) {
|
||||||
|
batch.deleteWhere(_db.userEntity, (row) => row.id.equals(user.userId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: SyncUserDeleteV1', error, stack);
|
_logger.severe('Error: SyncUserDeleteV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -158,7 +162,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
|
Future<void> deleteAssetsV1(Iterable<SyncAssetDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
try {
|
try {
|
||||||
await _db.remoteAssetEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.assetId)));
|
await _db.batch((batch) {
|
||||||
|
for (final asset in data) {
|
||||||
|
batch.deleteWhere(_db.remoteAssetEntity, (row) => row.id.equals(asset.assetId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
|
_logger.severe('Error: deleteAssetsV1 - $debugLabel', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -243,7 +251,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
Future<void> deleteAlbumsV1(Iterable<SyncAlbumDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.remoteAlbumEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.albumId)));
|
await _db.batch((batch) {
|
||||||
|
for (final album in data) {
|
||||||
|
batch.deleteWhere(_db.remoteAlbumEntity, (row) => row.id.equals(album.albumId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteAlbumsV1', error, stack);
|
_logger.severe('Error: deleteAlbumsV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -379,7 +391,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
|
Future<void> deleteMemoriesV1(Iterable<SyncMemoryDeleteV1> data) async {
|
||||||
try {
|
try {
|
||||||
await _db.memoryEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.memoryId)));
|
await _db.batch((batch) {
|
||||||
|
for (final memory in data) {
|
||||||
|
batch.deleteWhere(_db.memoryEntity, (row) => row.id.equals(memory.memoryId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteMemoriesV1', error, stack);
|
_logger.severe('Error: deleteMemoriesV1', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -443,7 +459,11 @@ class SyncStreamRepository extends DriftDatabaseRepository {
|
|||||||
|
|
||||||
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
|
Future<void> deleteStacksV1(Iterable<SyncStackDeleteV1> data, {String debugLabel = 'user'}) async {
|
||||||
try {
|
try {
|
||||||
await _db.stackEntity.deleteWhere((row) => row.id.isIn(data.map((e) => e.stackId)));
|
await _db.batch((batch) {
|
||||||
|
for (final stack in data) {
|
||||||
|
batch.deleteWhere(_db.stackEntity, (row) => row.id.equals(stack.stackId));
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
|
_logger.severe('Error: deleteStacksV1 - $debugLabel', error, stack);
|
||||||
rethrow;
|
rethrow;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -633,7 +633,7 @@ class _SearchResultGrid extends ConsumerWidget {
|
|||||||
groupBy: GroupAssetsBy.none,
|
groupBy: GroupAssetsBy.none,
|
||||||
appBar: null,
|
appBar: null,
|
||||||
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
bottomSheet: const GeneralBottomSheet(minChildSize: 0.20),
|
||||||
withScrubber: false,
|
snapToMonth: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,54 +1,45 @@
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/background_sync.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|
||||||
|
|
||||||
class DownloadActionButton extends ConsumerWidget {
|
class DownloadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool menuItem;
|
||||||
|
const DownloadActionButton({super.key, required this.source, this.menuItem = false});
|
||||||
|
|
||||||
const DownloadActionButton({super.key, required this.source});
|
void _onTap(BuildContext context, WidgetRef ref, BackgroundSyncManager backgroundSyncManager) async {
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).downloadAll(source);
|
try {
|
||||||
|
await ref.read(actionProvider.notifier).downloadAll(source);
|
||||||
|
|
||||||
|
Future.delayed(const Duration(seconds: 1), () async {
|
||||||
|
await backgroundSyncManager.syncLocal();
|
||||||
|
await backgroundSyncManager.hashAssets();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final backgroundManager = ref.watch(backgroundSyncProvider);
|
||||||
|
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.download,
|
iconData: Icons.download,
|
||||||
maxWidth: 95,
|
maxWidth: 95,
|
||||||
label: "download".t(context: context),
|
label: "download".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
menuItem: menuItem,
|
||||||
|
onPressed: () => _onTap(context, ref, backgroundManager),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
@@ -649,7 +650,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
appBar: const ViewerTopAppBar(),
|
appBar: const ViewerTopAppBar(),
|
||||||
extendBody: true,
|
extendBody: true,
|
||||||
extendBodyBehindAppBar: true,
|
extendBodyBehindAppBar: true,
|
||||||
body: PhotoViewGallery.builder(
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
PhotoViewGallery.builder(
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
loadingBuilder: _placeholderBuilder,
|
loadingBuilder: _placeholderBuilder,
|
||||||
pageController: pageController,
|
pageController: pageController,
|
||||||
@@ -664,6 +668,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
bottomNavigationBar: showingBottomSheet
|
bottomNavigationBar: showingBottomSheet
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: const Column(
|
: const Column(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
@@ -56,6 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
|
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||||||
final String id;
|
final String id;
|
||||||
final Size size;
|
final Size size;
|
||||||
final AssetType assetType;
|
final AssetType assetType;
|
||||||
|
final bool exact;
|
||||||
|
|
||||||
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
|
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution, this.exact = true});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
@@ -37,7 +38,12 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||||
final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
|
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
|
final request = this.request = LocalImageRequest(
|
||||||
|
localId: key.id,
|
||||||
|
size: key.size * devicePixelRatio,
|
||||||
|
assetType: key.assetType,
|
||||||
|
);
|
||||||
return loadRequest(request, decode);
|
return loadRequest(request, decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
|||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is LocalThumbProvider) {
|
if (other is LocalThumbProvider) {
|
||||||
return id == other.id;
|
return id == other.id && (!exact || size == other.size);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -60,7 +66,12 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
final Size size;
|
final Size size;
|
||||||
final AssetType assetType;
|
final AssetType assetType;
|
||||||
|
|
||||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
|
LocalFullImageProvider({
|
||||||
|
required this.id,
|
||||||
|
required this.assetType,
|
||||||
|
required this.size,
|
||||||
|
LocalThumbProvider? initialProvider,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
@@ -71,7 +82,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
|||||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
initialImage: getInitialImage(LocalThumbProvider(id: id, assetType: assetType, exact: false)),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:ui';
|
|||||||
|
|
||||||
const double kTimelineHeaderExtent = 80.0;
|
const double kTimelineHeaderExtent = 80.0;
|
||||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
const Size kThumbnailResolution = Size.square(128);
|
||||||
const double kTimelineSpacing = 2.0;
|
const double kTimelineSpacing = 2.0;
|
||||||
const int kTimelineColumnCount = 3;
|
const int kTimelineColumnCount = 3;
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||||
|
final size = Size.square(tileHeight);
|
||||||
return FixedTimelineRow(
|
return FixedTimelineRow(
|
||||||
dimension: tileHeight,
|
dimension: tileHeight,
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
@@ -134,6 +135,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||||
asset: assets[i],
|
asset: assets[i],
|
||||||
assetIndex: assetIndex + i,
|
assetIndex: assetIndex + i,
|
||||||
|
size: size,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -144,8 +146,9 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
class _AssetTileWidget extends ConsumerWidget {
|
class _AssetTileWidget extends ConsumerWidget {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
final int assetIndex;
|
final int assetIndex;
|
||||||
|
final Size size;
|
||||||
|
|
||||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
|
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size});
|
||||||
|
|
||||||
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
|
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
|
||||||
final multiSelectState = ref.read(multiSelectProvider);
|
final multiSelectState = ref.read(multiSelectProvider);
|
||||||
@@ -203,6 +206,7 @@ class _AssetTileWidget extends ConsumerWidget {
|
|||||||
lockSelection: lockSelection,
|
lockSelection: lockSelection,
|
||||||
showStorageIndicator: showStorageIndicator,
|
showStorageIndicator: showStorageIndicator,
|
||||||
heroOffset: heroOffset,
|
heroOffset: heroOffset,
|
||||||
|
size: size,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ class Scrubber extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
final double? monthSegmentSnappingOffset;
|
final double? monthSegmentSnappingOffset;
|
||||||
|
|
||||||
|
final bool snapToMonth;
|
||||||
|
|
||||||
|
/// Whether an app bar is present, affects coordinate calculations
|
||||||
|
final bool hasAppBar;
|
||||||
|
|
||||||
Scrubber({
|
Scrubber({
|
||||||
super.key,
|
super.key,
|
||||||
Key? scrollThumbKey,
|
Key? scrollThumbKey,
|
||||||
@@ -39,6 +44,8 @@ class Scrubber extends ConsumerStatefulWidget {
|
|||||||
this.topPadding = 0,
|
this.topPadding = 0,
|
||||||
this.bottomPadding = 0,
|
this.bottomPadding = 0,
|
||||||
this.monthSegmentSnappingOffset,
|
this.monthSegmentSnappingOffset,
|
||||||
|
this.snapToMonth = true,
|
||||||
|
this.hasAppBar = true,
|
||||||
required this.child,
|
required this.child,
|
||||||
}) : assert(child.scrollDirection == Axis.vertical);
|
}) : assert(child.scrollDirection == Axis.vertical);
|
||||||
|
|
||||||
@@ -232,7 +239,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_monthCount < kMinMonthsToEnableScrubberSnap) {
|
if (_monthCount < kMinMonthsToEnableScrubberSnap || !widget.snapToMonth) {
|
||||||
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
|
// If there are less than kMinMonthsToEnableScrubberSnap months, we don't need to snap to segments
|
||||||
setState(() {
|
setState(() {
|
||||||
_thumbTopOffset = dragPosition;
|
_thumbTopOffset = dragPosition;
|
||||||
@@ -259,6 +266,7 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
/// - If user drags to global Y position that's 100 pixels from the top
|
/// - If user drags to global Y position that's 100 pixels from the top
|
||||||
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
|
/// - The relative position would be 100 - 50 = 50 (50 pixels into the scrubber area)
|
||||||
double _calculateDragPosition(DragUpdateDetails details) {
|
double _calculateDragPosition(DragUpdateDetails details) {
|
||||||
|
if (widget.hasAppBar) {
|
||||||
final dragAreaTop = widget.topPadding;
|
final dragAreaTop = widget.topPadding;
|
||||||
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
|
final dragAreaBottom = widget.timelineHeight - widget.bottomPadding;
|
||||||
final dragAreaHeight = dragAreaBottom - dragAreaTop;
|
final dragAreaHeight = dragAreaBottom - dragAreaTop;
|
||||||
@@ -269,6 +277,19 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
return relativePosition.clamp(0.0, dragAreaHeight);
|
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 relativePosition = details.globalPosition.dy - dragAreaTop;
|
||||||
|
return relativePosition.clamp(0.0, _scrubberHeight);
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the segment closest to the given position
|
/// Find the segment closest to the given position
|
||||||
_Segment? _findNearestMonthSegment(double position) {
|
_Segment? _findNearestMonthSegment(double position) {
|
||||||
_Segment? nearestSegment;
|
_Segment? nearestSegment;
|
||||||
@@ -322,6 +343,8 @@ class ScrubberState extends ConsumerState<Scrubber> with TickerProviderStateMixi
|
|||||||
_isDragging = false;
|
_isDragging = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ref.read(timelineStateProvider.notifier).setScrubbing(false);
|
||||||
|
|
||||||
// Reset scrubber tracking when drag ends
|
// Reset scrubber tracking when drag ends
|
||||||
_currentScrubberDate = null;
|
_currentScrubberDate = null;
|
||||||
_scrubberDebouncer?.dispose();
|
_scrubberDebouncer?.dispose();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
|
|||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
@@ -38,6 +39,7 @@ class Timeline extends StatelessWidget {
|
|||||||
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
|
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.18),
|
||||||
this.groupBy,
|
this.groupBy,
|
||||||
this.withScrubber = true,
|
this.withScrubber = true,
|
||||||
|
this.snapToMonth = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -48,11 +50,13 @@ class Timeline extends StatelessWidget {
|
|||||||
final bool withStack;
|
final bool withStack;
|
||||||
final GroupAssetsBy? groupBy;
|
final GroupAssetsBy? groupBy;
|
||||||
final bool withScrubber;
|
final bool withScrubber;
|
||||||
|
final bool snapToMonth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
|
floatingActionButton: const DownloadStatusFloatingButton(),
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (_, constraints) => ProviderScope(
|
builder: (_, constraints) => ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
@@ -73,6 +77,7 @@ class Timeline extends StatelessWidget {
|
|||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
bottomSheet: bottomSheet,
|
bottomSheet: bottomSheet,
|
||||||
withScrubber: withScrubber,
|
withScrubber: withScrubber,
|
||||||
|
snapToMonth: snapToMonth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -87,6 +92,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
this.appBar,
|
this.appBar,
|
||||||
this.bottomSheet,
|
this.bottomSheet,
|
||||||
this.withScrubber = true,
|
this.withScrubber = true,
|
||||||
|
this.snapToMonth = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Widget? topSliverWidget;
|
final Widget? topSliverWidget;
|
||||||
@@ -94,6 +100,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
|
|||||||
final Widget? appBar;
|
final Widget? appBar;
|
||||||
final Widget? bottomSheet;
|
final Widget? bottomSheet;
|
||||||
final bool withScrubber;
|
final bool withScrubber;
|
||||||
|
final bool snapToMonth;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState createState() => _SliverTimelineState();
|
ConsumerState createState() => _SliverTimelineState();
|
||||||
@@ -309,11 +316,13 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
final Widget timeline;
|
final Widget timeline;
|
||||||
if (widget.withScrubber) {
|
if (widget.withScrubber) {
|
||||||
timeline = Scrubber(
|
timeline = Scrubber(
|
||||||
|
snapToMonth: widget.snapToMonth,
|
||||||
layoutSegments: segments,
|
layoutSegments: segments,
|
||||||
timelineHeight: maxHeight,
|
timelineHeight: maxHeight,
|
||||||
topPadding: topPadding,
|
topPadding: topPadding,
|
||||||
bottomPadding: bottomPadding,
|
bottomPadding: bottomPadding,
|
||||||
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
|
||||||
|
hasAppBar: widget.appBar != null,
|
||||||
child: grid,
|
child: grid,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -356,7 +356,6 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
|
|
||||||
Future<ActionResult> downloadAll(ActionSource source) async {
|
Future<ActionResult> downloadAll(ActionSource source) async {
|
||||||
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
final assets = _getAssets(source).whereType<RemoteAsset>().toList(growable: false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final didEnqueue = await _service.downloadAll(assets);
|
final didEnqueue = await _service.downloadAll(assets);
|
||||||
final enqueueCount = didEnqueue.where((e) => e).length;
|
final enqueueCount = didEnqueue.where((e) => e).length;
|
||||||
|
|||||||
@@ -90,7 +90,11 @@ class DownloadRepository {
|
|||||||
final isVideo = asset.isVideo;
|
final isVideo = asset.isVideo;
|
||||||
final url = getOriginalUrlForRemoteId(id);
|
final url = getOriginalUrlForRemoteId(id);
|
||||||
|
|
||||||
if (Platform.isAndroid || livePhotoVideoId == null || isVideo) {
|
// 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) {
|
||||||
tasks[taskIndex++] = DownloadTask(
|
tasks[taskIndex++] = DownloadTask(
|
||||||
taskId: id,
|
taskId: id,
|
||||||
url: url,
|
url: url,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
|||||||
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||||
|
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
|
||||||
@@ -345,6 +346,7 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: DownloadInfoRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
|||||||
@@ -688,6 +688,22 @@ class CropImageRouteArgs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [DownloadInfoPage]
|
||||||
|
class DownloadInfoRoute extends PageRouteInfo<void> {
|
||||||
|
const DownloadInfoRoute({List<PageRouteInfo>? children})
|
||||||
|
: super(DownloadInfoRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'DownloadInfoRoute';
|
||||||
|
|
||||||
|
static PageInfo page = PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const DownloadInfoPage();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [DriftActivitiesPage]
|
/// [DriftActivitiesPage]
|
||||||
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
class DriftActivitiesRoute extends PageRouteInfo<void> {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/repositories/download.repository.dart';
|
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
@@ -11,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
|||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/download.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||||
@@ -199,14 +199,11 @@ class ActionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
||||||
int removedCount = 0;
|
|
||||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
||||||
|
|
||||||
if (result.removed.isNotEmpty) {
|
if (result.removed.isNotEmpty) {
|
||||||
removedCount = await _remoteAlbumRepository.removeAssets(albumId, result.removed);
|
await _remoteAlbumRepository.removeAssets(albumId, result.removed);
|
||||||
}
|
}
|
||||||
|
return result.removed.length;
|
||||||
return removedCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateDescription(String assetId, String description) async {
|
Future<bool> updateDescription(String assetId, String description) async {
|
||||||
|
|||||||
+4
-1
@@ -13,6 +13,7 @@ import 'schema_v7.dart' as v7;
|
|||||||
import 'schema_v8.dart' as v8;
|
import 'schema_v8.dart' as v8;
|
||||||
import 'schema_v9.dart' as v9;
|
import 'schema_v9.dart' as v9;
|
||||||
import 'schema_v10.dart' as v10;
|
import 'schema_v10.dart' as v10;
|
||||||
|
import 'schema_v11.dart' as v11;
|
||||||
|
|
||||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||||
@override
|
@override
|
||||||
@@ -38,10 +39,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||||||
return v9.DatabaseAtV9(db);
|
return v9.DatabaseAtV9(db);
|
||||||
case 10:
|
case 10:
|
||||||
return v10.DatabaseAtV10(db);
|
return v10.DatabaseAtV10(db);
|
||||||
|
case 11:
|
||||||
|
return v11.DatabaseAtV11(db);
|
||||||
default:
|
default:
|
||||||
throw MissingSchemaException(version, versions);
|
throw MissingSchemaException(version, versions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||||
}
|
}
|
||||||
|
|||||||
+7198
File diff suppressed because it is too large
Load Diff
@@ -57,28 +57,28 @@ export class MediaRepository {
|
|||||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message);
|
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message);
|
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jxl };
|
return { buffer, format: RawExtractedFormat.Jxl };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message);
|
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
||||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.debug('Could not extract preview buffer from image', error.message);
|
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class MetadataRepository {
|
|||||||
|
|
||||||
readTags(path: string): Promise<ImmichTags> {
|
readTags(path: string): Promise<ImmichTags> {
|
||||||
return this.exiftool.read(path).catch((error) => {
|
return this.exiftool.read(path).catch((error) => {
|
||||||
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
||||||
return {};
|
return {};
|
||||||
}) as Promise<ImmichTags>;
|
}) as Promise<ImmichTags>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
|
|||||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
|
||||||
}
|
}
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
|
this.logger.warn(`Unable to sync oauth profile picture: ${error}\n${error?.stack}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,12 +132,12 @@ export class BackupService extends BaseService {
|
|||||||
gzip.stdout.pipe(fileStream);
|
gzip.stdout.pipe(fileStream);
|
||||||
|
|
||||||
pgdump.on('error', (err) => {
|
pgdump.on('error', (err) => {
|
||||||
this.logger.error('Backup failed with error', err);
|
this.logger.error(`Backup failed with error: ${err}`);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
gzip.on('error', (err) => {
|
gzip.on('error', (err) => {
|
||||||
this.logger.error('Gzip failed with error', err);
|
this.logger.error(`Gzip failed with error: ${err}`);
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
|
|||||||
});
|
});
|
||||||
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Database Backup Failure', error);
|
this.logger.error(`Database Backup Failure: ${error}`);
|
||||||
await this.storageRepository
|
await this.storageRepository
|
||||||
.unlink(backupFilePath)
|
.unlink(backupFilePath)
|
||||||
.catch((error) => this.logger.error('Failed to delete failed backup file', error));
|
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
|
|||||||
job.paths.map((path) =>
|
job.paths.map((path) =>
|
||||||
this.processEntity(path, library.ownerId, job.libraryId)
|
this.processEntity(path, library.ownerId, job.libraryId)
|
||||||
.then((asset) => assetImports.push(asset))
|
.then((asset) => assetImports.push(asset))
|
||||||
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)),
|
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class MemoryService extends BaseService {
|
|||||||
try {
|
try {
|
||||||
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
|
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
|
||||||
}
|
}
|
||||||
// update system metadata even when there is an error to minimize the chance of duplicates
|
// update system metadata even when there is an error to minimize the chance of duplicates
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {
|
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
|
|
||||||
return destination;
|
return destination;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Unable to get template path for ${filename}`, error);
|
this.logger.error(`Unable to get template path for ${filename}: ${error}`);
|
||||||
return asset.originalPath;
|
return asset.originalPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
|
|||||||
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
|
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
|
||||||
}
|
}
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
|
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const sendFile = async (
|
|||||||
|
|
||||||
// log non-http errors
|
// log non-http errors
|
||||||
if (error instanceof HttpException === false) {
|
if (error instanceof HttpException === false) {
|
||||||
logger.error(`Unable to send file: ${error.name}`, error.stack);
|
logger.error(`Unable to send file: ${error}`, error.stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.header('Cache-Control', 'none');
|
res.header('Cache-Control', 'none');
|
||||||
|
|||||||
@@ -240,7 +240,7 @@
|
|||||||
use:zoomImageAction
|
use:zoomImageAction
|
||||||
use:swipe={() => ({})}
|
use:swipe={() => ({})}
|
||||||
onswipe={onSwipe}
|
onswipe={onSwipe}
|
||||||
class="h-full w-full"
|
class="h-full w-full flex"
|
||||||
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
|
||||||
>
|
>
|
||||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
bind:this={$photoViewerImgElement}
|
bind:this={$photoViewerImgElement}
|
||||||
src={assetFileUrl}
|
src={assetFileUrl}
|
||||||
alt={$getAltText(toTimelineAsset(asset))}
|
alt={$getAltText(toTimelineAsset(asset))}
|
||||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
class="max-h-full max-w-full h-auto w-auto mx-auto my-auto {$slideshowState === SlideshowState.None
|
||||||
? 'object-contain'
|
? 'object-contain'
|
||||||
: slideshowLookCssMapping[$slideshowLook]}"
|
: slideshowLookCssMapping[$slideshowLook]}"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|||||||
@@ -85,8 +85,8 @@
|
|||||||
onerror={setErrored}
|
onerror={setErrored}
|
||||||
style:width={widthStyle}
|
style:width={widthStyle}
|
||||||
style:height={heightStyle}
|
style:height={heightStyle}
|
||||||
|
style:filter={hidden ? 'grayscale(50%)' : 'none'}
|
||||||
style:opacity={hidden ? '0.5' : '1'}
|
style:opacity={hidden ? '0.5' : '1'}
|
||||||
style:filter="blur(7px)"
|
|
||||||
src={url}
|
src={url}
|
||||||
alt={loaded || errored ? altText : ''}
|
alt={loaded || errored ? altText : ''}
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -88,7 +88,6 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="h-full w-full rounded-xl object-cover"
|
class="h-full w-full rounded-xl object-cover"
|
||||||
style:filter="blur(5px)"
|
|
||||||
src={getAssetThumbnailUrl(memory.assets[0].id)}
|
src={getAssetThumbnailUrl(memory.assets[0].id)}
|
||||||
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
|
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
|
||||||
import AssetLayout from '$lib/components/timeline/base-components/AssetLayout.svelte';
|
|
||||||
import BaseTimelineViewer from '$lib/components/timeline/base-components/Stream.svelte';
|
|
||||||
import SelectableTimelineMonth from '$lib/components/timeline/internal-components/SelectableSegment.svelte';
|
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
|
||||||
import { SearchStreamManager } from '$lib/managers/timeline-manager/SearchStreamManager.svelte';
|
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
searchTerms: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { searchTerms }: Props = $props();
|
|
||||||
|
|
||||||
let viewer: BaseTimelineViewer | undefined = $state();
|
|
||||||
let showSkeleton: boolean = $state(true);
|
|
||||||
|
|
||||||
const timelineManager = new SearchStreamManager(searchTerms, { isSmartSearchEnabled: true });
|
|
||||||
timelineManager.init();
|
|
||||||
const assetInteraction = new AssetInteraction();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BaseTimelineViewer
|
|
||||||
showScrollbar={true}
|
|
||||||
enableRouting={false}
|
|
||||||
{timelineManager}
|
|
||||||
isShowDeleteConfirmation={true}
|
|
||||||
{showSkeleton}
|
|
||||||
>
|
|
||||||
{#snippet skeleton({ segment })}
|
|
||||||
<Skeleton height={segment.height - segment.timelineManager.headerHeight} title={''} />
|
|
||||||
{/snippet}
|
|
||||||
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
|
||||||
<SelectableTimelineMonth
|
|
||||||
{segment}
|
|
||||||
{onScrollCompensationMonthInDOM}
|
|
||||||
{timelineManager}
|
|
||||||
{assetInteraction}
|
|
||||||
isSelectionMode={false}
|
|
||||||
singleSelect={false}
|
|
||||||
>
|
|
||||||
{#snippet content({ onAssetOpen, onAssetSelect, onHover })}
|
|
||||||
<AssetLayout
|
|
||||||
photostreamManager={timelineManager}
|
|
||||||
viewerAssets={segment.viewerAssets}
|
|
||||||
height={segment.height}
|
|
||||||
width={timelineManager.viewportWidth}
|
|
||||||
>
|
|
||||||
{#snippet thumbnail({ asset, position })}
|
|
||||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
|
||||||
{@const isAssetSelected = assetInteraction.hasSelectedAsset(asset.id)}
|
|
||||||
<Thumbnail
|
|
||||||
showStackedIcon={true}
|
|
||||||
showArchiveIcon={true}
|
|
||||||
{asset}
|
|
||||||
onClick={() => onAssetOpen(asset)}
|
|
||||||
onSelect={() => onAssetSelect(asset)}
|
|
||||||
onMouseEvent={() => onHover(asset)}
|
|
||||||
selected={isAssetSelected}
|
|
||||||
selectionCandidate={isAssetSelectionCandidate}
|
|
||||||
thumbnailWidth={position.width}
|
|
||||||
thumbnailHeight={position.height}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</AssetLayout>
|
|
||||||
{/snippet}
|
|
||||||
</SelectableTimelineMonth>
|
|
||||||
{/snippet}
|
|
||||||
</BaseTimelineViewer>
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { getTabbable } from '$lib/utils/focus-util';
|
import { getTabbable } from '$lib/utils/focus-util';
|
||||||
import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import { Icon } from '@immich/ui';
|
import { Icon } from '@immich/ui';
|
||||||
import { mdiPlay } from '@mdi/js';
|
import { mdiPlay } from '@mdi/js';
|
||||||
import { clamp } from 'lodash-es';
|
import { clamp } from 'lodash-es';
|
||||||
@@ -11,31 +11,18 @@
|
|||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Offset from the top of the timeline (e.g., for headers) */
|
|
||||||
timelineTopOffset?: number;
|
timelineTopOffset?: number;
|
||||||
/** Offset from the bottom of the timeline (e.g., for footers) */
|
|
||||||
timelineBottomOffset?: number;
|
timelineBottomOffset?: number;
|
||||||
/** Total height of the scrubber component */
|
|
||||||
height?: number;
|
height?: number;
|
||||||
/** Timeline manager instance that controls the timeline state */
|
|
||||||
timelineManager: TimelineManager;
|
timelineManager: TimelineManager;
|
||||||
/** Overall scroll percentage through the entire timeline (0-1), used when no specific month is targeted */
|
scrubOverallPercent?: number;
|
||||||
timelineScrollPercent?: number;
|
scrubberMonthPercent?: number;
|
||||||
/** The percentage of scroll through the month that is currently intersecting the top boundary of the viewport */
|
scrubberMonth?: { year: number; month: number };
|
||||||
viewportTopMonthScrollPercent?: number;
|
leadout?: boolean;
|
||||||
/** The year/month of the timeline month at the top of the viewport */
|
|
||||||
viewportTopMonth?: TimelineYearMonth;
|
|
||||||
/** Indicates whether the viewport is currently in the lead-out section (after all months) */
|
|
||||||
isInLeadOutSection?: boolean;
|
|
||||||
/** Width of the scrubber component in pixels (bindable for parent component margin adjustments) */
|
|
||||||
scrubberWidth?: number;
|
scrubberWidth?: number;
|
||||||
/** Callback fired when user interacts with the scrubber to navigate */
|
|
||||||
onScrub?: ScrubberListener;
|
onScrub?: ScrubberListener;
|
||||||
/** Callback fired when keyboard events occur on the scrubber */
|
|
||||||
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
||||||
/** Callback fired when scrubbing starts */
|
|
||||||
startScrub?: ScrubberListener;
|
startScrub?: ScrubberListener;
|
||||||
/** Callback fired when scrubbing stops */
|
|
||||||
stopScrub?: ScrubberListener;
|
stopScrub?: ScrubberListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +31,10 @@
|
|||||||
timelineBottomOffset = 0,
|
timelineBottomOffset = 0,
|
||||||
height = 0,
|
height = 0,
|
||||||
timelineManager,
|
timelineManager,
|
||||||
timelineScrollPercent = 0,
|
scrubOverallPercent = 0,
|
||||||
viewportTopMonthScrollPercent = 0,
|
scrubberMonthPercent = 0,
|
||||||
viewportTopMonth = undefined,
|
scrubberMonth = undefined,
|
||||||
isInLeadOutSection = false,
|
leadout = false,
|
||||||
onScrub = undefined,
|
onScrub = undefined,
|
||||||
onScrubKeyDown = undefined,
|
onScrubKeyDown = undefined,
|
||||||
startScrub = undefined,
|
startScrub = undefined,
|
||||||
@@ -113,7 +100,7 @@
|
|||||||
offset += scrubberMonthPercent * relativeBottomOffset;
|
offset += scrubberMonthPercent * relativeBottomOffset;
|
||||||
}
|
}
|
||||||
return offset;
|
return offset;
|
||||||
} else if (isInLeadOutSection) {
|
} else if (leadout) {
|
||||||
let offset = relativeTopOffset;
|
let offset = relativeTopOffset;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
offset += segment.height;
|
offset += segment.height;
|
||||||
@@ -124,9 +111,7 @@
|
|||||||
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let scrollY = $derived(
|
let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent));
|
||||||
toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent),
|
|
||||||
);
|
|
||||||
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||||
@@ -310,24 +295,12 @@
|
|||||||
|
|
||||||
const scrollPercent = toTimelineY(hoverY);
|
const scrollPercent = toTimelineY(hoverY);
|
||||||
if (wasDragging === false && isDragging) {
|
if (wasDragging === false && isDragging) {
|
||||||
void startScrub?.({
|
void startScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
scrubberMonth: segmentDate!,
|
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
void onScrub?.({
|
|
||||||
scrubberMonth: segmentDate!,
|
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasDragging && !isDragging) {
|
if (wasDragging && !isDragging) {
|
||||||
void stopScrub?.({
|
void stopScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
scrubberMonth: segmentDate!,
|
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,11 +308,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onScrub?.({
|
void onScrub?.(segmentDate!, scrollPercent, monthGroupPercentY);
|
||||||
scrubberMonth: segmentDate!,
|
|
||||||
overallScrollPercent: scrollPercent,
|
|
||||||
scrubberMonthScrollPercent: monthGroupPercentY,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
/* eslint-disable tscompat/tscompat */
|
/* eslint-disable tscompat/tscompat */
|
||||||
const getTouch = (event: TouchEvent) => {
|
const getTouch = (event: TouchEvent) => {
|
||||||
@@ -443,11 +412,7 @@
|
|||||||
}
|
}
|
||||||
if (next) {
|
if (next) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void onScrub?.({
|
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
||||||
scrubberMonth: { year: next.year, month: next.month },
|
|
||||||
overallScrollPercent: -1,
|
|
||||||
scrubberMonthScrollPercent: 0,
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,11 +422,7 @@
|
|||||||
const next = segments[idx + 1];
|
const next = segments[idx + 1];
|
||||||
if (next) {
|
if (next) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void onScrub?.({
|
void onScrub?.({ year: next.year, month: next.month }, -1, 0);
|
||||||
scrubberMonth: { year: next.year, month: next.month },
|
|
||||||
overallScrollPercent: -1,
|
|
||||||
scrubberMonthScrollPercent: 0,
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
|
||||||
|
|
||||||
import { flip } from 'svelte/animate';
|
|
||||||
import { scale } from 'svelte/transition';
|
|
||||||
|
|
||||||
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
let { isUploading } = uploadAssetsStore;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
viewerAssets: ViewerAsset[];
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
photostreamManager: PhotostreamManager;
|
|
||||||
thumbnail: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
asset: TimelineAsset;
|
|
||||||
position: CommonPosition;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { viewerAssets, width, height, photostreamManager, thumbnail, customThumbnailLayout }: Props = $props();
|
|
||||||
|
|
||||||
const transitionDuration = $derived.by(() => (photostreamManager.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);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Image grid -->
|
|
||||||
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
|
|
||||||
{#each filterIntersecting(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 }}
|
|
||||||
>
|
|
||||||
{@render thumbnail({ asset, position })}
|
|
||||||
{@render customThumbnailLayout?.(asset)}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
[data-image-grid] {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
<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 { 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 { fly } from 'svelte/transition';
|
|
||||||
|
|
||||||
import AssetLayout from '$lib/components/timeline/base-components/AssetLayout.svelte';
|
|
||||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
let { isUploading } = uploadAssetsStore;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
|
|
||||||
customThumbnailLayout?: Snippet<[TimelineAsset]>;
|
|
||||||
|
|
||||||
singleSelect: boolean;
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
monthGroup: MonthGroup;
|
|
||||||
timelineManager: TimelineManager;
|
|
||||||
|
|
||||||
onDayGroupSelect: (daygroup: DayGroup, assets: TimelineAsset[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
thumbnail: thumbnailWithGroup,
|
|
||||||
customThumbnailLayout,
|
|
||||||
singleSelect,
|
|
||||||
assetInteraction,
|
|
||||||
monthGroup,
|
|
||||||
timelineManager,
|
|
||||||
onDayGroupSelect,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let isMouseOverGroup = $state(false);
|
|
||||||
let hoveredDayGroup = $state();
|
|
||||||
|
|
||||||
const transitionDuration = $derived.by(() =>
|
|
||||||
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
|
||||||
);
|
|
||||||
|
|
||||||
function filterIntersecting<R extends { intersecting: boolean }>(intersectables: R[]) {
|
|
||||||
return intersectables.filter((intersectable) => intersectable.intersecting);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
|
||||||
{@const absoluteWidth = dayGroup.left}
|
|
||||||
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
|
||||||
<!-- 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)}
|
|
||||||
<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}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<AssetLayout
|
|
||||||
photostreamManager={timelineManager}
|
|
||||||
viewerAssets={dayGroup.viewerAssets}
|
|
||||||
height={dayGroup.height}
|
|
||||||
width={dayGroup.width}
|
|
||||||
{customThumbnailLayout}
|
|
||||||
>
|
|
||||||
{#snippet thumbnail({ asset, position })}
|
|
||||||
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
|
|
||||||
{/snippet}
|
|
||||||
</AssetLayout>
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
section {
|
|
||||||
contain: layout paint style;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
|
||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
|
||||||
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.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 {
|
|
||||||
segment: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
scrollToFunction: (top: number) => void;
|
|
||||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
skeleton: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
|
|
||||||
showScrollbar?: 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: PhotostreamManager;
|
|
||||||
|
|
||||||
showSkeleton?: boolean;
|
|
||||||
isShowDeleteConfirmation?: boolean;
|
|
||||||
styleMarginRightOverride?: string;
|
|
||||||
|
|
||||||
header?: Snippet<[scrollToFunction: (top: number) => void]>;
|
|
||||||
children?: Snippet;
|
|
||||||
empty?: Snippet;
|
|
||||||
handleTimelineScroll?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
segment,
|
|
||||||
|
|
||||||
enableRouting,
|
|
||||||
timelineManager = $bindable(),
|
|
||||||
showSkeleton = $bindable(true),
|
|
||||||
styleMarginRightOverride,
|
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
|
||||||
showScrollbar,
|
|
||||||
children,
|
|
||||||
skeleton,
|
|
||||||
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 handleTriggeredScrollCompensation = (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: PhotostreamSegment) => {
|
|
||||||
// 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) {
|
|
||||||
handleTriggeredScrollCompensation(timelineManager.scrollCompensation);
|
|
||||||
}
|
|
||||||
return height;
|
|
||||||
};
|
|
||||||
|
|
||||||
const assetIsVisible = (assetTop: number): boolean => {
|
|
||||||
if (!element) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { clientHeight, scrollTop } = element;
|
|
||||||
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scrollToAssetId = (assetId: string) => {
|
|
||||||
const monthGroup = timelineManager.getSegmentForAssetId(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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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={[
|
|
||||||
'h-full overflow-y-auto outline-none',
|
|
||||||
{ 'scrollbar-hidden': !showScrollbar },
|
|
||||||
{ 'm-0': isEmpty },
|
|
||||||
{ 'ms-0': !isEmpty },
|
|
||||||
]}
|
|
||||||
style:margin-right={styleMarginRightOverride}
|
|
||||||
style:scrollbar-width={showScrollbar ? 'auto' : 'none'}
|
|
||||||
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.id)}
|
|
||||||
{@const shouldDisplay = monthGroup.intersecting}
|
|
||||||
{@const absoluteHeight = monthGroup.top}
|
|
||||||
<div
|
|
||||||
class="month-group"
|
|
||||||
style:height={monthGroup.height + 'px'}
|
|
||||||
style:position="absolute"
|
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
|
||||||
style:width="100%"
|
|
||||||
>
|
|
||||||
{#if !shouldDisplay}
|
|
||||||
{@render skeleton({ segment: monthGroup })}
|
|
||||||
{:else}
|
|
||||||
{@render segment({
|
|
||||||
segment: monthGroup,
|
|
||||||
scrollToFunction: scrollTo,
|
|
||||||
onScrollCompensationMonthInDOM: handleTriggeredScrollCompensation,
|
|
||||||
})}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/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>
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import BaseTimelineViewer from '$lib/components/timeline/base-components/Stream.svelte';
|
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** `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;
|
|
||||||
|
|
||||||
showSkeleton?: boolean;
|
|
||||||
isShowDeleteConfirmation?: boolean;
|
|
||||||
|
|
||||||
segment: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
skeleton: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
children?: Snippet;
|
|
||||||
empty?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
enableRouting,
|
|
||||||
timelineManager = $bindable(),
|
|
||||||
|
|
||||||
showSkeleton = $bindable(true),
|
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
|
||||||
segment,
|
|
||||||
skeleton,
|
|
||||||
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?.scrollToAssetId(asset.id) ?? false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<BaseTimelineViewer
|
|
||||||
bind:this={baseTimelineViewer}
|
|
||||||
{enableRouting}
|
|
||||||
{timelineManager}
|
|
||||||
{showSkeleton}
|
|
||||||
{isShowDeleteConfirmation}
|
|
||||||
styleMarginRightOverride={scrubberWidth + 'px'}
|
|
||||||
{handleTimelineScroll}
|
|
||||||
{segment}
|
|
||||||
{skeleton}
|
|
||||||
{children}
|
|
||||||
{empty}
|
|
||||||
>
|
|
||||||
{#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>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
|
|
||||||
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
|
||||||
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
content: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
|
|
||||||
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
onAssetSelect: (asset: TimelineAsset) => void;
|
|
||||||
timelineManager: PhotostreamManager;
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { content, assetInteraction, timelineManager, onAssetSelect }: Props = $props();
|
|
||||||
|
|
||||||
// called when clicking asset with shift key pressed or with mouse
|
|
||||||
const onDayGroupAssetSelect = (dayGroup: DayGroup, asset: TimelineAsset) => {
|
|
||||||
onAssetSelect(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
|
|
||||||
const 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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDayGroupSelect = (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) {
|
|
||||||
onAssetSelect(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render content({
|
|
||||||
onDayGroupSelect,
|
|
||||||
onDayGroupAssetSelect,
|
|
||||||
})}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
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 type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
|
||||||
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
content: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
onAssetOpen: (asset: TimelineAsset) => void;
|
|
||||||
onAssetSelect: (asset: TimelineAsset) => void;
|
|
||||||
onHover: (asset: TimelineAsset) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
isSelectionMode: boolean;
|
|
||||||
singleSelect: boolean;
|
|
||||||
timelineManager: PhotostreamManager;
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
|
||||||
onSelect?: (isSingleSelect: boolean, asset: TimelineAsset) => void;
|
|
||||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
segment,
|
|
||||||
content,
|
|
||||||
isSelectionMode,
|
|
||||||
singleSelect,
|
|
||||||
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 = (asset: TimelineAsset) => {
|
|
||||||
if (isSelectionMode || assetInteraction.selectionActive) {
|
|
||||||
handleAssetSelect(asset);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void navigate({ targetRoute: 'current', assetId: asset.id });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnAssetOpen = (asset: TimelineAsset) => {
|
|
||||||
if (onAssetOpen) {
|
|
||||||
onAssetOpen(asset, () => defaultAssetOpen(asset));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
defaultAssetOpen(asset);
|
|
||||||
};
|
|
||||||
|
|
||||||
// called when clicking asset with shift key pressed or with mouse
|
|
||||||
const handleAssetSelect = (asset: TimelineAsset) => {
|
|
||||||
void onSelectAssets(asset);
|
|
||||||
|
|
||||||
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
|
||||||
isSelectingAllAssets.set(true);
|
|
||||||
} else {
|
|
||||||
isSelectingAllAssets.set(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = (asset: TimelineAsset) => {
|
|
||||||
if (assetInteraction.selectionActive) {
|
|
||||||
void selectAssetCandidates(asset);
|
|
||||||
}
|
|
||||||
lastAssetMouseEvent = asset;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSelectAssets = async (asset: TimelineAsset) => {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSelect?.(singleSelect, asset);
|
|
||||||
|
|
||||||
if (singleSelect) {
|
|
||||||
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) {
|
|
||||||
const assets = await timelineManager.retrieveRange(assetInteraction.assetSelectionStart, asset);
|
|
||||||
for (const asset of assets) {
|
|
||||||
if (deselect) {
|
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
|
||||||
} else {
|
|
||||||
handleSelectAsset(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAsset = (asset: TimelineAsset) => {
|
|
||||||
if ('albumAssets' in timelineManager) {
|
|
||||||
const tm = timelineManager as TimelineManager;
|
|
||||||
if (tm.albumAssets.has(asset.id)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assetInteraction.selectAsset(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);
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect.root(() => {
|
|
||||||
if (timelineManager.scrollCompensation.monthGroup === segment) {
|
|
||||||
onScrollCompensationMonthInDOM(timelineManager.scrollCompensation);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
|
|
||||||
|
|
||||||
{@render content({
|
|
||||||
onAssetOpen: handleOnAssetOpen,
|
|
||||||
onAssetSelect: handleSelectAsset,
|
|
||||||
onHover: handleOnHover,
|
|
||||||
})}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
<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}
|
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import type { UpdatePayload } from 'vite';
|
import type { UpdatePayload } from 'vite';
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
onAfterUpdate: (payload: UpdatePayload) => void;
|
onBeforeUpdate?: (payload: UpdatePayload) => void;
|
||||||
}
|
onAfterUpdate?: (payload: UpdatePayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
let { onAfterUpdate }: Props = $props();
|
let { onBeforeUpdate, onAfterUpdate }: Props = $props();
|
||||||
|
|
||||||
|
const unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const hot = import.meta.hot;
|
const hot = import.meta.hot;
|
||||||
if (hot) {
|
if (!hot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onBeforeUpdate) {
|
||||||
|
hot.on('vite:beforeUpdate', onBeforeUpdate);
|
||||||
|
unsubscribes.push(() => hot.off('vite:beforeUpdate', onBeforeUpdate));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onAfterUpdate) {
|
||||||
hot.on('vite:afterUpdate', onAfterUpdate);
|
hot.on('vite:afterUpdate', onAfterUpdate);
|
||||||
return () => hot.off('vite:afterUpdate', onAfterUpdate);
|
unsubscribes.push(() => hot.off('vite:afterUpdate', onAfterUpdate));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const unsubscribe of unsubscribes) {
|
||||||
|
unsubscribe();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
|
||||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
|
||||||
import { clamp, debounce } from 'lodash-es';
|
|
||||||
|
|
||||||
import type { PhotostreamSegment, SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import type { AssetDescriptor, TimelineAsset, TimelineManagerLayoutOptions, Viewport } from './types';
|
|
||||||
|
|
||||||
export abstract class PhotostreamManager {
|
|
||||||
isInitialized = $state(false);
|
|
||||||
topSectionHeight = $state(0);
|
|
||||||
bottomSectionHeight = $state(60);
|
|
||||||
abstract get months(): PhotostreamSegment[];
|
|
||||||
timelineHeight = $derived.by(
|
|
||||||
() => this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight,
|
|
||||||
);
|
|
||||||
assetCount = $derived.by(() => this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
|
||||||
|
|
||||||
topIntersectingMonthGroup: PhotostreamSegment | undefined = $state();
|
|
||||||
|
|
||||||
visibleWindow = $derived.by(() => ({
|
|
||||||
top: this.#scrollTop,
|
|
||||||
bottom: this.#scrollTop + this.viewportHeight,
|
|
||||||
}));
|
|
||||||
|
|
||||||
protected initTask = new CancellableTask(
|
|
||||||
() => (this.isInitialized = true),
|
|
||||||
() => (this.isInitialized = false),
|
|
||||||
() => void 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
#viewportHeight = $state(0);
|
|
||||||
#viewportWidth = $state(0);
|
|
||||||
#scrollTop = $state(0);
|
|
||||||
|
|
||||||
#rowHeight = $state(235);
|
|
||||||
#headerHeight = $state(48);
|
|
||||||
#gap = $state(12);
|
|
||||||
|
|
||||||
#scrolling = $state(false);
|
|
||||||
#suspendTransitions = $state(false);
|
|
||||||
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
|
||||||
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
|
||||||
scrollCompensation: {
|
|
||||||
heightDelta: number | undefined;
|
|
||||||
scrollTop: number | undefined;
|
|
||||||
monthGroup: PhotostreamSegment | undefined;
|
|
||||||
} = $state({
|
|
||||||
heightDelta: 0,
|
|
||||||
scrollTop: 0,
|
|
||||||
monthGroup: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
|
|
||||||
let changed = false;
|
|
||||||
changed ||= this.#setHeaderHeight(headerHeight);
|
|
||||||
changed ||= this.#setGap(gap);
|
|
||||||
changed ||= this.#setRowHeight(rowHeight);
|
|
||||||
if (changed) {
|
|
||||||
this.refreshLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#setHeaderHeight(value: number) {
|
|
||||||
if (this.#headerHeight == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#headerHeight = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get headerHeight() {
|
|
||||||
return this.#headerHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setGap(value: number) {
|
|
||||||
if (this.#gap == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#gap = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get gap() {
|
|
||||||
return this.#gap;
|
|
||||||
}
|
|
||||||
|
|
||||||
#setRowHeight(value: number) {
|
|
||||||
if (this.#rowHeight == value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.#rowHeight = value;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
get rowHeight() {
|
|
||||||
return this.#rowHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
set scrolling(value: boolean) {
|
|
||||||
this.#scrolling = value;
|
|
||||||
if (value) {
|
|
||||||
this.suspendTransitions = true;
|
|
||||||
this.#resetScrolling();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get scrolling() {
|
|
||||||
return this.#scrolling;
|
|
||||||
}
|
|
||||||
|
|
||||||
set suspendTransitions(value: boolean) {
|
|
||||||
this.#suspendTransitions = value;
|
|
||||||
if (value) {
|
|
||||||
this.#resetSuspendTransitions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get suspendTransitions() {
|
|
||||||
return this.#suspendTransitions;
|
|
||||||
}
|
|
||||||
|
|
||||||
set viewportWidth(value: number) {
|
|
||||||
const changed = value !== this.#viewportWidth;
|
|
||||||
this.#viewportWidth = value;
|
|
||||||
this.suspendTransitions = true;
|
|
||||||
void this.updateViewportGeometry(changed);
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewportWidth() {
|
|
||||||
return this.#viewportWidth;
|
|
||||||
}
|
|
||||||
|
|
||||||
set viewportHeight(value: number) {
|
|
||||||
this.#viewportHeight = value;
|
|
||||||
this.#suspendTransitions = true;
|
|
||||||
void this.updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewportHeight() {
|
|
||||||
return this.#viewportHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSlidingWindow(scrollTop: number) {
|
|
||||||
if (this.#scrollTop !== scrollTop) {
|
|
||||||
this.#scrollTop = scrollTop;
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearScrollCompensation() {
|
|
||||||
this.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: undefined,
|
|
||||||
monthGroup: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersections() {
|
|
||||||
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let topIntersectingMonthGroup = undefined;
|
|
||||||
for (const month of this.months) {
|
|
||||||
updateIntersectionMonthGroup(this, month);
|
|
||||||
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
|
|
||||||
topIntersectingMonthGroup = month;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
|
|
||||||
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
|
|
||||||
}
|
|
||||||
for (const month of this.months) {
|
|
||||||
if (month === this.topIntersectingMonthGroup) {
|
|
||||||
this.topIntersectingMonthGroup.percent = clamp(
|
|
||||||
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
month.percent = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.isInitialized = false;
|
|
||||||
await this.initTask.execute(async () => undefined, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
|
||||||
this.isInitialized = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateViewport(viewport: Viewport) {
|
|
||||||
if (viewport.height === 0 && viewport.width === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.initTask.executed) {
|
|
||||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.init());
|
|
||||||
}
|
|
||||||
|
|
||||||
const changedWidth = viewport.width !== this.viewportWidth;
|
|
||||||
this.viewportHeight = viewport.height;
|
|
||||||
this.viewportWidth = viewport.width;
|
|
||||||
this.updateViewportGeometry(changedWidth);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected updateViewportGeometry(changedWidth: boolean) {
|
|
||||||
if (!this.isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const month of this.months) {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
|
||||||
}
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
|
|
||||||
createLayoutOptions() {
|
|
||||||
const viewportWidth = this.viewportWidth;
|
|
||||||
|
|
||||||
return {
|
|
||||||
spacing: 2,
|
|
||||||
heightTolerance: 0.15,
|
|
||||||
rowHeight: this.#rowHeight,
|
|
||||||
rowWidth: Math.floor(viewportWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSegment(identifier: SegmentIdentifier, options?: { cancelable: boolean }): Promise<void> {
|
|
||||||
let cancelable = true;
|
|
||||||
if (options) {
|
|
||||||
cancelable = options.cancelable;
|
|
||||||
}
|
|
||||||
const segment = this.getSegmentByIdentifier(identifier);
|
|
||||||
if (!segment) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await segment.load(cancelable);
|
|
||||||
if (result === 'LOADED') {
|
|
||||||
updateIntersectionMonthGroup(this, segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSegmentByIdentifier(identifier: SegmentIdentifier) {
|
|
||||||
return this.months.find((segment) => identifier.matches(segment));
|
|
||||||
}
|
|
||||||
|
|
||||||
getSegmentForAssetId(assetId: String) {
|
|
||||||
for (const month of this.months) {
|
|
||||||
const asset = month.assets.find((asset) => asset.id === assetId);
|
|
||||||
if (asset) {
|
|
||||||
return month;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshLayout() {
|
|
||||||
for (const month of this.months) {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
|
||||||
}
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
|
|
||||||
getMaxScrollPercent() {
|
|
||||||
const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight;
|
|
||||||
return (totalHeight - this.viewportHeight) / totalHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMaxScroll() {
|
|
||||||
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
|
|
||||||
const range: TimelineAsset[] = [];
|
|
||||||
let collecting = false;
|
|
||||||
|
|
||||||
for (const month of this.months) {
|
|
||||||
for (const asset of month.assets) {
|
|
||||||
if (asset.id === start.id) collecting = true;
|
|
||||||
if (collecting) range.push(asset);
|
|
||||||
if (asset.id === end.id) return range;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return range;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
|
||||||
|
|
||||||
export type SegmentIdentifier = {
|
|
||||||
matches(segment: PhotostreamSegment): boolean;
|
|
||||||
};
|
|
||||||
export abstract class PhotostreamSegment {
|
|
||||||
#intersecting = $state(false);
|
|
||||||
actuallyIntersecting = $state(false);
|
|
||||||
#isLoaded = $state(false);
|
|
||||||
|
|
||||||
#height = $state(0);
|
|
||||||
#top = $state(0);
|
|
||||||
#assets = $derived.by(() => this.viewerAssets.map((viewerAsset) => viewerAsset.asset));
|
|
||||||
|
|
||||||
initialCount = $state(0);
|
|
||||||
percent = $state(0);
|
|
||||||
|
|
||||||
assetsCount = $derived.by(() => (this.isLoaded ? this.viewerAssets.length : this.initialCount));
|
|
||||||
loader = new CancellableTask(
|
|
||||||
() => this.markLoaded(),
|
|
||||||
() => this.markCanceled,
|
|
||||||
() => this.handleLoadError,
|
|
||||||
);
|
|
||||||
isHeightActual = $state(false);
|
|
||||||
|
|
||||||
abstract get timelineManager(): PhotostreamManager;
|
|
||||||
|
|
||||||
abstract get identifier(): SegmentIdentifier;
|
|
||||||
|
|
||||||
abstract get id(): string;
|
|
||||||
|
|
||||||
get isLoaded() {
|
|
||||||
return this.#isLoaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected markLoaded() {
|
|
||||||
this.#isLoaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected markCanceled() {
|
|
||||||
this.#isLoaded = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
set intersecting(newValue: boolean) {
|
|
||||||
const old = this.#intersecting;
|
|
||||||
if (old === newValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#intersecting = newValue;
|
|
||||||
if (newValue) {
|
|
||||||
this.load(true);
|
|
||||||
} else {
|
|
||||||
this.cancel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get intersecting() {
|
|
||||||
return this.#intersecting;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
|
|
||||||
return await this.loader?.execute(async (signal: AbortSignal) => {
|
|
||||||
await this.fetch(signal);
|
|
||||||
}, cancelable);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract fetch(signal: AbortSignal): Promise<void>;
|
|
||||||
|
|
||||||
get assets(): TimelineAsset[] {
|
|
||||||
return this.#assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract get viewerAssets(): ViewerAsset[];
|
|
||||||
|
|
||||||
set height(height: number) {
|
|
||||||
if (this.#height === height) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { timelineManager: store, percent } = this;
|
|
||||||
const index = store.months.indexOf(this);
|
|
||||||
const heightDelta = height - this.#height;
|
|
||||||
this.#height = height;
|
|
||||||
const prevMonthGroup = store.months[index - 1];
|
|
||||||
if (prevMonthGroup) {
|
|
||||||
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
|
||||||
if (this.#top !== newTop) {
|
|
||||||
this.#top = newTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
|
||||||
const monthGroup = this.timelineManager.months[cursor];
|
|
||||||
const newTop = monthGroup.#top + heightDelta;
|
|
||||||
if (monthGroup.#top !== newTop) {
|
|
||||||
monthGroup.#top = newTop;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (store.topIntersectingMonthGroup) {
|
|
||||||
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
if (index < currentIndex) {
|
|
||||||
store.scrollCompensation = {
|
|
||||||
heightDelta,
|
|
||||||
scrollTop: undefined,
|
|
||||||
monthGroup: this,
|
|
||||||
};
|
|
||||||
} else if (percent > 0) {
|
|
||||||
const top = this.top + height * percent;
|
|
||||||
store.scrollCompensation = {
|
|
||||||
heightDelta: undefined,
|
|
||||||
scrollTop: top,
|
|
||||||
monthGroup: this,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get height() {
|
|
||||||
return this.#height;
|
|
||||||
}
|
|
||||||
|
|
||||||
get top(): number {
|
|
||||||
return this.#top + this.timelineManager.topSectionHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected handleLoadError(error: unknown) {
|
|
||||||
const _$t = get(t);
|
|
||||||
handleError(error, _$t('errors.failed_to_load_assets'));
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.loader?.cancel();
|
|
||||||
}
|
|
||||||
|
|
||||||
layout(_noDefer: boolean) {}
|
|
||||||
|
|
||||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
|
||||||
this.intersecting = intersecting;
|
|
||||||
this.actuallyIntersecting = actuallyIntersecting;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract findAssetAbsolutePosition(assetId: string): number;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import { SearchStreamSegment } from '$lib/managers/timeline-manager/SearchStreamSegment.svelte';
|
|
||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
|
||||||
|
|
||||||
export type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & { isVisible: boolean };
|
|
||||||
|
|
||||||
export class SearchStreamManager extends PhotostreamManager {
|
|
||||||
#isSmartSearchEnabled: boolean;
|
|
||||||
|
|
||||||
#searchTerms: SearchTerms;
|
|
||||||
#months: SearchStreamSegment[] = $state([]);
|
|
||||||
|
|
||||||
constructor(searchTerms: SearchTerms, options: { isSmartSearchEnabled: boolean }) {
|
|
||||||
super();
|
|
||||||
this.#searchTerms = searchTerms;
|
|
||||||
this.#isSmartSearchEnabled = options.isSmartSearchEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.isInitialized = false;
|
|
||||||
await this.initTask.execute(async () => {
|
|
||||||
// add some months to start the searches
|
|
||||||
for (let i = 1; i < 3; i++) {
|
|
||||||
this.#months.push(new SearchStreamSegment(this, { ...this.#searchTerms, page: i }));
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
this.updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
get months(): SearchStreamSegment[] {
|
|
||||||
return this.#months;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isSmartSearchEnabled() {
|
|
||||||
return this.#isSmartSearchEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNextPage() {
|
|
||||||
debugger;
|
|
||||||
// note: pages are 1-based
|
|
||||||
this.#months.push(new SearchStreamSegment(this, { ...this.#searchTerms, page: this.#months.length + 1 }));
|
|
||||||
this.updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import type { SearchStreamManager, SearchTerms } from '$lib/managers/timeline-manager/SearchStreamManager.svelte';
|
|
||||||
import { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
|
|
||||||
import { getJustifiedLayoutFromAssets, getPosition } from '$lib/utils/layout-utils';
|
|
||||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
|
||||||
import { searchAssets, searchSmart } from '@immich/sdk';
|
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
|
|
||||||
export class SearchStreamSegment extends PhotostreamSegment {
|
|
||||||
manager: SearchStreamManager;
|
|
||||||
#identifier: SegmentIdentifier;
|
|
||||||
#id: string;
|
|
||||||
#searchTerms: SearchTerms;
|
|
||||||
|
|
||||||
#viewerAssets: ViewerAsset[] = $state([]);
|
|
||||||
|
|
||||||
constructor(manager: SearchStreamManager, searchTerms: SearchTerms) {
|
|
||||||
super();
|
|
||||||
this.manager = manager;
|
|
||||||
this.#searchTerms = searchTerms;
|
|
||||||
this.#id = JSON.stringify(searchTerms);
|
|
||||||
this.#identifier = {
|
|
||||||
matches(segment: SearchStreamSegment) {
|
|
||||||
return isEqual(segment.#searchTerms, searchTerms);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.initialCount = searchTerms.size || 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
get timelineManager(): SearchStreamManager {
|
|
||||||
return this.manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
get identifier(): SegmentIdentifier {
|
|
||||||
return this.#identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
get id(): string {
|
|
||||||
return this.#id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetch(signal: AbortSignal): Promise<void> {
|
|
||||||
const searchDto: SearchTerms = {
|
|
||||||
...this.#searchTerms,
|
|
||||||
withExif: true,
|
|
||||||
isVisible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { assets } =
|
|
||||||
('query' in searchDto || 'queryAssetId' in searchDto) && this.manager.isSmartSearchEnabled
|
|
||||||
? await searchSmart({ smartSearchDto: searchDto }, { signal })
|
|
||||||
: await searchAssets({ metadataSearchDto: searchDto }, { signal });
|
|
||||||
this.#viewerAssets = assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset)));
|
|
||||||
this.layout();
|
|
||||||
}
|
|
||||||
|
|
||||||
layout(): void {
|
|
||||||
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
|
|
||||||
const rowWidth = Math.floor(this.timelineManager.viewportWidth);
|
|
||||||
const rowHeight = rowWidth < 850 ? 100 : 235;
|
|
||||||
|
|
||||||
const geometry = getJustifiedLayoutFromAssets(timelineAssets, {
|
|
||||||
spacing: 2,
|
|
||||||
heightTolerance: 0.15,
|
|
||||||
rowHeight,
|
|
||||||
rowWidth,
|
|
||||||
});
|
|
||||||
// this.width = geometry.containerWidth;
|
|
||||||
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight;
|
|
||||||
for (let i = 0; i < this.#viewerAssets.length; i++) {
|
|
||||||
const position = getPosition(geometry, i);
|
|
||||||
this.#viewerAssets[i].position = position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewerAssets(): ViewerAsset[] {
|
|
||||||
return this.#viewerAssets;
|
|
||||||
}
|
|
||||||
|
|
||||||
findAssetAbsolutePosition(assetId: string) {
|
|
||||||
const viewerAsset = this.#viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
|
||||||
if (viewerAsset) {
|
|
||||||
if (!viewerAsset.position) {
|
|
||||||
console.warn('No position for asset');
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return this.top + viewerAsset.position.top + this.timelineManager.headerHeight;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
|
||||||
super.updateIntersection({ intersecting, actuallyIntersecting });
|
|
||||||
// if we're the last month, try to load next month
|
|
||||||
if (intersecting && this.timelineManager.months[this.timelineManager.months.length - 1] === this) {
|
|
||||||
this.timelineManager.loadNextPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ export class DayGroup {
|
|||||||
readonly monthGroup: MonthGroup;
|
readonly monthGroup: MonthGroup;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly groupTitle: string;
|
readonly groupTitle: string;
|
||||||
readonly groupTitleFull: string;
|
|
||||||
readonly day: number;
|
readonly day: number;
|
||||||
viewerAssets: ViewerAsset[] = $state([]);
|
viewerAssets: ViewerAsset[] = $state([]);
|
||||||
|
|
||||||
@@ -27,12 +26,11 @@ export class DayGroup {
|
|||||||
#col = $state(0);
|
#col = $state(0);
|
||||||
#deferredLayout = false;
|
#deferredLayout = false;
|
||||||
|
|
||||||
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string, groupTitleFull: string) {
|
constructor(monthGroup: MonthGroup, index: number, day: number, groupTitle: string) {
|
||||||
this.index = index;
|
this.index = index;
|
||||||
this.monthGroup = monthGroup;
|
this.monthGroup = monthGroup;
|
||||||
this.day = day;
|
this.day = day;
|
||||||
this.groupTitle = groupTitle;
|
this.groupTitle = groupTitle;
|
||||||
this.groupTitleFull = groupTitleFull;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get top() {
|
get top() {
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import { TUNABLES } from '$lib/utils/tunables';
|
import { TUNABLES } from '$lib/utils/tunables';
|
||||||
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||||
} = TUNABLES;
|
} = TUNABLES;
|
||||||
|
|
||||||
export function updateIntersectionMonthGroup(timelineManager: PhotostreamManager, month: PhotostreamSegment) {
|
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
|
||||||
const actuallyIntersecting = calculateSegmentIntersecting(timelineManager, month, 0, 0);
|
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
|
||||||
let preIntersecting = false;
|
let preIntersecting = false;
|
||||||
if (!actuallyIntersecting) {
|
if (!actuallyIntersecting) {
|
||||||
preIntersecting = calculateSegmentIntersecting(
|
preIntersecting = calculateMonthGroupIntersecting(
|
||||||
timelineManager,
|
timelineManager,
|
||||||
month,
|
month,
|
||||||
INTERSECTION_EXPAND_TOP,
|
INTERSECTION_EXPAND_TOP,
|
||||||
INTERSECTION_EXPAND_BOTTOM,
|
INTERSECTION_EXPAND_BOTTOM,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
month.updateIntersection({ intersecting: actuallyIntersecting || preIntersecting, actuallyIntersecting });
|
month.intersecting = actuallyIntersecting || preIntersecting;
|
||||||
|
month.actuallyIntersecting = actuallyIntersecting;
|
||||||
|
if (preIntersecting || actuallyIntersecting) {
|
||||||
|
timelineManager.clearDeferredLayout(month);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,9 +40,9 @@ export function isIntersecting(regionTop: number, regionBottom: number, windowTo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateSegmentIntersecting(
|
export function calculateMonthGroupIntersecting(
|
||||||
timelineManager: PhotostreamManager,
|
timelineManager: TimelineManager,
|
||||||
monthGroup: PhotostreamSegment,
|
monthGroup: MonthGroup,
|
||||||
expandTop: number,
|
expandTop: number,
|
||||||
expandBottom: number,
|
expandBottom: number,
|
||||||
) {
|
) {
|
||||||
@@ -54,7 +58,7 @@ export function calculateSegmentIntersecting(
|
|||||||
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
* Calculate intersection for viewer assets with additional parameters like header height and scroll compensation
|
||||||
*/
|
*/
|
||||||
export function calculateViewerAssetIntersecting(
|
export function calculateViewerAssetIntersecting(
|
||||||
timelineManager: PhotostreamManager,
|
timelineManager: TimelineManager,
|
||||||
positionTop: number,
|
positionTop: number,
|
||||||
positionHeight: number,
|
positionHeight: number,
|
||||||
expandTop: number = INTERSECTION_EXPAND_TOP,
|
expandTop: number = INTERSECTION_EXPAND_TOP,
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import type { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
|
||||||
import type { MonthGroup } from '../month-group.svelte';
|
import type { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
import type { UpdateGeometryOptions } from '../types';
|
import type { UpdateGeometryOptions } from '../types';
|
||||||
|
|
||||||
export function updateGeometry(
|
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
||||||
timelineManager: PhotostreamManager,
|
|
||||||
month: PhotostreamSegment,
|
|
||||||
options: UpdateGeometryOptions,
|
|
||||||
) {
|
|
||||||
const { invalidateHeight, noDefer = false } = options;
|
const { invalidateHeight, noDefer = false } = options;
|
||||||
if (invalidateHeight) {
|
if (invalidateHeight) {
|
||||||
month.isHeightActual = false;
|
month.isHeightActual = false;
|
||||||
@@ -23,7 +17,7 @@ export function updateGeometry(
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
month.layout(noDefer);
|
layoutMonthGroup(timelineManager, month, noDefer);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
export function layoutMonthGroup(timelineManager: TimelineManager, month: MonthGroup, noDefer: boolean = false) {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export function addAssetsToMonthGroups(
|
|||||||
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||||
|
|
||||||
if (!month) {
|
if (!month) {
|
||||||
month = new MonthGroup(timelineManager, asset.localDateTime, 1, true, options.order);
|
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
|
||||||
|
month.isLoaded = true;
|
||||||
timelineManager.months.push(month);
|
timelineManager.months.push(month);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,24 +143,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
formatGroupTitle,
|
formatGroupTitle,
|
||||||
formatGroupTitleFull,
|
|
||||||
formatMonthGroupTitle,
|
formatMonthGroupTitle,
|
||||||
fromTimelinePlainDate,
|
fromTimelinePlainDate,
|
||||||
fromTimelinePlainDateTime,
|
fromTimelinePlainDateTime,
|
||||||
fromTimelinePlainYearMonth,
|
fromTimelinePlainYearMonth,
|
||||||
getSegmentIdentifier,
|
|
||||||
getTimes,
|
getTimes,
|
||||||
setDifference,
|
setDifference,
|
||||||
type TimelineDateTime,
|
type TimelineDateTime,
|
||||||
type TimelineYearMonth,
|
type TimelineYearMonth,
|
||||||
} from '$lib/utils/timeline-util';
|
} from '$lib/utils/timeline-util';
|
||||||
|
|
||||||
import { layoutMonthGroup, updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
import { t } from 'svelte-i18n';
|
||||||
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
import { get } from 'svelte/store';
|
||||||
import { PhotostreamSegment, type SegmentIdentifier } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { DayGroup } from './day-group.svelte';
|
import { DayGroup } from './day-group.svelte';
|
||||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||||
@@ -24,49 +24,71 @@ import type { TimelineManager } from './timeline-manager.svelte';
|
|||||||
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
import type { AssetDescriptor, AssetOperation, Direction, MoveAsset, TimelineAsset } from './types';
|
||||||
import { ViewerAsset } from './viewer-asset.svelte';
|
import { ViewerAsset } from './viewer-asset.svelte';
|
||||||
|
|
||||||
export class MonthGroup extends PhotostreamSegment {
|
export class MonthGroup {
|
||||||
|
#intersecting: boolean = $state(false);
|
||||||
|
actuallyIntersecting: boolean = $state(false);
|
||||||
|
isLoaded: boolean = $state(false);
|
||||||
dayGroups: DayGroup[] = $state([]);
|
dayGroups: DayGroup[] = $state([]);
|
||||||
|
readonly timelineManager: TimelineManager;
|
||||||
|
|
||||||
|
#height: number = $state(0);
|
||||||
|
#top: number = $state(0);
|
||||||
|
|
||||||
|
#initialCount: number = 0;
|
||||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||||
#yearMonth: TimelineYearMonth;
|
percent: number = $state(0);
|
||||||
#identifier: SegmentIdentifier;
|
|
||||||
#timelineManager: TimelineManager;
|
assetsCount: number = $derived(
|
||||||
|
this.isLoaded
|
||||||
|
? this.dayGroups.reduce((accumulator, g) => accumulator + g.viewerAssets.length, 0)
|
||||||
|
: this.#initialCount,
|
||||||
|
);
|
||||||
|
loader: CancellableTask | undefined;
|
||||||
|
isHeightActual: boolean = $state(false);
|
||||||
|
|
||||||
readonly monthGroupTitle: string;
|
readonly monthGroupTitle: string;
|
||||||
|
readonly yearMonth: TimelineYearMonth;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
timelineManager: TimelineManager,
|
store: TimelineManager,
|
||||||
yearMonth: TimelineYearMonth,
|
yearMonth: TimelineYearMonth,
|
||||||
initialCount: number,
|
initialCount: number,
|
||||||
loaded: boolean,
|
|
||||||
order: AssetOrder = AssetOrder.Desc,
|
order: AssetOrder = AssetOrder.Desc,
|
||||||
) {
|
) {
|
||||||
super();
|
this.timelineManager = store;
|
||||||
this.initialCount = initialCount;
|
this.#initialCount = initialCount;
|
||||||
this.#yearMonth = yearMonth;
|
|
||||||
this.#identifier = getSegmentIdentifier(yearMonth);
|
|
||||||
this.#timelineManager = timelineManager;
|
|
||||||
this.#sortOrder = order;
|
this.#sortOrder = order;
|
||||||
|
|
||||||
|
this.yearMonth = yearMonth;
|
||||||
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
this.monthGroupTitle = formatMonthGroupTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||||
if (loaded) {
|
|
||||||
this.markLoaded();
|
this.loader = new CancellableTask(
|
||||||
|
() => {
|
||||||
|
this.isLoaded = true;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this.dayGroups = [];
|
||||||
|
this.isLoaded = false;
|
||||||
|
},
|
||||||
|
this.#handleLoadError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
set intersecting(newValue: boolean) {
|
||||||
|
const old = this.#intersecting;
|
||||||
|
if (old === newValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#intersecting = newValue;
|
||||||
|
if (newValue) {
|
||||||
|
void this.timelineManager.loadMonthGroup(this.yearMonth);
|
||||||
|
} else {
|
||||||
|
this.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get identifier() {
|
get intersecting() {
|
||||||
return this.#identifier;
|
return this.#intersecting;
|
||||||
}
|
|
||||||
|
|
||||||
get timelineManager() {
|
|
||||||
return this.#timelineManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
get yearMonth() {
|
|
||||||
return this.#yearMonth;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(signal: AbortSignal): Promise<void> {
|
|
||||||
return loadFromTimeBuckets(this.timelineManager, this, this.timelineManager.options, signal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get lastDayGroup() {
|
get lastDayGroup() {
|
||||||
@@ -77,9 +99,9 @@ export class MonthGroup extends PhotostreamSegment {
|
|||||||
return this.dayGroups[0]?.getFirstAsset();
|
return this.dayGroups[0]?.getFirstAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewerAssets() {
|
getAssets() {
|
||||||
// eslint-disable-next-line unicorn/no-array-reduce
|
// eslint-disable-next-line unicorn/no-array-reduce
|
||||||
return this.dayGroups.reduce((accumulator: ViewerAsset[], g: DayGroup) => accumulator.concat(g.viewerAssets), []);
|
return this.dayGroups.reduce((accumulator: TimelineAsset[], g: DayGroup) => accumulator.concat(g.getAssets()), []);
|
||||||
}
|
}
|
||||||
|
|
||||||
sortDayGroups() {
|
sortDayGroups() {
|
||||||
@@ -200,8 +222,7 @@ export class MonthGroup extends PhotostreamSegment {
|
|||||||
addContext.setDayGroup(dayGroup, localDateTime);
|
addContext.setDayGroup(dayGroup, localDateTime);
|
||||||
} else {
|
} else {
|
||||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||||
const groupTitleFull = formatGroupTitleFull(fromTimelinePlainDate(localDateTime));
|
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle);
|
||||||
dayGroup = new DayGroup(this, this.dayGroups.length, localDateTime.day, groupTitle, groupTitleFull);
|
|
||||||
this.dayGroups.push(dayGroup);
|
this.dayGroups.push(dayGroup);
|
||||||
addContext.setDayGroup(dayGroup, localDateTime);
|
addContext.setDayGroup(dayGroup, localDateTime);
|
||||||
addContext.newDayGroups.add(dayGroup);
|
addContext.newDayGroups.add(dayGroup);
|
||||||
@@ -221,15 +242,67 @@ export class MonthGroup extends PhotostreamSegment {
|
|||||||
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
|
return this.getRandomDayGroup()?.getRandomAsset()?.asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
get id() {
|
|
||||||
return this.viewId;
|
|
||||||
}
|
|
||||||
|
|
||||||
get viewId() {
|
get viewId() {
|
||||||
const { year, month } = this.yearMonth;
|
const { year, month } = this.yearMonth;
|
||||||
return year + '-' + month;
|
return year + '-' + month;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
set height(height: number) {
|
||||||
|
if (this.#height === height) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { timelineManager: store, percent } = this;
|
||||||
|
const index = store.months.indexOf(this);
|
||||||
|
const heightDelta = height - this.#height;
|
||||||
|
this.#height = height;
|
||||||
|
const prevMonthGroup = store.months[index - 1];
|
||||||
|
if (prevMonthGroup) {
|
||||||
|
const newTop = prevMonthGroup.#top + prevMonthGroup.#height;
|
||||||
|
if (this.#top !== newTop) {
|
||||||
|
this.#top = newTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let cursor = index + 1; cursor < store.months.length; cursor++) {
|
||||||
|
const monthGroup = this.timelineManager.months[cursor];
|
||||||
|
const newTop = monthGroup.#top + heightDelta;
|
||||||
|
if (monthGroup.#top !== newTop) {
|
||||||
|
monthGroup.#top = newTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (store.topIntersectingMonthGroup) {
|
||||||
|
const currentIndex = store.months.indexOf(store.topIntersectingMonthGroup);
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
if (index < currentIndex) {
|
||||||
|
store.scrollCompensation = {
|
||||||
|
heightDelta,
|
||||||
|
scrollTop: undefined,
|
||||||
|
monthGroup: this,
|
||||||
|
};
|
||||||
|
} else if (percent > 0) {
|
||||||
|
const top = this.top + height * percent;
|
||||||
|
store.scrollCompensation = {
|
||||||
|
heightDelta: undefined,
|
||||||
|
scrollTop: top,
|
||||||
|
monthGroup: this,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.#height;
|
||||||
|
}
|
||||||
|
|
||||||
|
get top(): number {
|
||||||
|
return this.#top + this.timelineManager.topSectionHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleLoadError(error: unknown) {
|
||||||
|
const _$t = get(t);
|
||||||
|
handleError(error, _$t('errors.failed_to_load_assets'));
|
||||||
|
}
|
||||||
|
|
||||||
findDayGroupForAsset(asset: TimelineAsset) {
|
findDayGroupForAsset(asset: TimelineAsset) {
|
||||||
for (const group of this.dayGroups) {
|
for (const group of this.dayGroups) {
|
||||||
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
if (group.viewerAssets.some((viewerAsset) => viewerAsset.id === asset.id)) {
|
||||||
@@ -243,7 +316,7 @@ export class MonthGroup extends PhotostreamSegment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findAssetAbsolutePosition(assetId: string) {
|
findAssetAbsolutePosition(assetId: string) {
|
||||||
this.#clearDeferredLayout();
|
this.timelineManager.clearDeferredLayout(this);
|
||||||
for (const group of this.dayGroups) {
|
for (const group of this.dayGroups) {
|
||||||
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
const viewerAsset = group.viewerAssets.find((viewAsset) => viewAsset.id === assetId);
|
||||||
if (viewerAsset) {
|
if (viewerAsset) {
|
||||||
@@ -301,26 +374,4 @@ export class MonthGroup extends PhotostreamSegment {
|
|||||||
cancel() {
|
cancel() {
|
||||||
this.loader?.cancel();
|
this.loader?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
layout(noDefer: boolean) {
|
|
||||||
layoutMonthGroup(this.timelineManager, this, noDefer);
|
|
||||||
}
|
|
||||||
|
|
||||||
#clearDeferredLayout() {
|
|
||||||
const hasDeferred = this.dayGroups.some((group) => group.deferredLayout);
|
|
||||||
if (hasDeferred) {
|
|
||||||
updateGeometry(this.timelineManager, this, { invalidateHeight: true, noDefer: true });
|
|
||||||
for (const group of this.dayGroups) {
|
|
||||||
group.deferredLayout = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
|
||||||
this.intersecting = intersecting;
|
|
||||||
this.actuallyIntersecting = actuallyIntersecting;
|
|
||||||
if (intersecting) {
|
|
||||||
this.#clearDeferredLayout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { fromISODateTimeUTCToObject, getSegmentIdentifier } from '$lib/utils/timeline-util';
|
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
import { TimelineManager } from './timeline-manager.svelte';
|
import { TimelineManager } from './timeline-manager.svelte';
|
||||||
@@ -92,7 +92,7 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadSegment', () => {
|
describe('loadMonthGroup', () => {
|
||||||
let timelineManager: TimelineManager;
|
let timelineManager: TimelineManager;
|
||||||
const bucketAssets: Record<string, TimelineAsset[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
'2024-01-03T00:00:00.000Z': timelineAssetFactory.buildList(1).map((asset) =>
|
||||||
@@ -128,48 +128,48 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads a month', async () => {
|
it('loads a month', async () => {
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid months', async () => {
|
it('ignores invalid months', async () => {
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2023, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2023, month: 1 });
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels month loading', async () => {
|
it('cancels month loading', async () => {
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
void timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||||
month?.cancel();
|
month?.cancel();
|
||||||
expect(abortSpy).toBeCalledTimes(1);
|
expect(abortSpy).toBeCalledTimes(1);
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(3);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents loading months multiple times', async () => {
|
it('prevents loading months multiple times', async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||||
timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 })),
|
timelineManager.loadMonthGroup({ year: 2024, month: 1 }),
|
||||||
]);
|
]);
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
|
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows loading a canceled month', async () => {
|
it('allows loading a canceled month', async () => {
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||||
const loadPromise = timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
const loadPromise = timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
|
|
||||||
month.cancel();
|
month.cancel();
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
expect(month?.assets.length).toEqual(0);
|
expect(month?.getAssets().length).toEqual(0);
|
||||||
|
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
expect(month!.assets.length).toEqual(3);
|
expect(month!.getAssets().length).toEqual(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].assets.length).toEqual(1);
|
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
expect(timelineManager.months[0].getFirstAsset().id).toEqual(asset.id);
|
||||||
@@ -215,7 +215,7 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
expect(timelineManager.assetCount).toEqual(2);
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
expect(timelineManager.months[0].assets.length).toEqual(2);
|
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||||
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
expect(timelineManager.months[0].yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
@@ -240,10 +240,10 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
expect(month).not.toBeNull();
|
expect(month).not.toBeNull();
|
||||||
expect(month?.assets.length).toEqual(3);
|
expect(month?.getAssets().length).toEqual(3);
|
||||||
expect(month?.assets[0].id).toEqual(assetOne.id);
|
expect(month?.getAssets()[0].id).toEqual(assetOne.id);
|
||||||
expect(month?.assets[1].id).toEqual(assetThree.id);
|
expect(month?.getAssets()[1].id).toEqual(assetThree.id);
|
||||||
expect(month?.assets[2].id).toEqual(assetTwo.id);
|
expect(month?.getAssets()[2].id).toEqual(assetTwo.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('orders months by descending date', () => {
|
it('orders months by descending date', () => {
|
||||||
@@ -341,14 +341,14 @@ describe('TimelineManager', () => {
|
|||||||
timelineManager.addAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(1);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
||||||
|
|
||||||
timelineManager.updateAssets([updatedAsset]);
|
timelineManager.updateAssets([updatedAsset]);
|
||||||
expect(timelineManager.months.length).toEqual(2);
|
expect(timelineManager.months.length).toEqual(2);
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.assets.length).toEqual(0);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.assets.length).toEqual(1);
|
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(2);
|
expect(timelineManager.assetCount).toEqual(2);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].assets.length).toEqual(2);
|
expect(timelineManager.months[0].getAssets().length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from month', () => {
|
it('removes asset from month', () => {
|
||||||
@@ -388,7 +388,7 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months.length).toEqual(1);
|
expect(timelineManager.months.length).toEqual(1);
|
||||||
expect(timelineManager.months[0].assets.length).toEqual(1);
|
expect(timelineManager.months[0].getAssets().length).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not remove month when empty', () => {
|
it('does not remove month when empty', () => {
|
||||||
@@ -477,45 +477,45 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId', async () => {
|
it('returns previous assetId', async () => {
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||||
|
|
||||||
const a = month!.assets[0];
|
const a = month!.getAssets()[0];
|
||||||
const b = month!.assets[1];
|
const b = month!.getAssets()[1];
|
||||||
const previous = await timelineManager.getLaterAsset(b);
|
const previous = await timelineManager.getLaterAsset(b);
|
||||||
expect(previous).toEqual(a);
|
expect(previous).toEqual(a);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns previous assetId spanning multiple months', async () => {
|
it('returns previous assetId spanning multiple months', async () => {
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||||
|
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
const a = month!.assets[0];
|
const a = month!.getAssets()[0];
|
||||||
const b = previousMonth!.assets[0];
|
const b = previousMonth!.getAssets()[0];
|
||||||
const previous = await timelineManager.getLaterAsset(a);
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
expect(previous).toEqual(b);
|
expect(previous).toEqual(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads previous month', async () => {
|
it('loads previous month', async () => {
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 2 });
|
||||||
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
const previousMonth = getMonthGroupByDate(timelineManager, { year: 2024, month: 3 });
|
||||||
const a = month!.getFirstAsset();
|
const a = month!.getFirstAsset();
|
||||||
const b = previousMonth!.getFirstAsset();
|
const b = previousMonth!.getFirstAsset();
|
||||||
const loadSegmentSpy = vi.spyOn(month!.loader!, 'execute');
|
const loadMonthGroupSpy = vi.spyOn(month!.loader!, 'execute');
|
||||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||||
const previous = await timelineManager.getLaterAsset(a);
|
const previous = await timelineManager.getLaterAsset(a);
|
||||||
expect(previous).toEqual(b);
|
expect(previous).toEqual(b);
|
||||||
expect(loadSegmentSpy).toBeCalledTimes(0);
|
expect(loadMonthGroupSpy).toBeCalledTimes(0);
|
||||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips removed assets', async () => {
|
it('skips removed assets', async () => {
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 1 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 1 });
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 2 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 2 });
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||||
|
|
||||||
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
const [assetOne, assetTwo, assetThree] = await getAssets(timelineManager);
|
||||||
timelineManager.removeAssets([assetTwo.id]);
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
@@ -523,7 +523,7 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when no more assets', async () => {
|
it('returns null when no more assets', async () => {
|
||||||
await timelineManager.loadSegment(getSegmentIdentifier({ year: 2024, month: 3 }));
|
await timelineManager.loadMonthGroup({ year: 2024, month: 3 });
|
||||||
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
expect(await timelineManager.getLaterAsset(timelineManager.months[0].getFirstAsset())).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,17 +3,14 @@ import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import {
|
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
|
||||||
getSegmentIdentifier,
|
|
||||||
toTimelineAsset,
|
|
||||||
type TimelineDateTime,
|
|
||||||
type TimelineYearMonth,
|
|
||||||
} from '$lib/utils/timeline-util';
|
|
||||||
|
|
||||||
import { isEqual } from 'lodash-es';
|
import { clamp, debounce, isEqual } from 'lodash-es';
|
||||||
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
|
||||||
|
import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
|
||||||
import {
|
import {
|
||||||
addAssetsToMonthGroups,
|
addAssetsToMonthGroups,
|
||||||
runAssetOperation,
|
runAssetOperation,
|
||||||
@@ -26,7 +23,6 @@ import {
|
|||||||
retrieveRange as retrieveRangeUtil,
|
retrieveRange as retrieveRangeUtil,
|
||||||
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
} from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
|
||||||
import { PhotostreamManager } from '$lib/managers/timeline-manager/PhotostreamManager.svelte';
|
|
||||||
import { DayGroup } from './day-group.svelte';
|
import { DayGroup } from './day-group.svelte';
|
||||||
import { isMismatched, updateObject } from './internal/utils.svelte';
|
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||||
import { MonthGroup } from './month-group.svelte';
|
import { MonthGroup } from './month-group.svelte';
|
||||||
@@ -36,14 +32,29 @@ import type {
|
|||||||
Direction,
|
Direction,
|
||||||
ScrubberMonth,
|
ScrubberMonth,
|
||||||
TimelineAsset,
|
TimelineAsset,
|
||||||
|
TimelineManagerLayoutOptions,
|
||||||
TimelineManagerOptions,
|
TimelineManagerOptions,
|
||||||
|
Viewport,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class TimelineManager extends PhotostreamManager {
|
export class TimelineManager {
|
||||||
|
isInitialized = $state(false);
|
||||||
|
months: MonthGroup[] = $state([]);
|
||||||
|
topSectionHeight = $state(0);
|
||||||
|
timelineHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0) + this.topSectionHeight);
|
||||||
|
assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
|
||||||
|
|
||||||
albumAssets: Set<string> = new SvelteSet();
|
albumAssets: Set<string> = new SvelteSet();
|
||||||
|
|
||||||
scrubberMonths: ScrubberMonth[] = $state([]);
|
scrubberMonths: ScrubberMonth[] = $state([]);
|
||||||
scrubberTimelineHeight: number = $state(0);
|
scrubberTimelineHeight: number = $state(0);
|
||||||
#months: MonthGroup[] = $state([]);
|
|
||||||
|
topIntersectingMonthGroup: MonthGroup | undefined = $state();
|
||||||
|
|
||||||
|
visibleWindow = $derived.by(() => ({
|
||||||
|
top: this.#scrollTop,
|
||||||
|
bottom: this.#scrollTop + this.viewportHeight,
|
||||||
|
}));
|
||||||
|
|
||||||
initTask = new CancellableTask(
|
initTask = new CancellableTask(
|
||||||
() => {
|
() => {
|
||||||
@@ -61,16 +72,121 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static #INIT_OPTIONS = {};
|
static #INIT_OPTIONS = {};
|
||||||
|
#viewportHeight = $state(0);
|
||||||
|
#viewportWidth = $state(0);
|
||||||
|
#scrollTop = $state(0);
|
||||||
#websocketSupport: WebsocketSupport | undefined;
|
#websocketSupport: WebsocketSupport | undefined;
|
||||||
|
|
||||||
|
#rowHeight = $state(235);
|
||||||
|
#headerHeight = $state(48);
|
||||||
|
#gap = $state(12);
|
||||||
|
|
||||||
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
#options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
|
||||||
|
|
||||||
get months() {
|
#scrolling = $state(false);
|
||||||
return this.#months;
|
#suspendTransitions = $state(false);
|
||||||
|
#resetScrolling = debounce(() => (this.#scrolling = false), 1000);
|
||||||
|
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
|
||||||
|
scrollCompensation: {
|
||||||
|
heightDelta: number | undefined;
|
||||||
|
scrollTop: number | undefined;
|
||||||
|
monthGroup: MonthGroup | undefined;
|
||||||
|
} = $state({
|
||||||
|
heightDelta: 0,
|
||||||
|
scrollTop: 0,
|
||||||
|
monthGroup: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
|
||||||
|
let changed = false;
|
||||||
|
changed ||= this.#setHeaderHeight(headerHeight);
|
||||||
|
changed ||= this.#setGap(gap);
|
||||||
|
changed ||= this.#setRowHeight(rowHeight);
|
||||||
|
if (changed) {
|
||||||
|
this.refreshLayout();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get options() {
|
#setHeaderHeight(value: number) {
|
||||||
return this.#options;
|
if (this.#headerHeight == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#headerHeight = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get headerHeight() {
|
||||||
|
return this.#headerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setGap(value: number) {
|
||||||
|
if (this.#gap == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#gap = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get gap() {
|
||||||
|
return this.#gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setRowHeight(value: number) {
|
||||||
|
if (this.#rowHeight == value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.#rowHeight = value;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
get rowHeight() {
|
||||||
|
return this.#rowHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
set scrolling(value: boolean) {
|
||||||
|
this.#scrolling = value;
|
||||||
|
if (value) {
|
||||||
|
this.suspendTransitions = true;
|
||||||
|
this.#resetScrolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get scrolling() {
|
||||||
|
return this.#scrolling;
|
||||||
|
}
|
||||||
|
|
||||||
|
set suspendTransitions(value: boolean) {
|
||||||
|
this.#suspendTransitions = value;
|
||||||
|
if (value) {
|
||||||
|
this.#resetSuspendTransitions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get suspendTransitions() {
|
||||||
|
return this.#suspendTransitions;
|
||||||
|
}
|
||||||
|
|
||||||
|
set viewportWidth(value: number) {
|
||||||
|
const changed = value !== this.#viewportWidth;
|
||||||
|
this.#viewportWidth = value;
|
||||||
|
this.suspendTransitions = true;
|
||||||
|
void this.#updateViewportGeometry(changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewportWidth() {
|
||||||
|
return this.#viewportWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
set viewportHeight(value: number) {
|
||||||
|
this.#viewportHeight = value;
|
||||||
|
this.#suspendTransitions = true;
|
||||||
|
void this.#updateViewportGeometry(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get viewportHeight() {
|
||||||
|
return this.#viewportHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
async *assetsIterator(options?: {
|
async *assetsIterator(options?: {
|
||||||
@@ -82,7 +198,7 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
const direction = options?.direction ?? 'earlier';
|
const direction = options?.direction ?? 'earlier';
|
||||||
let { startDayGroup, startAsset } = options ?? {};
|
let { startDayGroup, startAsset } = options ?? {};
|
||||||
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
|
||||||
await this.loadSegment(monthGroup.identifier, { cancelable: false });
|
await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
|
||||||
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
|
||||||
startDayGroup = startAsset = undefined;
|
startDayGroup = startAsset = undefined;
|
||||||
}
|
}
|
||||||
@@ -118,24 +234,75 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
this.#websocketSupport = undefined;
|
this.#websocketSupport = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateSlidingWindow(scrollTop: number) {
|
||||||
|
if (this.#scrollTop !== scrollTop) {
|
||||||
|
this.#scrollTop = scrollTop;
|
||||||
|
this.updateIntersections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScrollCompensation() {
|
||||||
|
this.scrollCompensation = {
|
||||||
|
heightDelta: undefined,
|
||||||
|
scrollTop: undefined,
|
||||||
|
monthGroup: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIntersections() {
|
||||||
|
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let topIntersectingMonthGroup = undefined;
|
||||||
|
for (const month of this.months) {
|
||||||
|
updateIntersectionMonthGroup(this, month);
|
||||||
|
if (!topIntersectingMonthGroup && month.actuallyIntersecting) {
|
||||||
|
topIntersectingMonthGroup = month;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (topIntersectingMonthGroup !== undefined && this.topIntersectingMonthGroup !== topIntersectingMonthGroup) {
|
||||||
|
this.topIntersectingMonthGroup = topIntersectingMonthGroup;
|
||||||
|
}
|
||||||
|
for (const month of this.months) {
|
||||||
|
if (month === this.topIntersectingMonthGroup) {
|
||||||
|
this.topIntersectingMonthGroup.percent = clamp(
|
||||||
|
(this.visibleWindow.top - this.topIntersectingMonthGroup.top) / this.topIntersectingMonthGroup.height,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
month.percent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDeferredLayout(month: MonthGroup) {
|
||||||
|
const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
|
||||||
|
if (hasDeferred) {
|
||||||
|
updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
|
||||||
|
for (const group of month.dayGroups) {
|
||||||
|
group.deferredLayout = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #initializeMonthGroups() {
|
async #initializeMonthGroups() {
|
||||||
const timebuckets = await getTimeBuckets({
|
const timebuckets = await getTimeBuckets({
|
||||||
...authManager.params,
|
...authManager.params,
|
||||||
...this.#options,
|
...this.#options,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#months = timebuckets.map((timeBucket) => {
|
this.months = timebuckets.map((timeBucket) => {
|
||||||
const date = new SvelteDate(timeBucket.timeBucket);
|
const date = new SvelteDate(timeBucket.timeBucket);
|
||||||
return new MonthGroup(
|
return new MonthGroup(
|
||||||
this,
|
this,
|
||||||
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
{ year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
|
||||||
timeBucket.count,
|
timeBucket.count,
|
||||||
false,
|
|
||||||
this.#options.order,
|
this.#options.order,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
this.albumAssets.clear();
|
this.albumAssets.clear();
|
||||||
this.updateViewportGeometry(false);
|
this.#updateViewportGeometry(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOptions(options: TimelineManagerOptions) {
|
async updateOptions(options: TimelineManagerOptions) {
|
||||||
@@ -146,17 +313,16 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.initTask.reset();
|
await this.initTask.reset();
|
||||||
this.#options = options;
|
await this.#init(options);
|
||||||
await this.init();
|
this.#updateViewportGeometry(false);
|
||||||
this.updateViewportGeometry(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async #init(options: TimelineManagerOptions) {
|
||||||
|
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
this.#months = [];
|
this.months = [];
|
||||||
this.albumAssets.clear();
|
this.albumAssets.clear();
|
||||||
await this.initTask.execute(async () => {
|
await this.initTask.execute(async () => {
|
||||||
|
this.#options = options;
|
||||||
await this.#initializeMonthGroups();
|
await this.#initializeMonthGroups();
|
||||||
}, true);
|
}, true);
|
||||||
}
|
}
|
||||||
@@ -166,8 +332,36 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateViewportGeometry(changedWidth: boolean) {
|
async updateViewport(viewport: Viewport) {
|
||||||
super.updateViewportGeometry(changedWidth);
|
if (viewport.height === 0 && viewport.width === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.initTask.executed) {
|
||||||
|
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedWidth = viewport.width !== this.viewportWidth;
|
||||||
|
this.viewportHeight = viewport.height;
|
||||||
|
this.viewportWidth = viewport.width;
|
||||||
|
this.#updateViewportGeometry(changedWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateViewportGeometry(changedWidth: boolean) {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.viewportWidth === 0 || this.viewportHeight === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const month of this.months) {
|
||||||
|
updateGeometry(this, month, { invalidateHeight: changedWidth });
|
||||||
|
}
|
||||||
|
this.updateIntersections();
|
||||||
this.#createScrubberMonths();
|
this.#createScrubberMonths();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +376,39 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
this.scrubberTimelineHeight = this.timelineHeight;
|
this.scrubberTimelineHeight = this.timelineHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createLayoutOptions() {
|
||||||
|
const viewportWidth = this.viewportWidth;
|
||||||
|
|
||||||
|
return {
|
||||||
|
spacing: 2,
|
||||||
|
heightTolerance: 0.15,
|
||||||
|
rowHeight: this.#rowHeight,
|
||||||
|
rowWidth: Math.floor(viewportWidth),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
|
||||||
|
let cancelable = true;
|
||||||
|
if (options) {
|
||||||
|
cancelable = options.cancelable;
|
||||||
|
}
|
||||||
|
const monthGroup = getMonthGroupByDate(this, yearMonth);
|
||||||
|
if (!monthGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthGroup.loader?.executed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
|
||||||
|
await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
|
||||||
|
}, cancelable);
|
||||||
|
if (result === 'LOADED') {
|
||||||
|
updateIntersectionMonthGroup(this, monthGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addAssets(assets: TimelineAsset[]) {
|
addAssets(assets: TimelineAsset[]) {
|
||||||
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||||
@@ -215,7 +442,7 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
|
||||||
await this.loadSegment(getSegmentIdentifier(yearMonth), options);
|
await this.loadMonthGroup(yearMonth, options);
|
||||||
return getMonthGroupByDate(this, yearMonth);
|
return getMonthGroupByDate(this, yearMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +454,7 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
async getRandomMonthGroup() {
|
async getRandomMonthGroup() {
|
||||||
const random = Math.floor(Math.random() * this.months.length);
|
const random = Math.floor(Math.random() * this.months.length);
|
||||||
const month = this.months[random];
|
const month = this.months[random];
|
||||||
await this.loadSegment(getSegmentIdentifier(month.yearMonth), { cancelable: false });
|
await this.loadMonthGroup(month.yearMonth, { cancelable: false });
|
||||||
return month;
|
return month;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +527,7 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
if (!monthGroup) {
|
if (!monthGroup) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.loadSegment(getSegmentIdentifier(dateTime), { cancelable: false });
|
await this.loadMonthGroup(dateTime, { cancelable: false });
|
||||||
const asset = monthGroup.findClosest(dateTime);
|
const asset = monthGroup.findClosest(dateTime);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
return asset;
|
return asset;
|
||||||
@@ -325,13 +552,4 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
getAssetOrder() {
|
getAssetOrder() {
|
||||||
return this.#options.order ?? AssetOrder.Desc;
|
return this.#options.order ?? AssetOrder.Desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
getMaxScrollPercent() {
|
|
||||||
const totalHeight = this.timelineHeight + this.bottomSectionHeight + this.topSectionHeight;
|
|
||||||
return (totalHeight - this.viewportHeight) / totalHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMaxScroll() {
|
|
||||||
return this.topSectionHeight + this.bottomSectionHeight + (this.timelineHeight - this.viewportHeight);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { PhotostreamSegment } from '$lib/managers/timeline-manager/PhotostreamSegment.svelte';
|
|
||||||
import type { CommonPosition } from '$lib/utils/layout-utils';
|
import type { CommonPosition } from '$lib/utils/layout-utils';
|
||||||
|
|
||||||
import type { DayGroup } from './day-group.svelte';
|
import type { DayGroup } from './day-group.svelte';
|
||||||
@@ -6,21 +5,16 @@ import { calculateViewerAssetIntersecting } from './internal/intersection-suppor
|
|||||||
import type { TimelineAsset } from './types';
|
import type { TimelineAsset } from './types';
|
||||||
|
|
||||||
export class ViewerAsset {
|
export class ViewerAsset {
|
||||||
readonly #group: DayGroup | PhotostreamSegment;
|
readonly #group: DayGroup;
|
||||||
|
|
||||||
intersecting = $derived.by(() => {
|
intersecting = $derived.by(() => {
|
||||||
if (!this.position) {
|
if (!this.position) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ((this.#group as DayGroup).sortAssets) {
|
|
||||||
const dayGroup = this.#group as DayGroup;
|
|
||||||
const store = dayGroup.monthGroup.timelineManager;
|
|
||||||
const positionTop = dayGroup.absoluteDayGroupTop + this.position.top;
|
|
||||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = (this.#group as PhotostreamSegment).timelineManager;
|
const store = this.#group.monthGroup.timelineManager;
|
||||||
const positionTop = this.position.top + (this.#group as PhotostreamSegment).top;
|
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||||
|
|
||||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -28,7 +22,7 @@ export class ViewerAsset {
|
|||||||
asset: TimelineAsset = <TimelineAsset>$state();
|
asset: TimelineAsset = <TimelineAsset>$state();
|
||||||
id: string = $derived(this.asset.id);
|
id: string = $derived(this.asset.id);
|
||||||
|
|
||||||
constructor(group: DayGroup | PhotostreamSegment, asset: TimelineAsset) {
|
constructor(group: DayGroup, asset: TimelineAsset) {
|
||||||
this.#group = group;
|
this.#group = group;
|
||||||
this.asset = asset;
|
this.asset = asset;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ export const selectAllAssets = async (timelineManager: TimelineManager, assetInt
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const monthGroup of timelineManager.months) {
|
for (const monthGroup of timelineManager.months) {
|
||||||
await timelineManager.loadSegment(monthGroup.identifier);
|
await timelineManager.loadMonthGroup(monthGroup.yearMonth);
|
||||||
|
|
||||||
if (!get(isSelectingAllAssets)) {
|
if (!get(isSelectingAllAssets)) {
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
|
|||||||
@@ -23,18 +23,11 @@ export type TimelineDateTime = TimelineDate & {
|
|||||||
millisecond: number;
|
millisecond: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScrubberListener = (scrubberData: {
|
export type ScrubberListener = (
|
||||||
scrubberMonth: { year: number; month: number };
|
scrubberMonth: { year: number; month: number },
|
||||||
overallScrollPercent: number;
|
overallScrollPercent: number,
|
||||||
scrubberMonthScrollPercent: number;
|
scrubberMonthScrollPercent: number,
|
||||||
scrollToFunction?: (top: number) => void;
|
) => void | Promise<void>;
|
||||||
}) => void | Promise<void>;
|
|
||||||
|
|
||||||
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
|
|
||||||
matches(segment: any) {
|
|
||||||
return segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||||
@@ -158,14 +151,6 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||||||
return getDateLocaleString(date, { locale: get(locale) });
|
return getDateLocaleString(date, { locale: get(locale) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatGroupTitleFull = (_date: DateTime): string => {
|
|
||||||
if (!_date.isValid) {
|
|
||||||
return _date.toString();
|
|
||||||
}
|
|
||||||
const date = _date as DateTime<true>;
|
|
||||||
return getDateLocaleString(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
||||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||||
|
|
||||||
@@ -249,79 +234,3 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonthGroupForSearch {
|
|
||||||
yearMonth: TimelineYearMonth;
|
|
||||||
top: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BinarySearchResult {
|
|
||||||
month: TimelineYearMonth;
|
|
||||||
monthScrollPercent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findMonthAtScrollPosition(
|
|
||||||
months: MonthGroupForSearch[],
|
|
||||||
scrollPosition: number,
|
|
||||||
maxScrollPercent: number,
|
|
||||||
): BinarySearchResult | null {
|
|
||||||
const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks
|
|
||||||
const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month
|
|
||||||
|
|
||||||
if (months.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're before the first month
|
|
||||||
const firstMonthTop = months[0].top * maxScrollPercent;
|
|
||||||
if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're after the last month
|
|
||||||
const lastMonth = months.at(-1)!;
|
|
||||||
const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent;
|
|
||||||
if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary search to find the month containing the scroll position
|
|
||||||
let left = 0;
|
|
||||||
let right = months.length - 1;
|
|
||||||
|
|
||||||
while (left <= right) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
const month = months[mid];
|
|
||||||
const monthTop = month.top * maxScrollPercent;
|
|
||||||
const monthBottom = monthTop + month.height * maxScrollPercent;
|
|
||||||
|
|
||||||
if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) {
|
|
||||||
// Found the month containing the scroll position
|
|
||||||
const distanceIntoMonth = scrollPosition - monthTop;
|
|
||||||
const monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent));
|
|
||||||
|
|
||||||
// Handle month boundary edge case
|
|
||||||
if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) {
|
|
||||||
return {
|
|
||||||
month: months[mid + 1].yearMonth,
|
|
||||||
monthScrollPercent: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
month: month.yearMonth,
|
|
||||||
monthScrollPercent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollPosition < monthTop) {
|
|
||||||
right = mid - 1;
|
|
||||||
} else {
|
|
||||||
left = mid + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shouldn't reach here, but return null if we do
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,7 +96,6 @@
|
|||||||
>
|
>
|
||||||
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
|
<div class="flex justify-center overflow-hidden rounded-xl brightness-75 filter">
|
||||||
<img
|
<img
|
||||||
style:filter="blur(10px)"
|
|
||||||
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
|
src={getAssetThumbnailUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
|
||||||
alt={item.value}
|
alt={item.value}
|
||||||
class="object-cover aspect-square w-full"
|
class="object-cover aspect-square w-full"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||||
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
|
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
|
||||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||||
@@ -19,7 +20,6 @@
|
|||||||
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
||||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import Gallery from '$lib/components/timeline/Gallery.svelte';
|
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
@@ -31,16 +31,20 @@
|
|||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
import { parseUtcDate } from '$lib/utils/date-time';
|
import { parseUtcDate } from '$lib/utils/date-time';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
||||||
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
import {
|
import {
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
getPerson,
|
getPerson,
|
||||||
getTagById,
|
getTagById,
|
||||||
type MetadataSearchDto,
|
type MetadataSearchDto,
|
||||||
|
searchAssets,
|
||||||
|
searchSmart,
|
||||||
type SmartSearchDto,
|
type SmartSearchDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { IconButton } from '@immich/ui';
|
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
|
||||||
import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
@@ -135,51 +139,43 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function onSearchQueryUpdate() {
|
async function onSearchQueryUpdate() {
|
||||||
debugger;
|
|
||||||
nextPage = 1;
|
nextPage = 1;
|
||||||
searchResultAssets = [];
|
searchResultAssets = [];
|
||||||
searchResultAlbums = [];
|
searchResultAlbums = [];
|
||||||
// await loadNextPage(true);
|
await loadNextPage(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchTerms = $derived.by(() => ({
|
// eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
|
||||||
|
export const loadNextPage = async (force?: boolean) => {
|
||||||
|
if (!nextPage || (isLoading && !force)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
|
const searchDto: SearchTerms = {
|
||||||
|
page: nextPage,
|
||||||
withExif: true,
|
withExif: true,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
language: $lang,
|
language: $lang,
|
||||||
...terms,
|
...terms,
|
||||||
}));
|
};
|
||||||
|
|
||||||
// // eslint-disable-next-line svelte/valid-prop-names-in-kit-pages
|
try {
|
||||||
// export const loadNextPage = async (force?: boolean) => {
|
const { albums, assets } =
|
||||||
// if (!nextPage || (isLoading && !force)) {
|
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
|
||||||
// return;
|
? await searchSmart({ smartSearchDto: searchDto })
|
||||||
// }
|
: await searchAssets({ metadataSearchDto: searchDto });
|
||||||
// isLoading = true;
|
|
||||||
|
|
||||||
// const searchDto: SearchTerms = {
|
searchResultAlbums.push(...albums.items);
|
||||||
// page: nextPage,
|
searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
|
||||||
// withExif: true,
|
|
||||||
// isVisible: true,
|
|
||||||
// language: $lang,
|
|
||||||
// ...terms,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// try {
|
nextPage = Number(assets.nextPage) || 0;
|
||||||
// const { albums, assets } =
|
} catch (error) {
|
||||||
// ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
|
handleError(error, $t('loading_search_results_failed'));
|
||||||
// ? await searchSmart({ smartSearchDto: searchDto })
|
} finally {
|
||||||
// : await searchAssets({ metadataSearchDto: searchDto });
|
isLoading = false;
|
||||||
|
}
|
||||||
// searchResultAlbums.push(...albums.items);
|
};
|
||||||
// searchResultAssets.push(...assets.items.map((asset) => toTimelineAsset(asset)));
|
|
||||||
|
|
||||||
// nextPage = Number(assets.nextPage) || 0;
|
|
||||||
// } catch (error) {
|
|
||||||
// handleError(error, $t('loading_search_results_failed'));
|
|
||||||
// } finally {
|
|
||||||
// isLoading = false;
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
function getHumanReadableDate(dateString: string) {
|
function getHumanReadableDate(dateString: string) {
|
||||||
const date = parseUtcDate(dateString).startOf('day');
|
const date = parseUtcDate(dateString).startOf('day');
|
||||||
@@ -363,7 +359,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="mb-0 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
|
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
|
||||||
bind:clientHeight={viewport.height}
|
bind:clientHeight={viewport.height}
|
||||||
bind:clientWidth={viewport.width}
|
bind:clientWidth={viewport.width}
|
||||||
bind:this={searchResultsElement}
|
bind:this={searchResultsElement}
|
||||||
@@ -378,12 +374,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
<section id="search-content" class="h-[calc(100dvh-144px)]">
|
<section id="search-content">
|
||||||
{#key searchTerms}
|
{#if searchResultAssets.length > 0}
|
||||||
<Gallery {searchTerms}></Gallery>
|
|
||||||
{/key}
|
|
||||||
<!-- {#if searchResultAssets.length > 0}
|
|
||||||
<Gallery {searchTerms}></Gallery>
|
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
assets={searchResultAssets}
|
assets={searchResultAssets}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
@@ -407,7 +399,7 @@
|
|||||||
<div class="flex justify-center py-16 items-center">
|
<div class="flex justify-center py-16 items-center">
|
||||||
<LoadingSpinner size="giant" />
|
<LoadingSpinner size="giant" />
|
||||||
</div>
|
</div>
|
||||||
{/if} -->
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -110,7 +110,17 @@
|
|||||||
return !!asset.latitude && !!asset.longitude;
|
return !!asset.latitude && !!asset.longitude;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssetOpen = (dayGroup: DayGroup, asset: TimelineAsset, defaultAssetOpen: () => void) => {
|
const handleThumbnailClick = (
|
||||||
|
asset: TimelineAsset,
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
dayGroup: DayGroup,
|
||||||
|
onClick: (
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
assets: TimelineAsset[],
|
||||||
|
groupTitle: string,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
) => void,
|
||||||
|
) => {
|
||||||
if (hasGps(asset)) {
|
if (hasGps(asset)) {
|
||||||
locationUpdated = true;
|
locationUpdated = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -119,7 +129,7 @@
|
|||||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||||
void setQueryValue('at', asset.id);
|
void setQueryValue('at', asset.id);
|
||||||
} else {
|
} else {
|
||||||
defaultAssetOpen();
|
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -183,9 +193,9 @@
|
|||||||
removeAction={AssetAction.ARCHIVE}
|
removeAction={AssetAction.ARCHIVE}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
withStacked
|
withStacked
|
||||||
onAssetOpen={handleAssetOpen}
|
onThumbnailClick={handleThumbnailClick}
|
||||||
>
|
>
|
||||||
{#snippet customThumbnailLayout(asset: TimelineAsset)}
|
{#snippet customLayout(asset: TimelineAsset)}
|
||||||
{#if hasGps(asset)}
|
{#if hasGps(asset)}
|
||||||
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
|
||||||
{asset.city || $t('gps')}
|
{asset.city || $t('gps')}
|
||||||
|
|||||||
Reference in New Issue
Block a user