mirror of
https://github.com/immich-app/immich.git
synced 2025-12-16 17:23:16 +03:00
Compare commits
4 Commits
feat/timel
...
fix/async-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f632e4f666 | ||
|
|
0e8492ceba | ||
|
|
13abe14142 | ||
|
|
ae595f2947 |
@@ -3,7 +3,6 @@ package app.alextran.immich
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
|
||||||
|
|
||||||
class ImmichApp : Application() {
|
class ImmichApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -17,6 +16,5 @@ class ImmichApp : Application() {
|
|||||||
// As a workaround, we also run a backup check when initializing the application
|
// As a workaround, we also run a backup check when initializing the application
|
||||||
|
|
||||||
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
ContentObserverWorker.startBackupWorker(context = this, delayMilliseconds = 0)
|
||||||
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
fun <T> dispatch(
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
callback: (Result<T>) -> Unit,
|
||||||
|
block: () -> T
|
||||||
|
) {
|
||||||
|
CoroutineScope(dispatcher).launch {
|
||||||
|
callback(runCatching { block() })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,49 +2,50 @@ package app.alextran.immich.background
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
private const val TAG = "BackgroundEngineLock"
|
private const val TAG = "BackgroundEngineLock"
|
||||||
|
|
||||||
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
|
class BackgroundEngineLock(context: Context) : BackgroundWorkerLockApi, FlutterPlugin {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private var engineCount = AtomicInteger(0)
|
private var engineCount = AtomicInteger(0)
|
||||||
|
|
||||||
private fun checkAndEnforceBackgroundLock(ctx: Context) {
|
private fun checkAndEnforceBackgroundLock(ctx: Context) {
|
||||||
// work manager task is running while the main app is opened, cancel the worker
|
// work manager task is running while the main app is opened, cancel the worker
|
||||||
if (BackgroundWorkerPreferences(ctx).isLocked() &&
|
if (BackgroundWorkerPreferences(ctx).isLocked() &&
|
||||||
engineCount.get() > 1 &&
|
engineCount.get() > 1 &&
|
||||||
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
|
BackgroundWorkerApiImpl.isBackgroundWorkerRunning()
|
||||||
) {
|
) {
|
||||||
Log.i(TAG, "Background worker is locked, cancelling the background worker")
|
Log.i(TAG, "Background worker is locked, cancelling the background worker")
|
||||||
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
|
BackgroundWorkerApiImpl.cancelBackgroundWorker(ctx)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun lock() {
|
override fun lock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
|
||||||
BackgroundWorkerPreferences(ctx).setLocked(true)
|
BackgroundWorkerPreferences(ctx).setLocked(true)
|
||||||
checkAndEnforceBackgroundLock(ctx)
|
checkAndEnforceBackgroundLock(ctx)
|
||||||
Log.i(TAG, "Background worker is locked")
|
Log.i(TAG, "Background worker is locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unlock() {
|
override fun unlock(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
|
||||||
BackgroundWorkerPreferences(ctx).setLocked(false)
|
BackgroundWorkerPreferences(ctx).setLocked(false)
|
||||||
Log.i(TAG, "Background worker is unlocked")
|
Log.i(TAG, "Background worker is unlocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
checkAndEnforceBackgroundLock(binding.applicationContext)
|
checkAndEnforceBackgroundLock(binding.applicationContext)
|
||||||
engineCount.incrementAndGet()
|
engineCount.incrementAndGet()
|
||||||
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
Log.i(TAG, "Flutter engine attached. Attached Engines count: $engineCount")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
engineCount.decrementAndGet()
|
engineCount.decrementAndGet()
|
||||||
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
Log.i(TAG, "Flutter engine detached. Attached Engines count: $engineCount")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,11 +133,12 @@ private open class BackgroundWorkerPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerFgHostApi {
|
interface BackgroundWorkerFgHostApi {
|
||||||
fun enable()
|
fun enable(callback: (Result<Unit>) -> Unit)
|
||||||
fun configure(settings: BackgroundWorkerSettings)
|
fun configure(settings: BackgroundWorkerSettings, callback: (Result<Unit>) -> Unit)
|
||||||
fun disable()
|
fun disable(callback: (Result<Unit>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by BackgroundWorkerFgHostApi. */
|
/** The codec used by BackgroundWorkerFgHostApi. */
|
||||||
@@ -152,13 +153,14 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.enable{ result: Result<Unit> ->
|
||||||
api.enable()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -170,13 +172,14 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val settingsArg = args[0] as BackgroundWorkerSettings
|
val settingsArg = args[0] as BackgroundWorkerSettings
|
||||||
val wrapped: List<Any?> = try {
|
api.configure(settingsArg) { result: Result<Unit> ->
|
||||||
api.configure(settingsArg)
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -186,13 +189,14 @@ interface BackgroundWorkerFgHostApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.disable{ result: Result<Unit> ->
|
||||||
api.disable()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
|
|||||||
if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) {
|
if (foregroundFuture != null && !foregroundFuture.isCancelled && !foregroundFuture.isDone) {
|
||||||
try {
|
try {
|
||||||
foregroundFuture.get(500, TimeUnit.MILLISECONDS)
|
foregroundFuture.get(500, TimeUnit.MILLISECONDS)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
// ignored, there is nothing to be done
|
// ignored, there is nothing to be done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.work.Constraints
|
|||||||
import androidx.work.ExistingWorkPolicy
|
import androidx.work.ExistingWorkPolicy
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import io.flutter.embedding.engine.FlutterEngineCache
|
import io.flutter.embedding.engine.FlutterEngineCache
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -16,16 +17,18 @@ private const val TAG = "BackgroundWorkerApiImpl"
|
|||||||
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
|
|
||||||
override fun enable() {
|
override fun enable(callback: (Result<Unit>) -> Unit) =
|
||||||
enqueueMediaObserver(ctx)
|
dispatch(callback = callback) { enqueueMediaObserver(ctx) }
|
||||||
}
|
|
||||||
|
|
||||||
override fun configure(settings: BackgroundWorkerSettings) {
|
override fun configure(
|
||||||
|
settings: BackgroundWorkerSettings,
|
||||||
|
callback: (Result<Unit>) -> Unit
|
||||||
|
) = dispatch(callback = callback) {
|
||||||
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
BackgroundWorkerPreferences(ctx).updateSettings(settings)
|
||||||
enqueueMediaObserver(ctx)
|
enqueueMediaObserver(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disable() {
|
override fun disable(callback: (Result<Unit>) -> Unit) = dispatch(callback = callback) {
|
||||||
WorkManager.getInstance(ctx).apply {
|
WorkManager.getInstance(ctx).apply {
|
||||||
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
cancelUniqueWork(OBSERVER_WORKER_NAME)
|
||||||
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
cancelUniqueWork(BACKGROUND_WORKER_NAME)
|
||||||
@@ -38,7 +41,6 @@ class BackgroundWorkerApiImpl(context: Context) : BackgroundWorkerFgHostApi {
|
|||||||
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
private const val OBSERVER_WORKER_NAME = "immich/MediaObserverV1"
|
||||||
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
const val ENGINE_CACHE_KEY = "immich::background_worker::engine"
|
||||||
|
|
||||||
|
|
||||||
fun enqueueMediaObserver(ctx: Context) {
|
fun enqueueMediaObserver(ctx: Context) {
|
||||||
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
val settings = BackgroundWorkerPreferences(ctx).getSettings()
|
||||||
val constraints = Constraints.Builder().apply {
|
val constraints = Constraints.Builder().apply {
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ private open class BackgroundWorkerLockPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface BackgroundWorkerLockApi {
|
interface BackgroundWorkerLockApi {
|
||||||
fun lock()
|
fun lock(callback: (Result<Unit>) -> Unit)
|
||||||
fun unlock()
|
fun unlock(callback: (Result<Unit>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by BackgroundWorkerLockApi. */
|
/** The codec used by BackgroundWorkerLockApi. */
|
||||||
@@ -62,13 +63,14 @@ interface BackgroundWorkerLockApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.lock$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.lock{ result: Result<Unit> ->
|
||||||
api.lock()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -78,13 +80,14 @@ interface BackgroundWorkerLockApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.BackgroundWorkerLockApi.unlock$separatedMessageChannelSuffix", codec)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.unlock{ result: Result<Unit> ->
|
||||||
api.unlock()
|
val error = result.exceptionOrNull()
|
||||||
listOf(null)
|
if (error != null) {
|
||||||
} catch (exception: Throwable) {
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapError(error))
|
||||||
BackgroundWorkerLockPigeonUtils.wrapError(exception)
|
} else {
|
||||||
|
reply.reply(BackgroundWorkerLockPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ private open class ConnectivityPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface ConnectivityApi {
|
interface ConnectivityApi {
|
||||||
fun getCapabilities(): List<NetworkCapability>
|
fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by ConnectivityApi. */
|
/** The codec used by ConnectivityApi. */
|
||||||
@@ -100,12 +101,15 @@ interface ConnectivityApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.getCapabilities{ result: Result<List<NetworkCapability>> ->
|
||||||
listOf(api.getCapabilities())
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
ConnectivityPigeonUtils.wrapError(exception)
|
reply.reply(ConnectivityPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(ConnectivityPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.wifi.WifiManager
|
import android.net.wifi.WifiManager
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
|
|
||||||
class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
||||||
private val connectivityManager =
|
private val connectivityManager =
|
||||||
@@ -11,7 +12,13 @@ class ConnectivityApiImpl(context: Context) : ConnectivityApi {
|
|||||||
private val wifiManager =
|
private val wifiManager =
|
||||||
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||||
|
|
||||||
override fun getCapabilities(): List<NetworkCapability> {
|
|
||||||
|
override fun getCapabilities(callback: (Result<List<NetworkCapability>>) -> Unit) =
|
||||||
|
dispatch(callback = callback) {
|
||||||
|
getCapabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCapabilities(): List<NetworkCapability> {
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||||
?: return emptyList()
|
?: return emptyList()
|
||||||
|
|
||||||
|
|||||||
@@ -296,13 +296,13 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface NativeSyncApi {
|
interface NativeSyncApi {
|
||||||
fun shouldFullSync(): Boolean
|
fun shouldFullSync(): Boolean
|
||||||
fun getMediaChanges(): SyncDelta
|
fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit)
|
||||||
fun checkpointSync()
|
fun checkpointSync()
|
||||||
fun clearSyncCheckpoint()
|
fun clearSyncCheckpoint()
|
||||||
fun getAssetIdsForAlbum(albumId: String): List<String>
|
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit)
|
||||||
fun getAlbums(): List<PlatformAlbum>
|
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit)
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long
|
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit)
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset>
|
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?, callback: (Result<List<PlatformAsset>>) -> Unit)
|
||||||
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
|
||||||
fun cancelHashing()
|
fun cancelHashing()
|
||||||
|
|
||||||
@@ -335,12 +335,15 @@ interface NativeSyncApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.getMediaChanges{ result: Result<SyncDelta> ->
|
||||||
listOf(api.getMediaChanges())
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -384,12 +387,15 @@ interface NativeSyncApi {
|
|||||||
channel.setMessageHandler { message, reply ->
|
channel.setMessageHandler { message, reply ->
|
||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val albumIdArg = args[0] as String
|
val albumIdArg = args[0] as String
|
||||||
val wrapped: List<Any?> = try {
|
api.getAssetIdsForAlbum(albumIdArg) { result: Result<List<String>> ->
|
||||||
listOf(api.getAssetIdsForAlbum(albumIdArg))
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -399,12 +405,15 @@ interface NativeSyncApi {
|
|||||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums$separatedMessageChannelSuffix", codec, taskQueue)
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
channel.setMessageHandler { _, reply ->
|
channel.setMessageHandler { _, reply ->
|
||||||
val wrapped: List<Any?> = try {
|
api.getAlbums{ result: Result<List<PlatformAlbum>> ->
|
||||||
listOf(api.getAlbums())
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -417,12 +426,15 @@ interface NativeSyncApi {
|
|||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val albumIdArg = args[0] as String
|
val albumIdArg = args[0] as String
|
||||||
val timestampArg = args[1] as Long
|
val timestampArg = args[1] as Long
|
||||||
val wrapped: List<Any?> = try {
|
api.getAssetsCountSince(albumIdArg, timestampArg) { result: Result<Long> ->
|
||||||
listOf(api.getAssetsCountSince(albumIdArg, timestampArg))
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
@@ -435,12 +447,15 @@ interface NativeSyncApi {
|
|||||||
val args = message as List<Any?>
|
val args = message as List<Any?>
|
||||||
val albumIdArg = args[0] as String
|
val albumIdArg = args[0] as String
|
||||||
val updatedTimeCondArg = args[1] as Long?
|
val updatedTimeCondArg = args[1] as Long?
|
||||||
val wrapped: List<Any?> = try {
|
api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg) { result: Result<List<PlatformAsset>> ->
|
||||||
listOf(api.getAssetsForAlbum(albumIdArg, updatedTimeCondArg))
|
val error = result.exceptionOrNull()
|
||||||
} catch (exception: Throwable) {
|
if (error != null) {
|
||||||
MessagesPigeonUtils.wrapError(exception)
|
reply.reply(MessagesPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
val data = result.getOrNull()
|
||||||
|
reply.reply(MessagesPigeonUtils.wrapResult(data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
reply.reply(wrapped)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class NativeSyncApiImpl26(context: Context) : NativeSyncApiImplBase(context), Na
|
|||||||
// No-op for Android 10 and below
|
// No-op for Android 10 and below
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getMediaChanges(): SyncDelta {
|
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
|
||||||
throw IllegalStateException("Method not supported on this Android version.")
|
callback(Result.failure(IllegalStateException("Method not supported on this Android version.")))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.os.Build
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.annotation.RequiresExtension
|
import androidx.annotation.RequiresExtension
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
@@ -47,7 +48,12 @@ class NativeSyncApiImpl30(context: Context) : NativeSyncApiImplBase(context), Na
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getMediaChanges(): SyncDelta {
|
override fun getMediaChanges(callback: (Result<SyncDelta>) -> Unit) =
|
||||||
|
dispatch(callback = callback) {
|
||||||
|
getMediaChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMediaChanges(): SyncDelta {
|
||||||
val genMap = getSavedGenerationMap()
|
val genMap = getSavedGenerationMap()
|
||||||
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
|
val currentVolumes = MediaStore.getExternalVolumeNames(ctx)
|
||||||
val changed = mutableListOf<PlatformAsset>()
|
val changed = mutableListOf<PlatformAsset>()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.database.Cursor
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
|
import app.alextran.immich.dispatch
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -145,7 +146,10 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAlbums(): List<PlatformAlbum> {
|
fun getAlbums(callback: (Result<List<PlatformAlbum>>) -> Unit) =
|
||||||
|
dispatch(callback = callback) { getAlbums() }
|
||||||
|
|
||||||
|
private fun getAlbums(): List<PlatformAlbum> {
|
||||||
val albums = mutableListOf<PlatformAlbum>()
|
val albums = mutableListOf<PlatformAlbum>()
|
||||||
val albumsCount = mutableMapOf<String, Int>()
|
val albumsCount = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
@@ -192,7 +196,10 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
.sortedBy { it.id }
|
.sortedBy { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAssetIdsForAlbum(albumId: String): List<String> {
|
fun getAssetIdsForAlbum(albumId: String, callback: (Result<List<String>>) -> Unit) =
|
||||||
|
dispatch(callback = callback) { getAssetIdsForAlbum(albumId); }
|
||||||
|
|
||||||
|
private fun getAssetIdsForAlbum(albumId: String): List<String> {
|
||||||
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
val projection = arrayOf(MediaStore.MediaColumns._ID)
|
||||||
|
|
||||||
return getCursor(
|
return getCursor(
|
||||||
@@ -208,15 +215,23 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
|
fun getAssetsCountSince(albumId: String, timestamp: Long, callback: (Result<Long>) -> Unit) =
|
||||||
|
dispatch(callback = callback) { getAssetsCountSince(albumId, timestamp) }
|
||||||
|
|
||||||
|
private fun getAssetsCountSince(albumId: String, timestamp: Long): Long =
|
||||||
getCursor(
|
getCursor(
|
||||||
MediaStore.VOLUME_EXTERNAL,
|
MediaStore.VOLUME_EXTERNAL,
|
||||||
"$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
|
"$BUCKET_SELECTION AND ${MediaStore.Files.FileColumns.DATE_ADDED} > ? AND $MEDIA_SELECTION",
|
||||||
arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
|
arrayOf(albumId, timestamp.toString(), *MEDIA_SELECTION_ARGS),
|
||||||
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
)?.use { cursor -> cursor.count.toLong() } ?: 0L
|
||||||
|
|
||||||
|
fun getAssetsForAlbum(
|
||||||
|
albumId: String,
|
||||||
|
updatedTimeCond: Long?,
|
||||||
|
callback: (Result<List<PlatformAsset>>) -> Unit
|
||||||
|
) = dispatch(callback = callback) { getAssetsForAlbum(albumId, updatedTimeCond) }
|
||||||
|
|
||||||
fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
|
private fun getAssetsForAlbum(albumId: String, updatedTimeCond: Long?): List<PlatformAsset> {
|
||||||
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
|
var selection = "$BUCKET_SELECTION AND $MEDIA_SELECTION"
|
||||||
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
|
val selectionArgs = mutableListOf(albumId, *MEDIA_SELECTION_ARGS)
|
||||||
|
|
||||||
@@ -254,7 +269,7 @@ open class NativeSyncApiImplBase(context: Context) {
|
|||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
|
|
||||||
callback(Result.success(results))
|
callback(Result.success(results))
|
||||||
} catch (e: CancellationException) {
|
} catch (_: CancellationException) {
|
||||||
callback(
|
callback(
|
||||||
Result.failure(
|
Result.failure(
|
||||||
FlutterError(
|
FlutterError(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 77;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -133,11 +133,14 @@
|
|||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = Sync;
|
path = Sync;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
B2D27ABE2E84A0FF004DD55B /* Core */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Core;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
F0B57D3D2DF764BD00DC5BCC /* WidgetExtension */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
@@ -247,6 +250,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
B2D27ABE2E84A0FF004DD55B /* Core */,
|
||||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||||
B21E34A62E5AF9760031FDB9 /* Background */,
|
B21E34A62E5AF9760031FDB9 /* Background */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
@@ -332,6 +336,7 @@
|
|||||||
);
|
);
|
||||||
fileSystemSynchronizedGroups = (
|
fileSystemSynchronizedGroups = (
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
|
B2D27ABE2E84A0FF004DD55B /* Core */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
productName = Runner;
|
productName = Runner;
|
||||||
@@ -521,10 +526,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
@@ -553,10 +562,14 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
|||||||
@@ -179,11 +179,12 @@ class BackgroundWorkerPigeonCodec: FlutterStandardMessageCodec, @unchecked Senda
|
|||||||
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
|
static let shared = BackgroundWorkerPigeonCodec(readerWriter: BackgroundWorkerPigeonCodecReaderWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol BackgroundWorkerFgHostApi {
|
protocol BackgroundWorkerFgHostApi {
|
||||||
func enable() throws
|
func enable(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func configure(settings: BackgroundWorkerSettings) throws
|
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func disable() throws
|
func disable(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -195,11 +196,13 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let enableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.enable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
enableChannel.setMessageHandler { _, reply in
|
enableChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.enable { result in
|
||||||
try api.enable()
|
switch result {
|
||||||
reply(wrapResult(nil))
|
case .success:
|
||||||
} catch {
|
reply(wrapResult(nil))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -210,11 +213,13 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
configureChannel.setMessageHandler { message, reply in
|
configureChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let settingsArg = args[0] as! BackgroundWorkerSettings
|
let settingsArg = args[0] as! BackgroundWorkerSettings
|
||||||
do {
|
api.configure(settings: settingsArg) { result in
|
||||||
try api.configure(settings: settingsArg)
|
switch result {
|
||||||
reply(wrapResult(nil))
|
case .success:
|
||||||
} catch {
|
reply(wrapResult(nil))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -223,11 +228,13 @@ class BackgroundWorkerFgHostApiSetup {
|
|||||||
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let disableChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.BackgroundWorkerFgHostApi.disable\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
disableChannel.setMessageHandler { _, reply in
|
disableChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.disable { result in
|
||||||
try api.disable()
|
switch result {
|
||||||
reply(wrapResult(nil))
|
case .success:
|
||||||
} catch {
|
reply(wrapResult(nil))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
|
|
||||||
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
||||||
|
func enable(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
func enable() throws {
|
dispatch(completion: completion) {
|
||||||
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
BackgroundWorkerApiImpl.scheduleRefreshWorker()
|
||||||
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
BackgroundWorkerApiImpl.scheduleProcessingWorker()
|
||||||
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
print("BackgroundWorkerApiImpl:enable Background worker scheduled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func configure(settings: BackgroundWorkerSettings) throws {
|
func configure(settings: BackgroundWorkerSettings, completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
// Android only
|
// Android only
|
||||||
|
completion(Result.success(Void()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func disable() throws {
|
func disable(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
dispatch(completion: completion) {
|
||||||
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.refreshTaskID);
|
||||||
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: BackgroundWorkerApiImpl.processingTaskID);
|
||||||
|
print("BackgroundWorkerApiImpl:disableUploadWorker Disabled background workers")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
private static let refreshTaskID = "app.alextran.immich.background.refreshUpload"
|
||||||
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
private static let processingTaskID = "app.alextran.immich.background.processingUpload"
|
||||||
private static let taskSemaphore = DispatchSemaphore(value: 1)
|
private static let taskSemaphore = DispatchSemaphore(value: 1)
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ class ConnectivityPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
|||||||
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
|
static let shared = ConnectivityPigeonCodec(readerWriter: ConnectivityPigeonCodecReaderWriter())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol ConnectivityApi {
|
protocol ConnectivityApi {
|
||||||
func getCapabilities() throws -> [NetworkCapability]
|
func getCapabilities(completion: @escaping (Result<[NetworkCapability], Error>) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -115,11 +116,13 @@ class ConnectivityApiSetup {
|
|||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ConnectivityApi.getCapabilities\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
getCapabilitiesChannel.setMessageHandler { _, reply in
|
getCapabilitiesChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.getCapabilities { result in
|
||||||
let result = try api.getCapabilities()
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
class ConnectivityApiImpl: ConnectivityApi {
|
class ConnectivityApiImpl: ConnectivityApi {
|
||||||
func getCapabilities() throws -> [NetworkCapability] {
|
func getCapabilities(completion: @escaping (Result<[NetworkCapability], any Error>) -> Void) {
|
||||||
[]
|
completion(Result.success([]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
mobile/ios/Runner/Core/ImmichPlugin.swift
Normal file
9
mobile/ios/Runner/Core/ImmichPlugin.swift
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
func dispatch<T>(
|
||||||
|
qos: DispatchQoS.QoSClass = .default,
|
||||||
|
completion: @escaping (Result<T, Error>) -> Void,
|
||||||
|
block: @escaping () throws -> T
|
||||||
|
) {
|
||||||
|
DispatchQueue.global(qos: qos).async {
|
||||||
|
completion(Result { try block() })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,13 +355,13 @@ class MessagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol NativeSyncApi {
|
protocol NativeSyncApi {
|
||||||
func shouldFullSync() throws -> Bool
|
func shouldFullSync() throws -> Bool
|
||||||
func getMediaChanges() throws -> SyncDelta
|
func getMediaChanges(completion: @escaping (Result<SyncDelta, Error>) -> Void)
|
||||||
func checkpointSync() throws
|
func checkpointSync() throws
|
||||||
func clearSyncCheckpoint() throws
|
func clearSyncCheckpoint() throws
|
||||||
func getAssetIdsForAlbum(albumId: String) throws -> [String]
|
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], Error>) -> Void)
|
||||||
func getAlbums() throws -> [PlatformAlbum]
|
func getAlbums(completion: @escaping (Result<[PlatformAlbum], Error>) -> Void)
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64
|
func getAssetsCountSince(albumId: String, timestamp: Int64, completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset]
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], Error>) -> Void)
|
||||||
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
|
||||||
func cancelHashing() throws
|
func cancelHashing() throws
|
||||||
}
|
}
|
||||||
@@ -395,11 +395,13 @@ class NativeSyncApiSetup {
|
|||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getMediaChanges\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
getMediaChangesChannel.setMessageHandler { _, reply in
|
getMediaChangesChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.getMediaChanges { result in
|
||||||
let result = try api.getMediaChanges()
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -438,11 +440,13 @@ class NativeSyncApiSetup {
|
|||||||
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
|
getAssetIdsForAlbumChannel.setMessageHandler { message, reply in
|
||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let albumIdArg = args[0] as! String
|
let albumIdArg = args[0] as! String
|
||||||
do {
|
api.getAssetIdsForAlbum(albumId: albumIdArg) { result in
|
||||||
let result = try api.getAssetIdsForAlbum(albumId: albumIdArg)
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -453,11 +457,13 @@ class NativeSyncApiSetup {
|
|||||||
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getAlbums\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
getAlbumsChannel.setMessageHandler { _, reply in
|
getAlbumsChannel.setMessageHandler { _, reply in
|
||||||
do {
|
api.getAlbums { result in
|
||||||
let result = try api.getAlbums()
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -471,11 +477,13 @@ class NativeSyncApiSetup {
|
|||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let albumIdArg = args[0] as! String
|
let albumIdArg = args[0] as! String
|
||||||
let timestampArg = args[1] as! Int64
|
let timestampArg = args[1] as! Int64
|
||||||
do {
|
api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg) { result in
|
||||||
let result = try api.getAssetsCountSince(albumId: albumIdArg, timestamp: timestampArg)
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -489,11 +497,13 @@ class NativeSyncApiSetup {
|
|||||||
let args = message as! [Any?]
|
let args = message as! [Any?]
|
||||||
let albumIdArg = args[0] as! String
|
let albumIdArg = args[0] as! String
|
||||||
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
|
let updatedTimeCondArg: Int64? = nilOrValue(args[1])
|
||||||
do {
|
api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg) { result in
|
||||||
let result = try api.getAssetsForAlbum(albumId: albumIdArg, updatedTimeCond: updatedTimeCondArg)
|
switch result {
|
||||||
reply(wrapResult(result))
|
case .success(let res):
|
||||||
} catch {
|
reply(wrapResult(res))
|
||||||
reply(wrapError(error))
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct AssetWrapper: Hashable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class NativeSyncApiImpl: NativeSyncApi {
|
class NativeSyncApiImpl: NativeSyncApi {
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let changeTokenKey = "immich:changeToken"
|
private let changeTokenKey = "immich:changeToken"
|
||||||
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum]
|
||||||
@@ -75,7 +76,12 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAlbums() throws -> [PlatformAlbum] {
|
|
||||||
|
func getAlbums(completion: @escaping (Result<[PlatformAlbum], any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion, block: getAlbums)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAlbums() throws -> [PlatformAlbum] {
|
||||||
var albums: [PlatformAlbum] = []
|
var albums: [PlatformAlbum] = []
|
||||||
|
|
||||||
albumTypes.forEach { type in
|
albumTypes.forEach { type in
|
||||||
@@ -112,7 +118,11 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return albums.sorted { $0.id < $1.id }
|
return albums.sorted { $0.id < $1.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMediaChanges() throws -> SyncDelta {
|
func getMediaChanges(completion: @escaping (Result<SyncDelta, any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion, block: getMediaChanges)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getMediaChanges() throws -> SyncDelta {
|
||||||
guard #available(iOS 16, *) else {
|
guard #available(iOS 16, *) else {
|
||||||
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature requires iOS 16 or later.", details: nil)
|
||||||
}
|
}
|
||||||
@@ -198,7 +208,11 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return albumAssets
|
return albumAssets
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
func getAssetIdsForAlbum(albumId: String, completion: @escaping (Result<[String], any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion) { try self.getAssetIdsForAlbum(albumId: albumId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAssetIdsForAlbum(albumId: String) throws -> [String] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
@@ -214,7 +228,13 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
func getAssetsCountSince(albumId: String, timestamp: Int64, completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion) {
|
||||||
|
try self.getAssetsCountSince(albumId: albumId, timestamp: timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAssetsCountSince(albumId: String, timestamp: Int64) throws -> Int64 {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return 0
|
return 0
|
||||||
@@ -228,7 +248,13 @@ class NativeSyncApiImpl: NativeSyncApi {
|
|||||||
return Int64(assets.count)
|
return Int64(assets.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?, completion: @escaping (Result<[PlatformAsset], any Error>) -> Void) {
|
||||||
|
dispatch(qos: .userInitiated, completion: completion) {
|
||||||
|
try self.getAssetsForAlbum(albumId: albumId, updatedTimeCond: updatedTimeCond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getAssetsForAlbum(albumId: String, updatedTimeCond: Int64?) throws -> [PlatformAsset] {
|
||||||
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
let collections = PHAssetCollection.fetchAssetCollections(withLocalIdentifiers: [albumId], options: nil)
|
||||||
guard let album = collections.firstObject else {
|
guard let album = collections.firstObject else {
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ class BackgroundWorkerSettings {
|
|||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class BackgroundWorkerFgHostApi {
|
abstract class BackgroundWorkerFgHostApi {
|
||||||
|
@async
|
||||||
void enable();
|
void enable();
|
||||||
|
|
||||||
|
@async
|
||||||
void configure(BackgroundWorkerSettings settings);
|
void configure(BackgroundWorkerSettings settings);
|
||||||
|
|
||||||
|
@async
|
||||||
void disable();
|
void disable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
)
|
)
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class BackgroundWorkerLockApi {
|
abstract class BackgroundWorkerLockApi {
|
||||||
|
@async
|
||||||
void lock();
|
void lock();
|
||||||
|
|
||||||
|
@async
|
||||||
void unlock();
|
void unlock();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum NetworkCapability { cellular, wifi, vpn, unmetered }
|
|||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class ConnectivityApi {
|
abstract class ConnectivityApi {
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<NetworkCapability> getCapabilities();
|
List<NetworkCapability> getCapabilities();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class HashResult {
|
|||||||
abstract class NativeSyncApi {
|
abstract class NativeSyncApi {
|
||||||
bool shouldFullSync();
|
bool shouldFullSync();
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
SyncDelta getMediaChanges();
|
SyncDelta getMediaChanges();
|
||||||
|
|
||||||
@@ -90,15 +91,19 @@ abstract class NativeSyncApi {
|
|||||||
|
|
||||||
void clearSyncCheckpoint();
|
void clearSyncCheckpoint();
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<String> getAssetIdsForAlbum(String albumId);
|
List<String> getAssetIdsForAlbum(String albumId);
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAlbum> getAlbums();
|
List<PlatformAlbum> getAlbums();
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
int getAssetsCountSince(String albumId, int timestamp);
|
int getAssetsCountSince(String albumId, int timestamp);
|
||||||
|
|
||||||
|
@async
|
||||||
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
|
||||||
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
List<PlatformAsset> getAssetsForAlbum(String albumId, {int? updatedTimeCond});
|
||||||
|
|
||||||
|
|||||||
@@ -161,24 +161,7 @@ export class NotificationService extends BaseService {
|
|||||||
|
|
||||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
// need to specify authDto to this mapAsset request, because it tries to prevent
|
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
|
||||||
// leaking information PR#7580 which expects a userId in the auth options object
|
|
||||||
this.eventRepository.clientSend(
|
|
||||||
'on_asset_update',
|
|
||||||
userId,
|
|
||||||
mapAsset(asset, {
|
|
||||||
auth: {
|
|
||||||
user: {
|
|
||||||
id: userId,
|
|
||||||
isAdmin: false,
|
|
||||||
name: '',
|
|
||||||
email: '',
|
|
||||||
quotaUsageInBytes: 0,
|
|
||||||
quotaSizeInBytes: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,6 @@
|
|||||||
|
|
||||||
const onMouseLeave = () => {
|
const onMouseLeave = () => {
|
||||||
mouseOver = false;
|
mouseOver = false;
|
||||||
onMouseEvent?.({ isMouseOver: false, selectedGroupIndex: groupIndex });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<div
|
<div
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
|
class="relative z-0 grid grid-cols-[--spacing(0)_auto] overflow-hidden sidebar:grid-cols-[--spacing(64)_auto]
|
||||||
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))] max-md:h-[calc(100dvh-var(--navbar-height-md))]'}
|
{hideNavbar ? 'h-dvh' : 'h-[calc(100dvh-var(--navbar-height))]'}
|
||||||
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
{hideNavbar ? 'pt-(--navbar-height)' : ''}
|
||||||
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
{hideNavbar ? 'max-md:pt-(--navbar-height-md)' : ''}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
|
||||||
import SearchResultsAssetViewer from '$lib/components/search/SearchResultsAssetViewer.svelte';
|
|
||||||
|
|
||||||
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
|
|
||||||
import Photostream from '$lib/components/timeline/Photostream.svelte';
|
|
||||||
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
|
|
||||||
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
|
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
|
||||||
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
|
||||||
|
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
children?: Snippet<[]>;
|
|
||||||
|
|
||||||
stylePaddingHorizontalPx?: number;
|
|
||||||
styleMarginTopPx?: number;
|
|
||||||
searchResultsManager: SearchResultsManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { searchResultsManager, assetInteraction, children, stylePaddingHorizontalPx, styleMarginTopPx }: Props =
|
|
||||||
$props();
|
|
||||||
let viewer: Photostream | undefined = $state(undefined);
|
|
||||||
|
|
||||||
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
|
|
||||||
viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<StreamWithViewer timelineManager={searchResultsManager} {onAfterNavigateComplete}>
|
|
||||||
{#snippet assetViewer({ onViewerClose })}
|
|
||||||
<SearchResultsAssetViewer timelineManager={searchResultsManager} {onViewerClose} />
|
|
||||||
{/snippet}
|
|
||||||
<Photostream
|
|
||||||
bind:this={viewer}
|
|
||||||
{stylePaddingHorizontalPx}
|
|
||||||
{styleMarginTopPx}
|
|
||||||
showScrollbar={true}
|
|
||||||
alwaysShowScrollbar={true}
|
|
||||||
enableRouting={true}
|
|
||||||
timelineManager={searchResultsManager}
|
|
||||||
isShowDeleteConfirmation={true}
|
|
||||||
smallHeaderHeight={{ rowHeight: 100, headerHeight: 2 }}
|
|
||||||
largeHeaderHeight={{ rowHeight: 235, headerHeight: 2 }}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
|
|
||||||
{#snippet skeleton({ segment })}
|
|
||||||
<Skeleton height={segment.height - segment.timelineManager.headerHeight} />
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
|
||||||
<SelectableSegment
|
|
||||||
{segment}
|
|
||||||
{onScrollCompensationMonthInDOM}
|
|
||||||
timelineManager={searchResultsManager}
|
|
||||||
{assetInteraction}
|
|
||||||
isSelectionMode={false}
|
|
||||||
singleSelect={false}
|
|
||||||
>
|
|
||||||
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
|
||||||
<AssetLayout
|
|
||||||
photostreamManager={searchResultsManager}
|
|
||||||
viewerAssets={segment.viewerAssets}
|
|
||||||
height={segment.height}
|
|
||||||
width={searchResultsManager.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={() => onAssetHover(asset)}
|
|
||||||
selected={isAssetSelected}
|
|
||||||
selectionCandidate={isAssetSelectionCandidate}
|
|
||||||
thumbnailWidth={position.width}
|
|
||||||
thumbnailHeight={position.height}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</AssetLayout>
|
|
||||||
{/snippet}
|
|
||||||
</SelectableSegment>
|
|
||||||
{/snippet}
|
|
||||||
</Photostream>
|
|
||||||
</StreamWithViewer>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
|
||||||
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
||||||
import { navigate } from '$lib/utils/navigation';
|
|
||||||
let { asset: viewingAsset, setAssetId, preloadAssets } = assetViewingStore;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
timelineManager: SearchResultsManager;
|
|
||||||
|
|
||||||
onViewerClose?: (asset: { id: string }) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { timelineManager, onViewerClose = () => Promise.resolve(void 0) }: Props = $props();
|
|
||||||
|
|
||||||
const handleNext = async (): Promise<boolean> => {
|
|
||||||
const next = timelineManager.findNextAsset($viewingAsset.id);
|
|
||||||
if (next) {
|
|
||||||
await navigateToAsset(next);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRandom = async (): Promise<{ id: string } | undefined> => {
|
|
||||||
const next = timelineManager.findRandomAsset();
|
|
||||||
if (next) {
|
|
||||||
await navigateToAsset(next);
|
|
||||||
return { id: next.id };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevious = async (): Promise<boolean> => {
|
|
||||||
const next = timelineManager.findPreviousAsset($viewingAsset.id);
|
|
||||||
if (next) {
|
|
||||||
await navigateToAsset(next);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateToAsset = async (asset?: { id: string }) => {
|
|
||||||
if (asset && asset.id !== $viewingAsset.id) {
|
|
||||||
await setAssetId(asset.id);
|
|
||||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = async (action: Action) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case AssetAction.ARCHIVE:
|
|
||||||
case AssetAction.DELETE:
|
|
||||||
case AssetAction.TRASH: {
|
|
||||||
timelineManager.removeAssets([action.asset.id]);
|
|
||||||
if (!(await handlePrevious())) {
|
|
||||||
await goto(AppRoute.PHOTOS);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#await import('../asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
|
||||||
<AssetViewer
|
|
||||||
asset={$viewingAsset}
|
|
||||||
preloadAssets={$preloadAssets}
|
|
||||||
onAction={handleAction}
|
|
||||||
onPrevious={handlePrevious}
|
|
||||||
onNext={handleNext}
|
|
||||||
onRandom={handleRandom}
|
|
||||||
onClose={onViewerClose}
|
|
||||||
/>
|
|
||||||
{/await}
|
|
||||||
@@ -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/photostream-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,109 +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 AssetLayout from '$lib/components/timeline/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}
|
|
||||||
<div
|
|
||||||
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
|
||||||
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
|
|
||||||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
|
||||||
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,307 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
|
||||||
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
|
|
||||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/photostream-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';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
segment: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
scrollToFunction: (top: number) => void;
|
|
||||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
skeleton: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
stylePaddingHorizontalPx: number;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
alwaysShowScrollbar?: boolean;
|
|
||||||
showSkeleton?: boolean;
|
|
||||||
isShowDeleteConfirmation?: boolean;
|
|
||||||
|
|
||||||
header?: Snippet<[scrollToFunction: (top: number) => void]>;
|
|
||||||
children?: Snippet;
|
|
||||||
empty?: Snippet;
|
|
||||||
handleTimelineScroll?: () => void;
|
|
||||||
|
|
||||||
smallHeaderHeight?: {
|
|
||||||
rowHeight: number;
|
|
||||||
headerHeight: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
largeHeaderHeight?: {
|
|
||||||
rowHeight: number;
|
|
||||||
headerHeight: number;
|
|
||||||
};
|
|
||||||
stylePaddingHorizontalPx?: number;
|
|
||||||
styleMarginTopPx?: number;
|
|
||||||
styleMarginRightPx?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
segment,
|
|
||||||
|
|
||||||
enableRouting,
|
|
||||||
timelineManager = $bindable(),
|
|
||||||
showSkeleton = $bindable(true),
|
|
||||||
showScrollbar,
|
|
||||||
styleMarginRightPx = 0,
|
|
||||||
stylePaddingHorizontalPx = 0,
|
|
||||||
styleMarginTopPx = 0,
|
|
||||||
alwaysShowScrollbar,
|
|
||||||
|
|
||||||
isShowDeleteConfirmation = $bindable(false),
|
|
||||||
|
|
||||||
children,
|
|
||||||
skeleton,
|
|
||||||
empty,
|
|
||||||
header,
|
|
||||||
handleTimelineScroll = () => {},
|
|
||||||
smallHeaderHeight = {
|
|
||||||
rowHeight: 100,
|
|
||||||
headerHeight: 32,
|
|
||||||
},
|
|
||||||
largeHeaderHeight = {
|
|
||||||
rowHeight: 235,
|
|
||||||
headerHeight: 48,
|
|
||||||
},
|
|
||||||
}: 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 ? smallHeaderHeight : largeHeaderHeight;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scrollToAssetId = async (assetId: string) => {
|
|
||||||
const monthGroup = await timelineManager.findSegmentForAssetId(assetId);
|
|
||||||
if (!monthGroup) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = getAssetHeight(assetId, monthGroup);
|
|
||||||
scrollTo(height);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const completeAfterNavigate = async ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) => {
|
|
||||||
if (timelineManager.viewportHeight === 0 || timelineManager.viewportWidth === 0) {
|
|
||||||
// this can happen if you do the following navigation order
|
|
||||||
// /photos?at=<id>, /photos/<id>, http://example.com, browser back, browser back
|
|
||||||
const rect = element?.getBoundingClientRect();
|
|
||||||
if (rect) {
|
|
||||||
timelineManager.viewportHeight = rect.height;
|
|
||||||
timelineManager.viewportWidth = rect.width;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (scrollToAssetQueryParam) {
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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={() => {
|
|
||||||
// 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 completeAfterNavigate({ scrollToAssetQueryParam: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 -->
|
|
||||||
<photostream
|
|
||||||
id="asset-grid"
|
|
||||||
class={[
|
|
||||||
'overflow-y-auto outline-none',
|
|
||||||
{ 'scrollbar-hidden': !showScrollbar },
|
|
||||||
{ 'overflow-y-scroll': alwaysShowScrollbar },
|
|
||||||
{ 'm-0': isEmpty },
|
|
||||||
{ 'ms-0': !isEmpty },
|
|
||||||
]}
|
|
||||||
style:height={`calc(100% - ${styleMarginTopPx}px)`}
|
|
||||||
style:margin-top={styleMarginTopPx + 'px'}
|
|
||||||
style:margin-right={styleMarginRightPx + 'px'}
|
|
||||||
style:padding-left={stylePaddingHorizontalPx + 'px'}
|
|
||||||
style:padding-right={stylePaddingHorizontalPx + 'px'}
|
|
||||||
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
|
|
||||||
tabindex="-1"
|
|
||||||
bind:clientHeight={timelineManager.viewportHeight}
|
|
||||||
bind:clientWidth={
|
|
||||||
null, (v: number) => ((timelineManager.viewportWidth = v - stylePaddingHorizontalPx * 2), updateSlidingWindow())
|
|
||||||
}
|
|
||||||
bind:this={element}
|
|
||||||
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
bind:this={timelineElement}
|
|
||||||
id="virtual-timeline"
|
|
||||||
class:relative={true}
|
|
||||||
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 && monthGroup.isLoaded}
|
|
||||||
{@const absoluteHeight = monthGroup.top}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="month-group"
|
|
||||||
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
|
|
||||||
style:position="absolute"
|
|
||||||
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
|
|
||||||
style:height={`${monthGroup.height}px`}
|
|
||||||
style:width="100%"
|
|
||||||
>
|
|
||||||
{#if !shouldDisplay}
|
|
||||||
{@render skeleton({ segment: monthGroup, stylePaddingHorizontalPx })}
|
|
||||||
{: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>
|
|
||||||
</photostream>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
photostream {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
#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,198 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Photostream from '$lib/components/timeline/Photostream.svelte';
|
|
||||||
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
|
||||||
import { findMonthAtScrollPosition } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
|
||||||
|
|
||||||
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { type ScrubberListenerWithScrollTo, 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 = 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: ScrubberListenerWithScrollTo = (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: Photostream | undefined = $state();
|
|
||||||
export const scrollToAsset = async (asset: TimelineAsset) =>
|
|
||||||
(await baseTimelineViewer?.scrollToAssetId(asset.id)) ?? false;
|
|
||||||
export const completeAfterNavigate = (args: { scrollToAssetQueryParam: boolean }) =>
|
|
||||||
baseTimelineViewer?.completeAfterNavigate(args);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Photostream
|
|
||||||
bind:this={baseTimelineViewer}
|
|
||||||
{enableRouting}
|
|
||||||
{timelineManager}
|
|
||||||
{showSkeleton}
|
|
||||||
{isShowDeleteConfirmation}
|
|
||||||
styleMarginRightPx={scrubberWidth}
|
|
||||||
{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}
|
|
||||||
</Photostream>
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +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 { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
content: Snippet<
|
|
||||||
[
|
|
||||||
{
|
|
||||||
onDayGroupSelect: (dayGroup: DayGroup, asset: TimelineAsset[]) => void;
|
|
||||||
onDayGroupAssetSelect: (dayGroup: DayGroup, asset: TimelineAsset) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
onAssetSelect: (asset: TimelineAsset) => void;
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { content, assetInteraction, 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,208 +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/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/photostream-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;
|
|
||||||
onAssetHover: (asset: TimelineAsset | null) => void;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>;
|
|
||||||
segment: PhotostreamSegment;
|
|
||||||
isSelectionMode: boolean;
|
|
||||||
singleSelect: boolean;
|
|
||||||
timelineManager: PhotostreamManager;
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
onAssetOpen?: (asset: TimelineAsset, defaultAssetOpen: () => void) => void;
|
|
||||||
onAssetSelect?: (asset: TimelineAsset) => void;
|
|
||||||
|
|
||||||
onScrollCompensationMonthInDOM: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
segment,
|
|
||||||
content,
|
|
||||||
isSelectionMode,
|
|
||||||
singleSelect,
|
|
||||||
assetInteraction,
|
|
||||||
timelineManager,
|
|
||||||
onAssetOpen,
|
|
||||||
onAssetSelect,
|
|
||||||
onScrollCompensationMonthInDOM,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let shiftKeyIsDown = $state(false);
|
|
||||||
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
|
||||||
let lastMouseHoverAsset: TimelineAsset | null = $state(null);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (shiftKeyIsDown && lastMouseHoverAsset) {
|
|
||||||
void selectAssetCandidates(lastMouseHoverAsset);
|
|
||||||
}
|
|
||||||
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) => {
|
|
||||||
handleSelectAssets(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;
|
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
|
||||||
if (lastMouseHoverAsset) {
|
|
||||||
void selectAssetCandidates(lastMouseHoverAsset);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!assetInteraction.assetSelectionStart) {
|
|
||||||
assetInteraction.setAssetSelectionStart(assetInteraction.selectedAssets.at(-1) ?? null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onKeyUp = (event: KeyboardEvent) => {
|
|
||||||
if (searchStore.isSearchEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Shift') {
|
|
||||||
event.preventDefault();
|
|
||||||
shiftKeyIsDown = false;
|
|
||||||
assetInteraction.clearAssetSelectionCandidates();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnHover = (asset: TimelineAsset | null) => {
|
|
||||||
if (asset) {
|
|
||||||
if (assetInteraction.selectionActive) {
|
|
||||||
void selectAssetCandidates(asset);
|
|
||||||
}
|
|
||||||
lastMouseHoverAsset = asset;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAssets = (asset: TimelineAsset) => {
|
|
||||||
if (!asset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onAssetSelect?.(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 = timelineManager.retrieveLoadedRange(assetInteraction.assetSelectionStart, asset);
|
|
||||||
for (const asset of assets) {
|
|
||||||
if (deselect) {
|
|
||||||
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
|
|
||||||
} else {
|
|
||||||
handleSelectAsset(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = (endAsset: TimelineAsset) => {
|
|
||||||
if (!shiftKeyIsDown) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startAsset = assetInteraction.assetSelectionStart;
|
|
||||||
if (!startAsset) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const assets = assetsSnapshot(timelineManager.retrieveLoadedRange(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: (asset) => {
|
|
||||||
void handleSelectAssets(asset);
|
|
||||||
},
|
|
||||||
onAssetHover: handleOnHover,
|
|
||||||
})}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
|
||||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
||||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
|
||||||
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { type Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
timelineManager: PhotostreamManager;
|
|
||||||
children?: Snippet;
|
|
||||||
assetViewer: Snippet<[{ onViewerClose: (asset: { id: string }) => Promise<void> }]>;
|
|
||||||
onAfterNavigateComplete: (args: { scrollToAssetQueryParam: boolean }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { timelineManager, children, assetViewer, onAfterNavigateComplete }: Props = $props();
|
|
||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
|
||||||
|
|
||||||
// tri-state boolean
|
|
||||||
let initialLoadWasAssetViewer: boolean | null = null;
|
|
||||||
let hasNavigatedToOrFromAssetViewer: boolean = false;
|
|
||||||
let timelineScrollPositionInitialized = false;
|
|
||||||
|
|
||||||
beforeNavigate(({ from, to }) => {
|
|
||||||
timelineManager.suspendTransitions = true;
|
|
||||||
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
|
|
||||||
});
|
|
||||||
|
|
||||||
const completeAfterNavigate = () => {
|
|
||||||
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
|
|
||||||
let isInitial = false;
|
|
||||||
// Set initial load state only once
|
|
||||||
if (initialLoadWasAssetViewer === null) {
|
|
||||||
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
|
||||||
isInitial = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrollToAssetQueryParam = false;
|
|
||||||
if (
|
|
||||||
!timelineScrollPositionInitialized &&
|
|
||||||
((isInitial && !assetViewerPage) || // Direct timeline load
|
|
||||||
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
|
|
||||||
) {
|
|
||||||
scrollToAssetQueryParam = true;
|
|
||||||
timelineScrollPositionInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return onAfterNavigateComplete({ scrollToAssetQueryParam });
|
|
||||||
};
|
|
||||||
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
|
|
||||||
|
|
||||||
const onViewerClose = async (asset: { id: string }) => {
|
|
||||||
assetViewingStore.showAssetViewer(false);
|
|
||||||
$gridScrollTarget = { at: asset.id };
|
|
||||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($showAssetViewer) {
|
|
||||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
|
||||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{@render children?.()}
|
|
||||||
|
|
||||||
<Portal target="body">
|
|
||||||
{#if $showAssetViewer}
|
|
||||||
{@render assetViewer({ onViewerClose })}
|
|
||||||
{/if}
|
|
||||||
</Portal>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,171 +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, mutex, preloadAssets } = assetViewingStore;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
timelineManager: TimelineManager;
|
|
||||||
|
|
||||||
withStacked?: boolean;
|
|
||||||
isShared?: boolean;
|
|
||||||
album?: AlbumResponseDto | null;
|
|
||||||
person?: PersonResponseDto | null;
|
|
||||||
onViewerClose?: (asset: { id: string }) => Promise<void>;
|
|
||||||
|
|
||||||
removeAction?:
|
|
||||||
| AssetAction.UNARCHIVE
|
|
||||||
| AssetAction.ARCHIVE
|
|
||||||
| AssetAction.FAVORITE
|
|
||||||
| AssetAction.UNFAVORITE
|
|
||||||
| AssetAction.SET_VISIBILITY_TIMELINE
|
|
||||||
| null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
timelineManager,
|
|
||||||
removeAction,
|
|
||||||
withStacked = false,
|
|
||||||
isShared = false,
|
|
||||||
album = null,
|
|
||||||
person = null,
|
|
||||||
onViewerClose = () => Promise.resolve(void 0),
|
|
||||||
}: 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 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 onViewerClose?.(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.upsertAssets([action.asset]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case AssetAction.ADD: {
|
|
||||||
timelineManager.upsertAssets([action.asset]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case AssetAction.UNSTACK: {
|
|
||||||
updateUnstackedAssetInTimeline(timelineManager, action.assets);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
|
||||||
timelineManager.upsertAssets([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={onViewerClose}
|
|
||||||
/>
|
|
||||||
{/await}
|
|
||||||
254
web/src/lib/components/timeline/TimelineDateGroup.svelte
Normal file
254
web/src/lib/components/timeline/TimelineDateGroup.svelte
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
|
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
|
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
|
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||||
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
|
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||||
|
import { navigate } from '$lib/utils/navigation';
|
||||||
|
|
||||||
|
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
|
||||||
|
|
||||||
|
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
|
||||||
|
import { Icon } from '@immich/ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
let { isUploading } = uploadAssetsStore;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isSelectionMode: boolean;
|
||||||
|
singleSelect: boolean;
|
||||||
|
withStacked: boolean;
|
||||||
|
showArchiveIcon: boolean;
|
||||||
|
monthGroup: MonthGroup;
|
||||||
|
timelineManager: TimelineManager;
|
||||||
|
assetInteraction: AssetInteraction;
|
||||||
|
customLayout?: Snippet<[TimelineAsset]>;
|
||||||
|
|
||||||
|
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
|
||||||
|
onSelectAssets: (asset: TimelineAsset) => void;
|
||||||
|
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
|
||||||
|
onScrollCompensation: (compensation: { heightDelta?: number; scrollTop?: number }) => void;
|
||||||
|
onThumbnailClick?: (
|
||||||
|
asset: TimelineAsset,
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
dayGroup: DayGroup,
|
||||||
|
onClick: (
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
assets: TimelineAsset[],
|
||||||
|
groupTitle: string,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
isSelectionMode,
|
||||||
|
singleSelect,
|
||||||
|
withStacked,
|
||||||
|
showArchiveIcon,
|
||||||
|
monthGroup = $bindable(),
|
||||||
|
assetInteraction,
|
||||||
|
timelineManager,
|
||||||
|
customLayout,
|
||||||
|
onSelect,
|
||||||
|
onSelectAssets,
|
||||||
|
onSelectAssetCandidates,
|
||||||
|
onScrollCompensation,
|
||||||
|
onThumbnailClick,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isMouseOverGroup = $state(false);
|
||||||
|
let hoveredDayGroup = $state();
|
||||||
|
|
||||||
|
const transitionDuration = $derived.by(() =>
|
||||||
|
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
|
||||||
|
);
|
||||||
|
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
|
||||||
|
const _onClick = (
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
assets: TimelineAsset[],
|
||||||
|
groupTitle: string,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
) => {
|
||||||
|
if (isSelectionMode || assetInteraction.selectionActive) {
|
||||||
|
assetSelectHandler(timelineManager, asset, assets, groupTitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void navigate({ targetRoute: 'current', assetId: asset.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
|
||||||
|
|
||||||
|
const assetSelectHandler = (
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
asset: TimelineAsset,
|
||||||
|
assetsInDayGroup: TimelineAsset[],
|
||||||
|
groupTitle: string,
|
||||||
|
) => {
|
||||||
|
onSelectAssets(asset);
|
||||||
|
|
||||||
|
// Check if all assets are selected in a group to toggle the group selection's icon
|
||||||
|
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
|
||||||
|
assetInteraction.hasSelectedAsset(asset.id),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// if all assets are selected in a group, add the group to selected group
|
||||||
|
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
|
||||||
|
assetInteraction.addGroupToMultiselectGroup(groupTitle);
|
||||||
|
} else {
|
||||||
|
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
|
||||||
|
isSelectingAllAssets.set(true);
|
||||||
|
} else {
|
||||||
|
isSelectingAllAssets.set(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
|
||||||
|
// Show multi select icon on hover on date group
|
||||||
|
hoveredDayGroup = groupTitle;
|
||||||
|
|
||||||
|
if (assetInteraction.selectionActive) {
|
||||||
|
onSelectAssetCandidates(asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
|
||||||
|
return intersectable.filter((int) => int.intersecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
|
||||||
|
const { month, year } = dayGroup.monthGroup.yearMonth;
|
||||||
|
const date = fromTimelinePlainDate({
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day: dayGroup.day,
|
||||||
|
});
|
||||||
|
return getDateLocaleString(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect.root(() => {
|
||||||
|
if (timelineManager.scrollCompensation.monthGroup === monthGroup) {
|
||||||
|
onScrollCompensation(timelineManager.scrollCompensation);
|
||||||
|
timelineManager.clearScrollCompensation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
|
||||||
|
{@const absoluteWidth = dayGroup.left}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<section
|
||||||
|
class={[
|
||||||
|
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
|
||||||
|
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
|
||||||
|
]}
|
||||||
|
data-group
|
||||||
|
style:position="absolute"
|
||||||
|
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
|
||||||
|
onmouseenter={() => {
|
||||||
|
isMouseOverGroup = true;
|
||||||
|
assetMouseEventHandler(dayGroup.groupTitle, null);
|
||||||
|
}}
|
||||||
|
onmouseleave={() => {
|
||||||
|
isMouseOverGroup = false;
|
||||||
|
assetMouseEventHandler(dayGroup.groupTitle, 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}
|
||||||
|
<div
|
||||||
|
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
|
||||||
|
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
|
||||||
|
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||||
|
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||||
|
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
|
||||||
|
>
|
||||||
|
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
|
||||||
|
<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={getDayGroupFullDate(dayGroup)}>
|
||||||
|
{dayGroup.groupTitle}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image grid -->
|
||||||
|
<div
|
||||||
|
data-image-grid
|
||||||
|
class="relative overflow-clip"
|
||||||
|
style:height={dayGroup.height + 'px'}
|
||||||
|
style:width={dayGroup.width + 'px'}
|
||||||
|
>
|
||||||
|
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
|
||||||
|
{@const position = viewerAsset.position!}
|
||||||
|
{@const asset = viewerAsset.asset!}
|
||||||
|
|
||||||
|
<!-- {#if viewerAsset.intersecting} -->
|
||||||
|
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
|
||||||
|
<div
|
||||||
|
data-asset-id={asset.id}
|
||||||
|
class="absolute"
|
||||||
|
style:top={position.top + 'px'}
|
||||||
|
style:left={position.left + 'px'}
|
||||||
|
style:width={position.width + 'px'}
|
||||||
|
style:height={position.height + 'px'}
|
||||||
|
out:scale|global={{ start: 0.1, duration: scaleDuration }}
|
||||||
|
animate:flip={{ duration: transitionDuration }}
|
||||||
|
>
|
||||||
|
<Thumbnail
|
||||||
|
showStackedIcon={withStacked}
|
||||||
|
{showArchiveIcon}
|
||||||
|
{asset}
|
||||||
|
{groupIndex}
|
||||||
|
onClick={(asset) => {
|
||||||
|
if (typeof onThumbnailClick === 'function') {
|
||||||
|
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
|
||||||
|
} else {
|
||||||
|
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
|
||||||
|
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
|
||||||
|
selected={assetInteraction.hasSelectedAsset(asset.id) ||
|
||||||
|
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||||
|
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
|
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
|
||||||
|
thumbnailWidth={position.width}
|
||||||
|
thumbnailHeight={position.height}
|
||||||
|
/>
|
||||||
|
{#if customLayout}
|
||||||
|
{@render customLayout(asset)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- {/if} -->
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
contain: layout paint style;
|
||||||
|
}
|
||||||
|
[data-image-grid] {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { OnArchive } from '$lib/utils/actions';
|
import type { OnArchive } from '$lib/utils/actions';
|
||||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||||
import { AssetVisibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
@@ -13,11 +12,9 @@
|
|||||||
onArchive?: OnArchive;
|
onArchive?: OnArchive;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
unarchive?: boolean;
|
unarchive?: boolean;
|
||||||
manager?: PhotostreamManager;
|
|
||||||
removeOnArchive?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onArchive, menuItem = false, unarchive = false, manager, removeOnArchive }: Props = $props();
|
let { onArchive, menuItem = false, unarchive = false }: Props = $props();
|
||||||
|
|
||||||
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
|
let text = $derived(unarchive ? $t('unarchive') : $t('to_archive'));
|
||||||
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
|
let icon = $derived(unarchive ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline);
|
||||||
@@ -27,18 +24,12 @@
|
|||||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async () => {
|
||||||
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||||
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
|
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
||||||
loading = true;
|
loading = true;
|
||||||
const ids = await archiveAssets(assets, visibility as AssetVisibility);
|
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
manager?.updateAssetOperation(ids, (asset) => {
|
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||||
asset.visibility = visibility;
|
|
||||||
return {
|
|
||||||
remove: removeOnArchive,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
onArchive?.(ids, visibility ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
|
||||||
clearSelect();
|
clearSelect();
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
|
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
|
||||||
import { IconButton } from '@immich/ui';
|
import { IconButton } from '@immich/ui';
|
||||||
@@ -11,14 +9,13 @@
|
|||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onAssetDelete?: OnDelete;
|
onAssetDelete: OnDelete;
|
||||||
onUndoDelete?: OnUndoDelete;
|
onUndoDelete?: OnUndoDelete | undefined;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
manager?: PhotostreamManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onAssetDelete, onUndoDelete, menuItem = false, force = !$featureFlags.trash, manager }: Props = $props();
|
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force = !$featureFlags.trash }: Props = $props();
|
||||||
|
|
||||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||||
|
|
||||||
@@ -39,14 +36,7 @@
|
|||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
loading = true;
|
loading = true;
|
||||||
const assets = [...getOwnedAssets()];
|
const assets = [...getOwnedAssets()];
|
||||||
const undo = (assets: TimelineAsset[]) => {
|
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
|
||||||
manager?.upsertAssets(assets);
|
|
||||||
manager?.order(assetOrdering ?? []);
|
|
||||||
onUndoDelete?.(assets);
|
|
||||||
};
|
|
||||||
await deleteAssets(force, onAssetDelete, assets, undo);
|
|
||||||
const assetOrdering = manager?.assets.map((asset) => asset.id);
|
|
||||||
manager?.removeAssets(assets.map((asset) => asset.id));
|
|
||||||
clearSelect();
|
clearSelect();
|
||||||
isShowConfirmation = false;
|
isShowConfirmation = false;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
notificationController,
|
notificationController,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { OnFavorite } from '$lib/utils/actions';
|
import type { OnFavorite } from '$lib/utils/actions';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAssets } from '@immich/sdk';
|
import { updateAssets } from '@immich/sdk';
|
||||||
@@ -17,10 +16,9 @@
|
|||||||
onFavorite?: OnFavorite;
|
onFavorite?: OnFavorite;
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
removeFavorite: boolean;
|
removeFavorite: boolean;
|
||||||
manager?: PhotostreamManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onFavorite, menuItem = false, removeFavorite, manager }: Props = $props();
|
let { onFavorite, menuItem = false, removeFavorite }: Props = $props();
|
||||||
|
|
||||||
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
|
let text = $derived(removeFavorite ? $t('remove_from_favorites') : $t('to_favorite'));
|
||||||
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
|
let icon = $derived(removeFavorite ? mdiHeartMinusOutline : mdiHeartOutline);
|
||||||
@@ -41,7 +39,11 @@
|
|||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
|
await updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
|
||||||
}
|
}
|
||||||
manager?.updateAssetOperation(ids, (asset) => ((asset.isFavorite = isFavorite), void 0));
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
}
|
||||||
|
|
||||||
onFavorite?.(ids, isFavorite);
|
onFavorite?.(ids, isFavorite);
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
|
||||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
|
||||||
import ChangeDate, {
|
|
||||||
type AbsoluteResult,
|
|
||||||
type RelativeResult,
|
|
||||||
} from '$lib/components/shared-components/change-date.svelte';
|
|
||||||
import {
|
|
||||||
setFocusToAsset as setFocusAssetUtil,
|
|
||||||
setFocusTo as setFocusToUtil,
|
|
||||||
type FocusDirection,
|
|
||||||
type FocusInterval,
|
|
||||||
} 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';
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
timelineManager: TimelineManager;
|
|
||||||
assetInteraction: AssetInteraction;
|
|
||||||
isShowDeleteConfirmation: boolean;
|
|
||||||
onEscape?: () => void;
|
|
||||||
scrollToAsset: (asset: TimelineAsset) => Promise<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.upsertAssets(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 = (direction: FocusDirection, interval: FocusInterval) =>
|
|
||||||
setFocusToUtil(scrollToAsset, timelineManager, direction, interval);
|
|
||||||
|
|
||||||
const setFocusAsset = (asset: TimelineAsset) => setFocusAssetUtil(scrollToAsset, asset);
|
|
||||||
|
|
||||||
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}
|
|
||||||
<ChangeDate
|
|
||||||
withDuration={false}
|
|
||||||
title="Navigate to Time"
|
|
||||||
initialDate={DateTime.now()}
|
|
||||||
timezoneInput={false}
|
|
||||||
onConfirm={async (dateString: AbsoluteResult | RelativeResult) => {
|
|
||||||
isShowSelectDate = false;
|
|
||||||
if (dateString.mode == 'absolute') {
|
|
||||||
const asset = await timelineManager.getClosestAssetToDate(
|
|
||||||
(DateTime.fromISO(dateString.date) as DateTime<true>).toObject(),
|
|
||||||
);
|
|
||||||
if (asset) {
|
|
||||||
void setFocusAsset(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onCancel={() => (isShowSelectDate = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -21,26 +21,19 @@ export const focusPreviousAsset = () =>
|
|||||||
|
|
||||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||||
|
|
||||||
export const setFocusToAsset = async (
|
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
const scrolled = scrollToAsset(asset);
|
||||||
asset: TimelineAsset,
|
|
||||||
) => {
|
|
||||||
const scrolled = await scrollToAsset(asset);
|
|
||||||
if (scrolled) {
|
if (scrolled) {
|
||||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||||
element?.focus();
|
element?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FocusDirection = 'earlier' | 'later';
|
|
||||||
|
|
||||||
export type FocusInterval = 'day' | 'month' | 'year' | 'asset';
|
|
||||||
|
|
||||||
export const setFocusTo = async (
|
export const setFocusTo = async (
|
||||||
scrollToAsset: (asset: TimelineAsset) => Promise<boolean>,
|
scrollToAsset: (asset: TimelineAsset) => boolean,
|
||||||
store: TimelineManager,
|
store: TimelineManager,
|
||||||
direction: FocusDirection,
|
direction: 'earlier' | 'later',
|
||||||
interval: FocusInterval,
|
interval: 'day' | 'month' | 'year' | 'asset',
|
||||||
) => {
|
) => {
|
||||||
if (tracker.isActive()) {
|
if (tracker.isActive()) {
|
||||||
// there are unfinished running invocations, so return early
|
// there are unfinished running invocations, so return early
|
||||||
@@ -72,10 +65,7 @@ export const setFocusTo = async (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrolled = await scrollToAsset(asset);
|
const scrolled = scrollToAsset(asset);
|
||||||
if (!invocation.isStillValid()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (scrolled) {
|
if (scrolled) {
|
||||||
await tick();
|
await tick();
|
||||||
if (!invocation.isStillValid()) {
|
if (!invocation.isStillValid()) {
|
||||||
|
|||||||
@@ -1,29 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
height: number;
|
height: number;
|
||||||
title?: string;
|
title: string;
|
||||||
stylePaddingHorizontalPx?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { height = 0, title, stylePaddingHorizontalPx = 0 }: Props = $props();
|
let { height = 0, title }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<skeleton class="overflow-clip" style:height={height + 'px'}>
|
<div class="overflow-clip" style:height={height + 'px'}>
|
||||||
{#if title}
|
|
||||||
<div
|
|
||||||
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div
|
<div
|
||||||
class="animate-pulse absolute h-full"
|
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
|
||||||
style:width="100%"
|
>
|
||||||
style:padding-left={stylePaddingHorizontalPx + 'px'}
|
{title}
|
||||||
style:padding-right={stylePaddingHorizontalPx + 'px'}
|
</div>
|
||||||
|
<div
|
||||||
|
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
|
||||||
|
style:width="calc(100% - 20px)"
|
||||||
data-skeleton="true"
|
data-skeleton="true"
|
||||||
></div>
|
></div>
|
||||||
</skeleton>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
[data-skeleton] {
|
[data-skeleton] {
|
||||||
|
|||||||
@@ -1,442 +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 {
|
|
||||||
PhotostreamSegment,
|
|
||||||
type SegmentIdentifier,
|
|
||||||
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
|
||||||
|
|
||||||
import { updateObject } from '$lib/managers/timeline-manager/internal/utils.svelte';
|
|
||||||
import type {
|
|
||||||
AssetDescriptor,
|
|
||||||
AssetOperation,
|
|
||||||
MoveAsset,
|
|
||||||
TimelineAsset,
|
|
||||||
TimelineManagerLayoutOptions,
|
|
||||||
Viewport,
|
|
||||||
} from '$lib/managers/timeline-manager/types';
|
|
||||||
import { setDifference } from '$lib/utils/timeline-util';
|
|
||||||
|
|
||||||
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(() => Promise.resolve(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segment.loader?.executed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await segment.load(cancelable);
|
|
||||||
if (result === 'LOADED') {
|
|
||||||
updateIntersectionMonthGroup(this, segment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSegmentByIdentifier(identifier: SegmentIdentifier) {
|
|
||||||
return this.months.find((segment) => identifier.matches(segment));
|
|
||||||
}
|
|
||||||
|
|
||||||
findSegmentForAssetId(assetId: string): Promise<PhotostreamSegment | undefined> {
|
|
||||||
for (const month of this.months) {
|
|
||||||
const asset = month.assets.find((asset) => asset.id === assetId);
|
|
||||||
if (asset) {
|
|
||||||
return Promise.resolve(month);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve(void 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
retrieveLoadedRange(start: AssetDescriptor, end: AssetDescriptor): TimelineAsset[] {
|
|
||||||
const range: TimelineAsset[] = [];
|
|
||||||
let collecting = false;
|
|
||||||
|
|
||||||
for (const month of this.months) {
|
|
||||||
if (collecting && !month.isLoaded) {
|
|
||||||
// if there are any unloaded months in the range, return empty []
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
retrieveRange(start: AssetDescriptor, end: AssetDescriptor): Promise<TimelineAsset[]> {
|
|
||||||
return Promise.resolve(this.retrieveLoadedRange(start, end));
|
|
||||||
}
|
|
||||||
|
|
||||||
get assets() {
|
|
||||||
return this.months.flatMap((segment) => segment.assets).flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
return this.#runAssetOperation(new Set(ids), operation);
|
|
||||||
}
|
|
||||||
|
|
||||||
upsertAssets(assets: TimelineAsset[]) {
|
|
||||||
const notExcluded = assets.filter((asset) => !this.isExcluded(asset));
|
|
||||||
const notUpdated = this.#updateAssets(notExcluded);
|
|
||||||
this.addAssetsToSegments([...notUpdated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#updateAssets(assets: TimelineAsset[]) {
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const lookup = new Map<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const { unprocessedIds } = this.#runAssetOperation(new Set(lookup.keys()), (asset) =>
|
|
||||||
updateObject(asset, lookup.get(asset.id)),
|
|
||||||
);
|
|
||||||
const result: TimelineAsset[] = [];
|
|
||||||
for (const id of unprocessedIds.values()) {
|
|
||||||
result.push(lookup.get(id)!);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAssets(ids: string[]) {
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const { unprocessedIds } = this.#runAssetOperation(new Set(ids), () => ({ remove: true }));
|
|
||||||
return [...unprocessedIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
isExcluded(_: TimelineAsset) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected createUpsertContext(): unknown {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract upsertAssetIntoSegment(asset: TimelineAsset, context: unknown): void;
|
|
||||||
|
|
||||||
protected postCreateSegments(): void {}
|
|
||||||
|
|
||||||
protected postUpsert(_: unknown): void {}
|
|
||||||
|
|
||||||
protected addAssetsToSegments(assets: TimelineAsset[]) {
|
|
||||||
if (assets.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const context = this.createUpsertContext();
|
|
||||||
const monthCount = this.months.length;
|
|
||||||
for (const asset of assets) {
|
|
||||||
this.upsertAssetIntoSegment(asset, context);
|
|
||||||
}
|
|
||||||
if (this.months.length !== monthCount) {
|
|
||||||
this.postCreateSegments();
|
|
||||||
}
|
|
||||||
this.postUpsert(context);
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
|
|
||||||
#runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
|
||||||
if (ids.size === 0) {
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
return { processedIds: new Set(), unprocessedIds: ids, changedGeometry: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const changedMonthGroups = new Set<PhotostreamSegment>();
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
let idsToProcess = new Set(ids);
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const idsProcessed = new Set<string>();
|
|
||||||
const combinedMoveAssets: MoveAsset[][] = [];
|
|
||||||
for (const month of this.months) {
|
|
||||||
if (idsToProcess.size > 0) {
|
|
||||||
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
|
||||||
if (moveAssets.length > 0) {
|
|
||||||
combinedMoveAssets.push(moveAssets);
|
|
||||||
}
|
|
||||||
idsToProcess = setDifference(idsToProcess, processedIds);
|
|
||||||
for (const id of processedIds) {
|
|
||||||
idsProcessed.add(id);
|
|
||||||
}
|
|
||||||
if (changedGeometry) {
|
|
||||||
changedMonthGroups.add(month);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (combinedMoveAssets.length > 0) {
|
|
||||||
this.addAssetsToSegments(combinedMoveAssets.flat().map((a) => a.asset));
|
|
||||||
}
|
|
||||||
const changedGeometry = changedMonthGroups.size > 0;
|
|
||||||
for (const month of changedMonthGroups) {
|
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
|
||||||
}
|
|
||||||
if (changedGeometry) {
|
|
||||||
this.updateIntersections();
|
|
||||||
}
|
|
||||||
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests that the manager's content be explictly set to the given id ordering.
|
|
||||||
* A particular Photostream manager may ignore this request.
|
|
||||||
*/
|
|
||||||
order(_: string[]) {}
|
|
||||||
}
|
|
||||||
@@ -1,200 +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/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { AssetOperation, MoveAsset, 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) {
|
|
||||||
void 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>;
|
|
||||||
|
|
||||||
async reload(cancelable: boolean): Promise<'DONE' | 'WAITED' | 'CANCELED' | 'LOADED' | 'ERRORED'> {
|
|
||||||
await this.loader.reset();
|
|
||||||
return await this.load(cancelable);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(_?: boolean) {}
|
|
||||||
|
|
||||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
|
||||||
this.intersecting = intersecting;
|
|
||||||
this.actuallyIntersecting = actuallyIntersecting;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract findAssetAbsolutePosition(assetId: string): number;
|
|
||||||
|
|
||||||
runAssetOperation(ids: Set<string>, operation: AssetOperation) {
|
|
||||||
if (ids.size === 0) {
|
|
||||||
return {
|
|
||||||
moveAssets: [] as MoveAsset[],
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
processedIds: new Set<string>(),
|
|
||||||
unprocessedIds: ids,
|
|
||||||
changedGeometry: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const unprocessedIds = new Set<string>(ids);
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const processedIds = new Set<string>();
|
|
||||||
const moveAssets: MoveAsset[] = [];
|
|
||||||
let changedGeometry = false;
|
|
||||||
for (const assetId of unprocessedIds) {
|
|
||||||
const index = this.viewerAssets.findIndex((viewAsset) => viewAsset.id == assetId);
|
|
||||||
if (index === -1) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = this.viewerAssets[index].asset!;
|
|
||||||
const oldTime = { ...asset.localDateTime };
|
|
||||||
const opResult = operation(asset);
|
|
||||||
let remove = false;
|
|
||||||
if (opResult) {
|
|
||||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
|
||||||
}
|
|
||||||
const newTime = asset.localDateTime;
|
|
||||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
|
||||||
const { year, month, day } = newTime;
|
|
||||||
remove = true;
|
|
||||||
moveAssets.push({ asset, date: { year, month, day } });
|
|
||||||
}
|
|
||||||
unprocessedIds.delete(assetId);
|
|
||||||
processedIds.add(assetId);
|
|
||||||
if (remove || this.timelineManager.isExcluded(asset)) {
|
|
||||||
this.viewerAssets.splice(index, 1);
|
|
||||||
changedGeometry = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { moveAssets, processedIds, unprocessedIds, changedGeometry };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import { SearchResultsSegment } from '$lib/managers/searchresults-manager/SearchResultsSegment.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
|
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
|
|
||||||
export type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'> & { isVisible: boolean };
|
|
||||||
|
|
||||||
export type Options = {
|
|
||||||
searchTerms: SearchTerms;
|
|
||||||
isSmartSearchEnabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class SearchResultsManager extends PhotostreamManager {
|
|
||||||
#options: Options = {
|
|
||||||
searchTerms: {
|
|
||||||
isVisible: false,
|
|
||||||
},
|
|
||||||
isSmartSearchEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
#months: SearchResultsSegment[] = $state([]);
|
|
||||||
|
|
||||||
get options() {
|
|
||||||
return this.#options;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateOptions(options: Options) {
|
|
||||||
if (isEqual(this.#options, options)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.initTask.reset();
|
|
||||||
this.#options = options;
|
|
||||||
await this.init();
|
|
||||||
this.updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
this.isInitialized = false;
|
|
||||||
await this.initTask.execute(() => {
|
|
||||||
this.#months.splice(0);
|
|
||||||
this.#months.push(new SearchResultsSegment(this, '1', { ...this.#options.searchTerms }));
|
|
||||||
return Promise.resolve();
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
this.updateViewportGeometry(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
get months(): SearchResultsSegment[] {
|
|
||||||
return this.#months;
|
|
||||||
}
|
|
||||||
|
|
||||||
findNextAsset(currentAssetId: string): { id: string } | undefined {
|
|
||||||
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
|
|
||||||
const segment = this.#months[segmentIndex];
|
|
||||||
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
|
|
||||||
|
|
||||||
if (assetIndex !== -1) {
|
|
||||||
// Found the current asset
|
|
||||||
if (assetIndex < segment.assets.length - 1) {
|
|
||||||
// Next asset is in the same segment
|
|
||||||
return segment.assets[assetIndex + 1];
|
|
||||||
} else if (segmentIndex < this.#months.length - 1) {
|
|
||||||
// Next asset is in the next segment
|
|
||||||
const nextSegment = this.#months[segmentIndex + 1];
|
|
||||||
if (nextSegment.assets.length > 0) {
|
|
||||||
return nextSegment.assets[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
findPreviousAsset(currentAssetId: string): { id: string } | undefined {
|
|
||||||
for (let segmentIndex = 0; segmentIndex < this.#months.length; segmentIndex++) {
|
|
||||||
const segment = this.#months[segmentIndex];
|
|
||||||
const assetIndex = segment.assets.findIndex((asset) => asset.id === currentAssetId);
|
|
||||||
|
|
||||||
if (assetIndex !== -1) {
|
|
||||||
// Found the current asset
|
|
||||||
if (assetIndex > 0) {
|
|
||||||
// Previous asset is in the same segment
|
|
||||||
return segment.assets[assetIndex - 1];
|
|
||||||
} else if (segmentIndex > 0) {
|
|
||||||
// Previous asset is in the previous segment
|
|
||||||
const previousSegment = this.#months[segmentIndex - 1];
|
|
||||||
if (previousSegment.assets.length > 0) {
|
|
||||||
return previousSegment.assets.at(-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
findRandomAsset(): { id: string } | undefined {
|
|
||||||
// Get all loaded assets across all segments
|
|
||||||
const allAssets = this.#months.flatMap((segment) => segment.assets);
|
|
||||||
|
|
||||||
if (allAssets.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return a random asset
|
|
||||||
const randomIndex = Math.floor(Math.random() * allAssets.length);
|
|
||||||
return allAssets[randomIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected upsertAssetIntoSegment(asset: TimelineAsset): void {
|
|
||||||
this.#months.at(-1)?.addTimelineAsset(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
order(ids: string[]) {
|
|
||||||
this.#months.at(-1)?.order(ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import {
|
|
||||||
PhotostreamSegment,
|
|
||||||
type SegmentIdentifier,
|
|
||||||
} from '$lib/managers/photostream-manager/PhotostreamSegment.svelte';
|
|
||||||
import type {
|
|
||||||
SearchResultsManager,
|
|
||||||
SearchTerms,
|
|
||||||
} from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
|
||||||
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 { TUNABLES } from '$lib/utils/tunables';
|
|
||||||
import { searchAssets, searchSmart } from '@immich/sdk';
|
|
||||||
import { isEqual } from 'lodash-es';
|
|
||||||
const {
|
|
||||||
TIMELINE: { INTERSECTION_EXPAND_BOTTOM },
|
|
||||||
} = TUNABLES;
|
|
||||||
export class SearchResultsSegment extends PhotostreamSegment {
|
|
||||||
#manager: SearchResultsManager;
|
|
||||||
#identifier: SegmentIdentifier;
|
|
||||||
#id: string;
|
|
||||||
#searchTerms: SearchTerms;
|
|
||||||
#currentPage: string | null = null;
|
|
||||||
#nextPage: string | null = null;
|
|
||||||
|
|
||||||
#viewerAssets: ViewerAsset[] = $state([]);
|
|
||||||
|
|
||||||
constructor(manager: SearchResultsManager, currentPage: string, searchTerms: SearchTerms) {
|
|
||||||
super();
|
|
||||||
this.#currentPage = currentPage;
|
|
||||||
this.#manager = manager;
|
|
||||||
this.#searchTerms = searchTerms;
|
|
||||||
this.#id = JSON.stringify(searchTerms);
|
|
||||||
this.#identifier = {
|
|
||||||
matches(segment: SearchResultsSegment) {
|
|
||||||
return isEqual(segment.#searchTerms, searchTerms);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.initialCount = searchTerms.size || 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
get timelineManager(): SearchResultsManager {
|
|
||||||
return this.#manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
get identifier(): SegmentIdentifier {
|
|
||||||
return this.#identifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
get id(): string {
|
|
||||||
return this.#id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetch(): Promise<void> {
|
|
||||||
if (this.#currentPage === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const searchDto: SearchTerms = {
|
|
||||||
...this.#searchTerms,
|
|
||||||
withExif: true,
|
|
||||||
isVisible: true,
|
|
||||||
page: +this.#currentPage,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { assets } =
|
|
||||||
('query' in searchDto || 'queryAssetId' in searchDto) && this.#manager.options.isSmartSearchEnabled
|
|
||||||
? await searchSmart({ smartSearchDto: searchDto })
|
|
||||||
: await searchAssets({ metadataSearchDto: searchDto });
|
|
||||||
this.#nextPage = assets.nextPage;
|
|
||||||
|
|
||||||
this.#viewerAssets.push(...assets.items.map((asset) => new ViewerAsset(this, toTimelineAsset(asset))));
|
|
||||||
this.layout();
|
|
||||||
}
|
|
||||||
|
|
||||||
layout(): void {
|
|
||||||
const timelineAssets = this.#viewerAssets.map((viewerAsset) => viewerAsset.asset);
|
|
||||||
const layoutOptions = this.timelineManager.createLayoutOptions();
|
|
||||||
const geometry = getJustifiedLayoutFromAssets(timelineAssets, layoutOptions);
|
|
||||||
|
|
||||||
this.height = timelineAssets.length === 0 ? 0 : geometry.containerHeight + this.timelineManager.headerHeight;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNextPage() {
|
|
||||||
if (this.#nextPage !== null) {
|
|
||||||
if (this.#currentPage === this.#nextPage) {
|
|
||||||
// there's an active load
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.#currentPage = this.#nextPage;
|
|
||||||
void this.reload(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIntersection({ intersecting, actuallyIntersecting }: { intersecting: boolean; actuallyIntersecting: boolean }) {
|
|
||||||
super.updateIntersection({ intersecting, actuallyIntersecting });
|
|
||||||
const loadNextPage =
|
|
||||||
this.timelineManager.timelineHeight - this.timelineManager.visibleWindow.bottom < INTERSECTION_EXPAND_BOTTOM;
|
|
||||||
if (loadNextPage) {
|
|
||||||
this.loadNextPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addTimelineAsset(asset: TimelineAsset) {
|
|
||||||
this.#viewerAssets.push(new ViewerAsset(this, asset));
|
|
||||||
this.layout();
|
|
||||||
}
|
|
||||||
|
|
||||||
order(ids: string[]) {
|
|
||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
||||||
const idMap = new Map(ids.map((id, index) => [id, index]));
|
|
||||||
this.#viewerAssets.sort((a, b) => {
|
|
||||||
const indexA = idMap.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const indexB = idMap.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
return indexA - indexB;
|
|
||||||
});
|
|
||||||
this.layout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() {
|
||||||
@@ -129,11 +127,7 @@ export class DayGroup {
|
|||||||
|
|
||||||
const asset = this.viewerAssets[index].asset!;
|
const asset = this.viewerAssets[index].asset!;
|
||||||
const oldTime = { ...asset.localDateTime };
|
const oldTime = { ...asset.localDateTime };
|
||||||
const opResult = operation(asset);
|
let { remove } = operation(asset);
|
||||||
let remove = false;
|
|
||||||
if (opResult) {
|
|
||||||
remove = (opResult as { remove: boolean }).remove ?? false;
|
|
||||||
}
|
|
||||||
const newTime = asset.localDateTime;
|
const newTime = asset.localDateTime;
|
||||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
||||||
const { year, month, day } = newTime;
|
const { year, month, day } = newTime;
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/photostream-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/photostream-manager/PhotostreamManager.svelte';
|
|
||||||
import type { PhotostreamSegment } from '$lib/managers/photostream-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) {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
|
||||||
|
import { AssetOrder } from '@immich/sdk';
|
||||||
|
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import { GroupInsertionCache } from '../group-insertion-cache.svelte';
|
||||||
|
import { MonthGroup } from '../month-group.svelte';
|
||||||
|
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||||
|
import type { AssetOperation, TimelineAsset } from '../types';
|
||||||
|
import { updateGeometry } from './layout-support.svelte';
|
||||||
|
import { getMonthGroupByDate } from './search-support.svelte';
|
||||||
|
|
||||||
|
export function addAssetsToMonthGroups(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
assets: TimelineAsset[],
|
||||||
|
options: { order: AssetOrder },
|
||||||
|
) {
|
||||||
|
if (assets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addContext = new GroupInsertionCache();
|
||||||
|
const updatedMonthGroups = new SvelteSet<MonthGroup>();
|
||||||
|
const monthCount = timelineManager.months.length;
|
||||||
|
for (const asset of assets) {
|
||||||
|
let month = getMonthGroupByDate(timelineManager, asset.localDateTime);
|
||||||
|
|
||||||
|
if (!month) {
|
||||||
|
month = new MonthGroup(timelineManager, asset.localDateTime, 1, options.order);
|
||||||
|
month.isLoaded = true;
|
||||||
|
timelineManager.months.push(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
month.addTimelineAsset(asset, addContext);
|
||||||
|
updatedMonthGroups.add(month);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timelineManager.months.length !== monthCount) {
|
||||||
|
timelineManager.months.sort((a, b) => {
|
||||||
|
return a.yearMonth.year === b.yearMonth.year
|
||||||
|
? b.yearMonth.month - a.yearMonth.month
|
||||||
|
: b.yearMonth.year - a.yearMonth.year;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of addContext.existingDayGroups) {
|
||||||
|
group.sortAssets(options.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const monthGroup of addContext.bucketsWithNewDayGroups) {
|
||||||
|
monthGroup.sortDayGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const month of addContext.updatedBuckets) {
|
||||||
|
month.sortDayGroups();
|
||||||
|
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||||
|
}
|
||||||
|
timelineManager.updateIntersections();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runAssetOperation(
|
||||||
|
timelineManager: TimelineManager,
|
||||||
|
ids: Set<string>,
|
||||||
|
operation: AssetOperation,
|
||||||
|
options: { order: AssetOrder },
|
||||||
|
) {
|
||||||
|
if (ids.size === 0) {
|
||||||
|
return { processedIds: new SvelteSet(), unprocessedIds: ids, changedGeometry: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedMonthGroups = new SvelteSet<MonthGroup>();
|
||||||
|
let idsToProcess = new SvelteSet(ids);
|
||||||
|
const idsProcessed = new SvelteSet<string>();
|
||||||
|
const combinedMoveAssets: { asset: TimelineAsset; date: TimelineDate }[][] = [];
|
||||||
|
for (const month of timelineManager.months) {
|
||||||
|
if (idsToProcess.size > 0) {
|
||||||
|
const { moveAssets, processedIds, changedGeometry } = month.runAssetOperation(idsToProcess, operation);
|
||||||
|
if (moveAssets.length > 0) {
|
||||||
|
combinedMoveAssets.push(moveAssets);
|
||||||
|
}
|
||||||
|
idsToProcess = setDifference(idsToProcess, processedIds);
|
||||||
|
for (const id of processedIds) {
|
||||||
|
idsProcessed.add(id);
|
||||||
|
}
|
||||||
|
if (changedGeometry) {
|
||||||
|
changedMonthGroups.add(month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (combinedMoveAssets.length > 0) {
|
||||||
|
addAssetsToMonthGroups(
|
||||||
|
timelineManager,
|
||||||
|
combinedMoveAssets.flat().map((a) => a.asset),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const changedGeometry = changedMonthGroups.size > 0;
|
||||||
|
for (const month of changedMonthGroups) {
|
||||||
|
updateGeometry(timelineManager, month, { invalidateHeight: true });
|
||||||
|
}
|
||||||
|
if (changedGeometry) {
|
||||||
|
timelineManager.updateIntersections();
|
||||||
|
}
|
||||||
|
return { unprocessedIds: idsToProcess, processedIds: idsProcessed, changedGeometry };
|
||||||
|
}
|
||||||
@@ -143,79 +143,3 @@ export function findMonthGroupForDate(timelineManager: TimelineManager, targetYe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export class WebsocketSupport {
|
|||||||
#processPendingChanges = throttle(() => {
|
#processPendingChanges = throttle(() => {
|
||||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||||
if (add.length > 0) {
|
if (add.length > 0) {
|
||||||
this.#timelineManager.upsertAssets(add);
|
this.#timelineManager.addAssets(add);
|
||||||
}
|
}
|
||||||
if (update.length > 0) {
|
if (update.length > 0) {
|
||||||
this.#timelineManager.upsertAssets(update);
|
this.#timelineManager.updateAssets(update);
|
||||||
}
|
}
|
||||||
if (remove.length > 0) {
|
if (remove.length > 0) {
|
||||||
this.#timelineManager.removeAssets(remove);
|
this.#timelineManager.removeAssets(remove);
|
||||||
|
|||||||
@@ -1,26 +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/photostream-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';
|
||||||
@@ -28,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() {
|
||||||
@@ -81,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() {
|
||||||
@@ -204,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);
|
||||||
@@ -225,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)) {
|
||||||
@@ -247,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) {
|
||||||
@@ -305,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,10 +1,7 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import {
|
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||||
findMonthGroupForAsset,
|
|
||||||
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';
|
||||||
@@ -95,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) =>
|
||||||
@@ -131,52 +128,52 @@ 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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('upsertAssets', () => {
|
describe('addAssets', () => {
|
||||||
let timelineManager: TimelineManager;
|
let timelineManager: TimelineManager;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -197,11 +194,11 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
|
|
||||||
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);
|
||||||
@@ -213,12 +210,12 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
})
|
})
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
timelineManager.upsertAssets([assetOne]);
|
timelineManager.addAssets([assetOne]);
|
||||||
timelineManager.upsertAssets([assetTwo]);
|
timelineManager.addAssets([assetTwo]);
|
||||||
|
|
||||||
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);
|
||||||
});
|
});
|
||||||
@@ -239,14 +236,14 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
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', () => {
|
||||||
@@ -265,7 +262,7 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||||
|
|
||||||
expect(timelineManager.months.length).toEqual(3);
|
expect(timelineManager.months.length).toEqual(3);
|
||||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||||
@@ -279,11 +276,11 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates existing asset', () => {
|
it('updates existing asset', () => {
|
||||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'upsertAssets');
|
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
|
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
});
|
});
|
||||||
@@ -295,7 +292,7 @@ describe('TimelineManager', () => {
|
|||||||
|
|
||||||
const timelineManager = new TimelineManager();
|
const timelineManager = new TimelineManager();
|
||||||
await timelineManager.updateOptions({ isTrashed: true });
|
await timelineManager.updateOptions({ isTrashed: true });
|
||||||
timelineManager.upsertAssets([asset, trashedAsset]);
|
timelineManager.addAssets([asset, trashedAsset]);
|
||||||
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -310,15 +307,22 @@ describe('TimelineManager', () => {
|
|||||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores non-existing assets', () => {
|
||||||
|
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||||
|
|
||||||
|
expect(timelineManager.months.length).toEqual(0);
|
||||||
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('updates an asset', () => {
|
it('updates an asset', () => {
|
||||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||||
const updatedAsset = { ...asset, isFavorite: true };
|
const updatedAsset = { ...asset, isFavorite: true };
|
||||||
|
|
||||||
timelineManager.upsertAssets([asset]);
|
timelineManager.addAssets([asset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||||
|
|
||||||
timelineManager.upsertAssets([updatedAsset]);
|
timelineManager.updateAssets([updatedAsset]);
|
||||||
expect(timelineManager.assetCount).toEqual(1);
|
expect(timelineManager.assetCount).toEqual(1);
|
||||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||||
});
|
});
|
||||||
@@ -334,17 +338,17 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
timelineManager.upsertAssets([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.upsertAssets([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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,7 +363,7 @@ describe('TimelineManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('ignores invalid IDs', () => {
|
it('ignores invalid IDs', () => {
|
||||||
timelineManager.upsertAssets(
|
timelineManager.addAssets(
|
||||||
timelineAssetFactory
|
timelineAssetFactory
|
||||||
.buildList(2, {
|
.buildList(2, {
|
||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
@@ -370,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', () => {
|
||||||
@@ -379,12 +383,12 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
})
|
})
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
timelineManager.removeAssets([assetOne.id]);
|
timelineManager.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
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', () => {
|
||||||
@@ -393,7 +397,7 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||||
})
|
})
|
||||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||||
timelineManager.upsertAssets(assets);
|
timelineManager.addAssets(assets);
|
||||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||||
|
|
||||||
expect(timelineManager.assetCount).toEqual(0);
|
expect(timelineManager.assetCount).toEqual(0);
|
||||||
@@ -425,7 +429,7 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -473,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]);
|
||||||
@@ -519,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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -550,12 +554,12 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||||
expect(findMonthGroupForAsset(timelineManager, assetTwo.id)?.monthGroup.yearMonth.month).toEqual(2);
|
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores removed months', () => {
|
it('ignores removed months', () => {
|
||||||
@@ -569,11 +573,11 @@ describe('TimelineManager', () => {
|
|||||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
timelineManager.addAssets([assetOne, assetTwo]);
|
||||||
|
|
||||||
timelineManager.removeAssets([assetTwo.id]);
|
timelineManager.removeAssets([assetTwo.id]);
|
||||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.year).toEqual(2024);
|
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||||
expect(findMonthGroupForAsset(timelineManager, assetOne.id)?.monthGroup.yearMonth.month).toEqual(1);
|
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.month).toEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,19 +3,18 @@ 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, SvelteSet } from 'svelte/reactivity';
|
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
import { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||||
import { GroupInsertionCache } from '$lib/managers/timeline-manager/group-insertion-cache.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 {
|
||||||
|
addAssetsToMonthGroups,
|
||||||
|
runAssetOperation,
|
||||||
|
} from '$lib/managers/timeline-manager/internal/operations-support.svelte';
|
||||||
import {
|
import {
|
||||||
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
findMonthGroupForAsset as findMonthGroupForAssetUtil,
|
||||||
findMonthGroupForDate,
|
findMonthGroupForDate,
|
||||||
@@ -25,15 +24,37 @@ import {
|
|||||||
} 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 { DayGroup } from './day-group.svelte';
|
import { DayGroup } from './day-group.svelte';
|
||||||
import { isMismatched } from './internal/utils.svelte';
|
import { isMismatched, updateObject } from './internal/utils.svelte';
|
||||||
import { MonthGroup } from './month-group.svelte';
|
import { MonthGroup } from './month-group.svelte';
|
||||||
import type { AssetDescriptor, Direction, ScrubberMonth, TimelineAsset, TimelineManagerOptions } from './types';
|
import type {
|
||||||
|
AssetDescriptor,
|
||||||
|
AssetOperation,
|
||||||
|
Direction,
|
||||||
|
ScrubberMonth,
|
||||||
|
TimelineAsset,
|
||||||
|
TimelineManagerLayoutOptions,
|
||||||
|
TimelineManagerOptions,
|
||||||
|
Viewport,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
export class TimelineManager extends PhotostreamManager {
|
|
||||||
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(
|
||||||
() => {
|
() => {
|
||||||
@@ -51,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?: {
|
||||||
@@ -72,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;
|
||||||
}
|
}
|
||||||
@@ -108,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) {
|
||||||
@@ -136,16 +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);
|
||||||
}
|
}
|
||||||
@@ -155,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +376,46 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
this.scrubberTimelineHeight = this.timelineHeight;
|
this.scrubberTimelineHeight = this.timelineHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSegmentForAssetId(id: string) {
|
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[]) {
|
||||||
|
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||||
|
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||||
|
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMonthGroupForAsset(id: string) {
|
||||||
if (!this.isInitialized) {
|
if (!this.isInitialized) {
|
||||||
await this.initTask.waitUntilCompletion();
|
await this.initTask.waitUntilCompletion();
|
||||||
}
|
}
|
||||||
@@ -198,14 +442,19 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMonthGroupByAssetId(assetId: string) {
|
||||||
|
const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
|
||||||
|
return monthGroupInfo?.monthGroup;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +463,40 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
return month?.getRandomAsset();
|
return month?.getRandomAsset();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAssetOperation(ids: string[], operation: AssetOperation) {
|
||||||
|
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAssets(assets: TimelineAsset[]) {
|
||||||
|
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||||
|
const { unprocessedIds } = runAssetOperation(
|
||||||
|
this,
|
||||||
|
new SvelteSet(lookup.keys()),
|
||||||
|
(asset) => {
|
||||||
|
updateObject(asset, lookup.get(asset.id));
|
||||||
|
return { remove: false };
|
||||||
|
},
|
||||||
|
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||||
|
);
|
||||||
|
const result: TimelineAsset[] = [];
|
||||||
|
for (const id of unprocessedIds.values()) {
|
||||||
|
result.push(lookup.get(id)!);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAssets(ids: string[]) {
|
||||||
|
const { unprocessedIds } = runAssetOperation(
|
||||||
|
this,
|
||||||
|
new SvelteSet(ids),
|
||||||
|
() => {
|
||||||
|
return { remove: true };
|
||||||
|
},
|
||||||
|
{ order: this.#options.order ?? AssetOrder.Desc },
|
||||||
|
);
|
||||||
|
return [...unprocessedIds];
|
||||||
|
}
|
||||||
|
|
||||||
refreshLayout() {
|
refreshLayout() {
|
||||||
for (const month of this.months) {
|
for (const month of this.months) {
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
updateGeometry(this, month, { invalidateHeight: true });
|
||||||
@@ -244,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;
|
||||||
@@ -269,42 +552,4 @@ export class TimelineManager extends PhotostreamManager {
|
|||||||
getAssetOrder() {
|
getAssetOrder() {
|
||||||
return this.#options.order ?? AssetOrder.Desc;
|
return this.#options.order ?? AssetOrder.Desc;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected createUpsertContext(): unknown {
|
|
||||||
return new GroupInsertionCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected upsertAssetIntoSegment(asset: TimelineAsset, context: GroupInsertionCache): void {
|
|
||||||
let month = getMonthGroupByDate(this, asset.localDateTime);
|
|
||||||
|
|
||||||
if (!month) {
|
|
||||||
month = new MonthGroup(this, asset.localDateTime, 1, true, this.#options.order);
|
|
||||||
this.months.push(month);
|
|
||||||
}
|
|
||||||
|
|
||||||
month.addTimelineAsset(asset, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected postCreateSegments(): void {
|
|
||||||
this.months.sort((a, b) => {
|
|
||||||
return a.yearMonth.year === b.yearMonth.year
|
|
||||||
? b.yearMonth.month - a.yearMonth.month
|
|
||||||
: b.yearMonth.year - a.yearMonth.year;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected postUpsert(context: GroupInsertionCache): void {
|
|
||||||
for (const group of context.existingDayGroups) {
|
|
||||||
group.sortAssets(this.#options.order);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const monthGroup of context.bucketsWithNewDayGroups) {
|
|
||||||
monthGroup.sortDayGroups();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const month of context.updatedBuckets) {
|
|
||||||
month.sortDayGroups();
|
|
||||||
updateGeometry(this, month, { invalidateHeight: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export type TimelineAsset = {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean } | unknown;
|
export type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||||
|
|
||||||
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
|
export type MoveAsset = { asset: TimelineAsset; date: TimelineDate };
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { PhotostreamSegment } from '$lib/managers/photostream-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,20 +5,15 @@ 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 = this.#group.monthGroup.timelineManager;
|
||||||
const store = dayGroup.monthGroup.timelineManager;
|
const positionTop = this.#group.absoluteDayGroupTop + this.position.top;
|
||||||
const positionTop = dayGroup.absoluteDayGroupTop + this.position.top;
|
|
||||||
return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
|
|
||||||
}
|
|
||||||
const store = (this.#group as PhotostreamSegment).timelineManager;
|
|
||||||
const positionTop = this.position.top + (this.#group as PhotostreamSegment).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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ export type OnSetVisibility = (ids: string[]) => void;
|
|||||||
|
|
||||||
export const deleteAssets = async (
|
export const deleteAssets = async (
|
||||||
force: boolean,
|
force: boolean,
|
||||||
onAssetDelete: OnDelete | undefined,
|
onAssetDelete: OnDelete,
|
||||||
assets: TimelineAsset[],
|
assets: TimelineAsset[],
|
||||||
onUndoDelete: OnUndoDelete | undefined,
|
onUndoDelete: OnUndoDelete | undefined = undefined,
|
||||||
) => {
|
) => {
|
||||||
const $t = get(t);
|
const $t = get(t);
|
||||||
try {
|
try {
|
||||||
const ids = assets.map((a) => a.id);
|
const ids = assets.map((a) => a.id);
|
||||||
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
|
await deleteBulk({ assetBulkDeleteDto: { ids, force } });
|
||||||
onAssetDelete?.(ids);
|
onAssetDelete(ids);
|
||||||
|
|
||||||
notificationController.show({
|
notificationController.show({
|
||||||
message: force
|
message: force
|
||||||
@@ -98,5 +98,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
timelineManager.upsertAssets(assets);
|
timelineManager.addAssets(assets);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
@@ -24,19 +23,11 @@ export type TimelineDateTime = TimelineDate & {
|
|||||||
millisecond: number;
|
millisecond: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScrubberData = {
|
export type ScrubberListener = (
|
||||||
scrubberMonth: { year: number; month: number };
|
scrubberMonth: { year: number; month: number },
|
||||||
overallScrollPercent: number;
|
overallScrollPercent: number,
|
||||||
scrubberMonthScrollPercent: number;
|
scrubberMonthScrollPercent: number,
|
||||||
};
|
) => void | Promise<void>;
|
||||||
|
|
||||||
export type ScrubberListener = (scrubberData: ScrubberData) => void | Promise<void>;
|
|
||||||
|
|
||||||
export type ScrubberDataWithScrollTo = ScrubberData & {
|
|
||||||
scrollToFunction: (top: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ScrubberListenerWithScrollTo = (scrubberData: ScrubberDataWithScrollTo) => void | Promise<void>;
|
|
||||||
|
|
||||||
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
// used for AssetResponseDto.dateTimeOriginal, amongst others
|
||||||
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>
|
||||||
@@ -160,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);
|
||||||
|
|
||||||
@@ -251,11 +234,3 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getSegmentIdentifier = (yearMonth: TimelineYearMonth | TimelineDateTime) => ({
|
|
||||||
matches(segment: MonthGroup) {
|
|
||||||
return (
|
|
||||||
segment.yearMonth && segment.yearMonth.year === yearMonth.year && segment.yearMonth.month === yearMonth.month
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
||||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
|
||||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||||
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
||||||
@@ -261,15 +262,18 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = () => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveAssets = async () => {
|
const handleRemoveAssets = async (assetIds: string[]) => {
|
||||||
|
timelineManager.removeAssets(assetIds);
|
||||||
await refreshAlbum();
|
await refreshAlbum();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUndoRemoveAssets = async () => {
|
const handleUndoRemoveAssets = async (assets: TimelineAsset[]) => {
|
||||||
|
timelineManager.addAssets(assets);
|
||||||
await refreshAlbum();
|
await refreshAlbum();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -448,7 +452,7 @@
|
|||||||
{isSelectionMode}
|
{isSelectionMode}
|
||||||
{singleSelect}
|
{singleSelect}
|
||||||
{showArchiveIcon}
|
{showArchiveIcon}
|
||||||
onAssetSelect={onSelect}
|
{onSelect}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
{#if viewMode !== AlbumPageViewMode.SELECT_ASSETS}
|
||||||
@@ -568,7 +572,14 @@
|
|||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
{#if assetInteraction.isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
></FavoriteAction>
|
||||||
{/if}
|
{/if}
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
|
||||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||||
@@ -583,7 +594,7 @@
|
|||||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -595,12 +606,7 @@
|
|||||||
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
|
<RemoveFromAlbum menuItem bind:album onRemove={handleRemoveAssets} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if assetInteraction.isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<DeleteAssets
|
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} onUndoDelete={handleUndoRemoveAssets} />
|
||||||
menuItem
|
|
||||||
onAssetDelete={handleRemoveAssets}
|
|
||||||
onUndoDelete={handleUndoRemoveAssets}
|
|
||||||
manager={timelineManager}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
|
|||||||
@@ -65,18 +65,32 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<ArchiveAction unarchive manager={timelineManager} />
|
<ArchiveAction
|
||||||
|
unarchive
|
||||||
|
onArchive={(ids, visibility) =>
|
||||||
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.visibility = visibility;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<DeleteAssets menuItem manager={timelineManager} />
|
<DeleteAssets menuItem onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => assetInteraction.clearMultiselect()}
|
clearSelect={() => assetInteraction.clearMultiselect()}
|
||||||
>
|
>
|
||||||
<FavoriteAction removeFavorite manager={timelineManager} />
|
<FavoriteAction removeFavorite onFavorite={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
<CreateSharedLink />
|
<CreateSharedLink />
|
||||||
<SelectAllAssets {timelineManager} {assetInteraction} />
|
<SelectAllAssets {timelineManager} {assetInteraction} />
|
||||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||||
@@ -83,12 +83,20 @@
|
|||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescription menuItem />
|
<ChangeDescription menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
|
<ArchiveAction
|
||||||
|
menuItem
|
||||||
|
unarchive={assetInteraction.isAllArchived}
|
||||||
|
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
|
/>
|
||||||
{#if $preferences.tags.enabled}
|
{#if $preferences.tags.enabled}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<DeleteAssets menuItem manager={timelineManager} />
|
<DeleteAssets
|
||||||
|
menuItem
|
||||||
|
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
|
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||||
|
/>
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -349,8 +349,15 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAssets = async () => await updateAssetCount();
|
const handleDeleteAssets = async (assetIds: string[]) => {
|
||||||
const handleUndoDeleteAssets = async () => await updateAssetCount();
|
timelineManager.removeAssets(assetIds);
|
||||||
|
await updateAssetCount();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndoDeleteAssets = async (assets: TimelineAsset[]) => {
|
||||||
|
timelineManager.addAssets(assets);
|
||||||
|
await updateAssetCount();
|
||||||
|
};
|
||||||
|
|
||||||
let person = $derived(data.person);
|
let person = $derived(data.person);
|
||||||
|
|
||||||
@@ -385,7 +392,7 @@
|
|||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
isSelectionMode={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
singleSelect={viewMode === PersonPageViewMode.SELECT_PERSON}
|
||||||
onAssetSelect={handleSelectFeaturePhoto}
|
onSelect={handleSelectFeaturePhoto}
|
||||||
onEscape={handleEscape}
|
onEscape={handleEscape}
|
||||||
>
|
>
|
||||||
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
{#if viewMode === PersonPageViewMode.VIEW_ASSETS}
|
||||||
@@ -504,7 +511,14 @@
|
|||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
/>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||||
<MenuOption
|
<MenuOption
|
||||||
@@ -515,16 +529,19 @@
|
|||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescription menuItem />
|
<ChangeDescription menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} manager={timelineManager} />
|
<ArchiveAction
|
||||||
|
menuItem
|
||||||
|
unarchive={assetInteraction.isAllArchived}
|
||||||
|
onArchive={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
|
/>
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<DeleteAssets
|
<DeleteAssets
|
||||||
menuItem
|
menuItem
|
||||||
manager={timelineManager}
|
onAssetDelete={(assetIds) => handleDeleteAssets(assetIds)}
|
||||||
onAssetDelete={handleDeleteAssets}
|
onUndoDelete={(assets) => handleUndoDeleteAssets(assets)}
|
||||||
onUndoDelete={handleUndoDeleteAssets}
|
|
||||||
/>
|
/>
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
|
|||||||
@@ -70,12 +70,12 @@
|
|||||||
|
|
||||||
const handleLink: OnLink = ({ still, motion }) => {
|
const handleLink: OnLink = ({ still, motion }) => {
|
||||||
timelineManager.removeAssets([motion.id]);
|
timelineManager.removeAssets([motion.id]);
|
||||||
timelineManager.upsertAssets([still]);
|
timelineManager.updateAssets([still]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
const handleUnlink: OnUnlink = ({ still, motion }) => {
|
||||||
timelineManager.upsertAssets([motion]);
|
timelineManager.addAssets([motion]);
|
||||||
timelineManager.upsertAssets([still]);
|
timelineManager.updateAssets([still]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
@@ -118,7 +118,14 @@
|
|||||||
<AddToAlbum />
|
<AddToAlbum />
|
||||||
<AddToAlbum shared />
|
<AddToAlbum shared />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={timelineManager}></FavoriteAction>
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) =>
|
||||||
|
timelineManager.updateAssetOperation(ids, (asset) => {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
return { remove: false };
|
||||||
|
})}
|
||||||
|
></FavoriteAction>
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||||
@@ -139,11 +146,15 @@
|
|||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescription menuItem />
|
<ChangeDescription menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction menuItem manager={timelineManager} />
|
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
|
||||||
{#if $preferences.tags.enabled}
|
{#if $preferences.tags.enabled}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem manager={timelineManager} />
|
<DeleteAssets
|
||||||
|
menuItem
|
||||||
|
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||||
|
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
|
||||||
|
/>
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
<hr />
|
<hr />
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
import SearchResults from '$lib/components/search/SearchResults.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';
|
||||||
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte';
|
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte';
|
||||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||||
import ChangeDescriptionAction from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
|
||||||
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
|
||||||
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
|
||||||
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
|
||||||
@@ -19,40 +20,67 @@
|
|||||||
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 { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { SearchResultsManager } from '$lib/managers/searchresults-manager/SearchResultsManager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { lang, locale } from '$lib/stores/preferences.store';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
|
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 { getPerson, getTagById, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { IconButton } from '@immich/ui';
|
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
|
||||||
import { mdiArrowLeft, mdiDotsVertical, mdiPlus, mdiSelectAll } from '@mdi/js';
|
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||||
|
import {
|
||||||
|
type AlbumResponseDto,
|
||||||
|
getPerson,
|
||||||
|
getTagById,
|
||||||
|
type MetadataSearchDto,
|
||||||
|
searchAssets,
|
||||||
|
searchSmart,
|
||||||
|
type SmartSearchDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
|
||||||
|
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
|
let searchResultsElement: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// The GalleryViewer pushes it's own history state, which causes weird
|
||||||
|
// behavior for history.back(). To prevent that we store the previous page
|
||||||
|
// manually and navigate back to that.
|
||||||
let previousRoute = $state(AppRoute.EXPLORE as string);
|
let previousRoute = $state(AppRoute.EXPLORE as string);
|
||||||
|
|
||||||
|
let nextPage = $state(1);
|
||||||
|
let searchResultAlbums: AlbumResponseDto[] = $state([]);
|
||||||
|
let searchResultAssets: TimelineAsset[] = $state([]);
|
||||||
|
let isLoading = $state(true);
|
||||||
|
let scrollY = $state(0);
|
||||||
|
let scrollYHistory = 0;
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
|
type SearchTerms = MetadataSearchDto & Pick<SmartSearchDto, 'query' | 'queryAssetId'>;
|
||||||
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
|
||||||
let isSmartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
|
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
|
||||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||||
|
|
||||||
const searchResultsManager = new SearchResultsManager();
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void searchResultsManager.updateOptions({ isSmartSearchEnabled, searchTerms: terms });
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
terms;
|
||||||
|
setTimeout(() => {
|
||||||
|
handlePromiseError(onSearchQueryUpdate());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let timelineManager = new TimelineManager();
|
||||||
|
|
||||||
const onEscape = () => {
|
const onEscape = () => {
|
||||||
if ($showAssetViewer) {
|
if ($showAssetViewer) {
|
||||||
return;
|
return;
|
||||||
@@ -62,16 +90,91 @@
|
|||||||
assetInteraction.selectedAssets = [];
|
assetInteraction.selectedAssets = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handlePromiseError(goto(previousRoute));
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (scrollY) {
|
||||||
|
scrollYHistory = scrollY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate(({ from }) => {
|
||||||
|
// Prevent setting previousRoute to the current page.
|
||||||
|
if (from?.url && from.route.id !== page.route.id) {
|
||||||
|
previousRoute = from.url.href;
|
||||||
|
}
|
||||||
|
const route = from?.route?.id;
|
||||||
|
|
||||||
|
if (isPeopleRoute(route)) {
|
||||||
|
previousRoute = AppRoute.PHOTOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAlbumsRoute(route)) {
|
||||||
|
previousRoute = AppRoute.EXPLORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
.then(() => {
|
||||||
|
window.scrollTo(0, scrollYHistory);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// do nothing
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAssetDelete = (assetIds: string[]) => {
|
||||||
|
const assetIdSet = new Set(assetIds);
|
||||||
|
searchResultAssets = searchResultAssets.filter((asset: TimelineAsset) => !assetIdSet.has(asset.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetVisibility = (assetIds: string[]) => {
|
const handleSetVisibility = (assetIds: string[]) => {
|
||||||
searchResultsManager.removeAssets(assetIds);
|
timelineManager.removeAssets(assetIds);
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
|
onAssetDelete(assetIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
const allAssets = searchResultsManager.months.flatMap((segment) => segment.assets);
|
assetInteraction.selectAssets(searchResultAssets);
|
||||||
assetInteraction.selectAssets(allAssets);
|
};
|
||||||
|
|
||||||
|
async function onSearchQueryUpdate() {
|
||||||
|
nextPage = 1;
|
||||||
|
searchResultAssets = [];
|
||||||
|
searchResultAlbums = [];
|
||||||
|
await loadNextPage(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
isVisible: true,
|
||||||
|
language: $lang,
|
||||||
|
...terms,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { albums, assets } =
|
||||||
|
('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled
|
||||||
|
? await searchSmart({ smartSearchDto: searchDto })
|
||||||
|
: await searchAssets({ metadataSearchDto: searchDto });
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -145,7 +248,8 @@
|
|||||||
cancelMultiselect(assetInteraction);
|
cancelMultiselect(assetInteraction);
|
||||||
|
|
||||||
if (terms.isNotInAlbum.toString() == 'true') {
|
if (terms.isNotInAlbum.toString() == 'true') {
|
||||||
searchResultsManager.removeAssets(assetIds);
|
const assetIdSet = new Set(assetIds);
|
||||||
|
searchResultAssets = searchResultAssets.filter((asset) => !assetIdSet.has(asset.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,11 +258,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window bind:scrollY />
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
|
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<div class="fixed z-1 top-0 start-0 w-full">
|
<div class="fixed top-0 start-0 w-full">
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
@@ -176,29 +281,34 @@
|
|||||||
<AddToAlbum {onAddToAlbum} />
|
<AddToAlbum {onAddToAlbum} />
|
||||||
<AddToAlbum shared {onAddToAlbum} />
|
<AddToAlbum shared {onAddToAlbum} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(assetIds, isFavorite) => {
|
||||||
|
for (const assetId of assetIds) {
|
||||||
|
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
|
||||||
|
if (asset) {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
menuItem
|
|
||||||
removeOnArchive={true}
|
|
||||||
unarchive={assetInteraction.isAllArchived}
|
|
||||||
manager={searchResultsManager}
|
|
||||||
/>
|
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem manager={searchResultsManager} />
|
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||||
<hr />
|
<hr />
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fixed z-1 top-0 start-0 w-full">
|
<div class="fixed top-0 start-0 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="absolute bg-light"></div>
|
||||||
<div class="w-full flex-1 ps-4">
|
<div class="w-full flex-1 ps-4">
|
||||||
@@ -209,55 +319,92 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
{#if terms}
|
||||||
class="absolute top-0 right-0 left-0 bottom-0 mb-0 bg-immich-bg dark:bg-immich-dark-bg max-h-screen"
|
<section
|
||||||
bind:clientHeight={viewport.height}
|
id="search-chips"
|
||||||
bind:clientWidth={viewport.width}
|
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
|
||||||
>
|
>
|
||||||
<SearchResults {searchResultsManager} {assetInteraction} stylePaddingHorizontalPx={16}>
|
{#each getObjectKeys(terms) as searchKey (searchKey)}
|
||||||
{#if terms}
|
{@const value = terms[searchKey]}
|
||||||
<section
|
<div class="flex place-content-center place-items-center items-stretch text-xs">
|
||||||
id="search-chips"
|
<div
|
||||||
class="md:mt-[94px] mt-[72px] mb-4 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap"
|
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
||||||
>
|
|
||||||
{#each getObjectKeys(terms) as searchKey (searchKey)}
|
|
||||||
{@const value = terms[searchKey]}
|
|
||||||
<div class="flex place-content-center place-items-center items-stretch text-xs">
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-center bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
|
|
||||||
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
{value === true ? 'rounded-full' : 'rounded-s-full'}"
|
||||||
>
|
>
|
||||||
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
{getHumanReadableSearchKey(searchKey as keyof SearchTerms)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if value !== true}
|
{#if value !== true}
|
||||||
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
|
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-e-full">
|
||||||
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
{#if (searchKey === 'takenAfter' || searchKey === 'takenBefore') && typeof value === 'string'}
|
||||||
{getHumanReadableDate(value)}
|
{getHumanReadableDate(value)}
|
||||||
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
{:else if searchKey === 'personIds' && Array.isArray(value)}
|
||||||
{#await getPersonName(value) then personName}
|
{#await getPersonName(value) then personName}
|
||||||
{personName}
|
{personName}
|
||||||
{/await}
|
{/await}
|
||||||
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
|
||||||
{#await getTagNames(value) then tagNames}
|
{#await getTagNames(value) then tagNames}
|
||||||
{tagNames}
|
{tagNames}
|
||||||
{/await}
|
{/await}
|
||||||
{:else if value === null || value === ''}
|
{:else if value === null || value === ''}
|
||||||
{$t('unknown')}
|
{$t('unknown')}
|
||||||
{:else}
|
{:else}
|
||||||
{value}
|
{value}
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/if}
|
||||||
</section>
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4 max-h-screen"
|
||||||
|
bind:clientHeight={viewport.height}
|
||||||
|
bind:clientWidth={viewport.width}
|
||||||
|
bind:this={searchResultsElement}
|
||||||
|
>
|
||||||
|
{#if searchResultAlbums.length > 0}
|
||||||
|
<section>
|
||||||
|
<div class="uppercase ms-6 text-4xl font-medium text-black/70 dark:text-white/80">{$t('albums')}</div>
|
||||||
|
<AlbumCardGroup albums={searchResultAlbums} showDateRange showItemCount />
|
||||||
|
|
||||||
|
<div class="uppercase m-6 text-4xl font-medium text-black/70 dark:text-white/80">
|
||||||
|
{$t('photos_and_videos')}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
<section id="search-content">
|
||||||
|
{#if searchResultAssets.length > 0}
|
||||||
|
<GalleryViewer
|
||||||
|
assets={searchResultAssets}
|
||||||
|
{assetInteraction}
|
||||||
|
onIntersected={loadNextPage}
|
||||||
|
showArchiveIcon={true}
|
||||||
|
{viewport}
|
||||||
|
onReload={onSearchQueryUpdate}
|
||||||
|
slidingWindowOffset={searchResultsElement.offsetTop}
|
||||||
|
/>
|
||||||
|
{:else if !isLoading}
|
||||||
|
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
|
||||||
|
<div class="flex flex-col content-center items-center text-center">
|
||||||
|
<Icon icon={mdiImageOffOutline} size="3.5em" />
|
||||||
|
<p class="mt-5 text-3xl font-medium">{$t('no_results')}</p>
|
||||||
|
<p class="text-base font-normal">{$t('no_results_description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</SearchResults>
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex justify-center py-16 items-center">
|
||||||
|
<LoadingSpinner size="giant" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
{#if assetInteraction.selectionActive}
|
{#if assetInteraction.selectionActive}
|
||||||
<div class="fixed z-1 top-0 start-0 w-full">
|
<div class="fixed top-0 start-0 w-full">
|
||||||
<AssetSelectControlBar
|
<AssetSelectControlBar
|
||||||
assets={assetInteraction.selectedAssets}
|
assets={assetInteraction.selectedAssets}
|
||||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||||
@@ -275,33 +422,38 @@
|
|||||||
<AddToAlbum {onAddToAlbum} />
|
<AddToAlbum {onAddToAlbum} />
|
||||||
<AddToAlbum shared {onAddToAlbum} />
|
<AddToAlbum shared {onAddToAlbum} />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
<FavoriteAction removeFavorite={assetInteraction.isAllFavorite} manager={searchResultsManager} />
|
<FavoriteAction
|
||||||
|
removeFavorite={assetInteraction.isAllFavorite}
|
||||||
|
onFavorite={(ids, isFavorite) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const asset = searchResultAssets.find((asset) => asset.id === id);
|
||||||
|
if (asset) {
|
||||||
|
asset.isFavorite = isFavorite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||||
<DownloadAction menuItem />
|
<DownloadAction menuItem />
|
||||||
<ChangeDate menuItem />
|
<ChangeDate menuItem />
|
||||||
<ChangeDescriptionAction menuItem />
|
<ChangeDescription menuItem />
|
||||||
<ChangeLocation menuItem />
|
<ChangeLocation menuItem />
|
||||||
<ArchiveAction
|
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||||
menuItem
|
|
||||||
removeOnArchive={true}
|
|
||||||
unarchive={assetInteraction.isAllArchived}
|
|
||||||
manager={searchResultsManager}
|
|
||||||
/>
|
|
||||||
{#if assetInteraction.isAllUserOwned}
|
{#if assetInteraction.isAllUserOwned}
|
||||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||||
<TagAction menuItem />
|
<TagAction menuItem />
|
||||||
{/if}
|
{/if}
|
||||||
<DeleteAssets menuItem manager={searchResultsManager} />
|
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||||
<hr />
|
<hr />
|
||||||
<AssetJobActions />
|
<AssetJobActions />
|
||||||
</ButtonContextMenu>
|
</ButtonContextMenu>
|
||||||
</AssetSelectControlBar>
|
</AssetSelectControlBar>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="fixed z-1 top-0 start-0 w-full">
|
<div class="fixed top-0 start-0 w-full">
|
||||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||||
<div class="absolute bg-light"></div>
|
<div class="absolute bg-light"></div>
|
||||||
<div class="w-full flex-1 ps-4">
|
<div class="w-full flex-1 ps-4">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
import GeolocationUpdateConfirmModal from '$lib/modals/GeolocationUpdateConfirmModal.svelte';
|
||||||
@@ -62,7 +63,7 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
timelineManager.upsertAssets(updatedAssets);
|
timelineManager.updateAssets(updatedAssets);
|
||||||
|
|
||||||
handleDeselectAll();
|
handleDeselectAll();
|
||||||
};
|
};
|
||||||
@@ -109,7 +110,17 @@
|
|||||||
return !!asset.latitude && !!asset.longitude;
|
return !!asset.latitude && !!asset.longitude;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAssetOpen = (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(() => {
|
||||||
@@ -117,9 +128,9 @@
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
location = { latitude: asset.latitude!, longitude: asset.longitude! };
|
||||||
void setQueryValue('at', asset.id);
|
void setQueryValue('at', asset.id);
|
||||||
return;
|
} else {
|
||||||
|
onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
|
||||||
}
|
}
|
||||||
defaultAssetOpen();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -182,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