Compare commits

..

4 Commits

Author SHA1 Message Date
shenlong-tanwen
f632e4f666 review changes 2025-09-29 05:46:09 +05:30
shenlong-tanwen
0e8492ceba trailing closures in swift 2025-09-29 03:38:29 +05:30
shenlong-tanwen
13abe14142 async ios 2025-09-29 00:10:34 +05:30
shenlong-tanwen
ae595f2947 fix: android - execute in background 2025-09-29 00:10:34 +05:30
31 changed files with 578 additions and 528 deletions

View File

@@ -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)
} }
} }

View File

@@ -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() })
}
}

View File

@@ -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")
} }
} }

View File

@@ -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)

View File

@@ -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
} }
} }

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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.")))
}
} }

View File

@@ -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>()

View File

@@ -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(

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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([]))
} }
} }

View 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() })
}
}

View File

@@ -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 {

View File

@@ -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 []

View File

@@ -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();
} }

View File

@@ -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();
} }

View File

@@ -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();
} }

View File

@@ -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});

View File

@@ -3,7 +3,7 @@
import type { ScrubberMonth } from '$lib/managers/timeline-manager/types'; import type { ScrubberMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getTabbable } from '$lib/utils/focus-util'; import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener, 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;
} }
} }

View File

@@ -1,11 +1,17 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer'; import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AbsoluteResult, RelativeResult } from '$lib/components/shared-components/change-date.svelte';
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte'; import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte'; import {
import { AssetAction } from '$lib/constants'; setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { AppRoute, AssetAction } from '$lib/constants';
import HotModuleReload from '$lib/elements/HotModuleReload.svelte'; import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte'; import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte'; import Skeleton from '$lib/elements/Skeleton.svelte';
@@ -15,17 +21,25 @@
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 { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; 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, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { getTimes, toTimelineAsset, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk'; import { AssetVisibility, getAssetInfo, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite'; import type { UpdatePayload } from 'vite';
import DeleteAssetDialog from '../photos-page/delete-asset-dialog.svelte';
import TimelineDateGroup from './TimelineDateGroup.svelte'; import TimelineDateGroup from './TimelineDateGroup.svelte';
interface Props { interface Props {
@@ -95,21 +109,16 @@
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true); let showSkeleton = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport. let isShowSelectDate = $state(false);
// Note: There may be multiple months visible within the viewport at any given time. let scrubberMonthPercent = $state(0);
let viewportTopMonthScrollPercent = $state(0); let scrubberMonth: { year: number; month: number } | undefined = $state(undefined);
// The timeline month intersecting the top position of the viewport let scrubOverallPercent: number = $state(0);
let viewportTopMonth: { year: number; month: number } | undefined = $state(undefined);
// Overall scroll percentage through the entire timeline (0-1)
let timelineScrollPercent: number = $state(0);
let scrubberWidth = $state(0); let scrubberWidth = $state(0);
// 60 is the bottom spacer element at 60px // 60 is the bottom spacer element at 60px
let bottomSectionHeight = 60; let bottomSectionHeight = 60;
// Indicates whether the viewport is currently in the lead-out section (after all months) let leadout = $state(false);
let isInLeadOutSection = $state(false);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mobileDevice.maxMd); const maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse); const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
@@ -292,19 +301,20 @@
scrollTop(scrollToTop); scrollTop(scrollToTop);
}; };
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
// this function scrolls the timeline to the specified month group and offset, based on scrubber interaction const onScrub: ScrubberListener = (
const onScrub: ScrubberListener = (scrubberData) => { scrubMonth: { year: number; month: number },
const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent } = scrubberData; overallScrollPercent: number,
scrubberMonthScrollPercent: number,
if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { ) => {
if (!scrubMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
const offset = maxScroll * overallScrollPercent; const offset = maxScroll * overallScrollPercent;
scrollTop(offset); scrollTop(offset);
} else { } else {
const monthGroup = timelineManager.months.find( const monthGroup = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, ({ yearMonth: { year, month } }) => year === scrubMonth.year && month === scrubMonth.month,
); );
if (!monthGroup) { if (!monthGroup) {
return; return;
@@ -315,7 +325,7 @@
// note: don't throttle, debounch, or otherwise make this function async - it causes flicker // note: don't throttle, debounch, or otherwise make this function async - it causes flicker
const handleTimelineScroll = () => { const handleTimelineScroll = () => {
isInLeadOutSection = false; leadout = false;
if (!element) { if (!element) {
return; return;
@@ -324,19 +334,19 @@
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
viewportTopMonth = undefined; scrubberMonth = undefined;
viewportTopMonthScrollPercent = 0; scrubberMonthPercent = 0;
} else { } else {
let top = element.scrollTop; let top = element.scrollTop;
if (top < timelineManager.topSectionHeight) { if (top < timelineManager.topSectionHeight) {
// in the lead-in area // in the lead-in area
viewportTopMonth = undefined; scrubberMonth = undefined;
viewportTopMonthScrollPercent = 0; scrubberMonthPercent = 0;
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); scrubOverallPercent = Math.min(1, element.scrollTop / maxScroll);
return; return;
} }
@@ -361,15 +371,15 @@
let next = top - monthGroupHeight * maxScrollPercent; let next = top - monthGroupHeight * maxScrollPercent;
// instead of checking for < 0, add a little wiggle room for subpixel resolution // instead of checking for < 0, add a little wiggle room for subpixel resolution
if (next < -1 && monthGroup) { if (next < -1 && monthGroup) {
viewportTopMonth = monthGroup; scrubberMonth = monthGroup;
// allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage // allowing next to be at least 1 may cause percent to go negative, so ensure positive percentage
viewportTopMonthScrollPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent)); scrubberMonthPercent = Math.max(0, top / (monthGroupHeight * maxScrollPercent));
// compensate for lost precision/rounding errors advance to the next bucket, if present // compensate for lost precision/rounding errors advance to the next bucket, if present
if (viewportTopMonthScrollPercent > 0.9999 && i + 1 < monthsLength - 1) { if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) {
viewportTopMonth = timelineManager.months[i + 1].yearMonth; scrubberMonth = timelineManager.months[i + 1].yearMonth;
viewportTopMonthScrollPercent = 0; scrubberMonthPercent = 0;
} }
found = true; found = true;
@@ -378,14 +388,61 @@
top = next; top = next;
} }
if (!found) { if (!found) {
isInLeadOutSection = true; leadout = true;
viewportTopMonth = undefined; scrubberMonth = undefined;
viewportTopMonthScrollPercent = 0; scrubberMonthPercent = 0;
timelineScrollPercent = 1; scrubOverallPercent = 1;
} }
} }
}; };
const trashOrDelete = async (force: boolean = false) => {
isShowDeleteConfirmation = false;
await deleteAssets(
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
);
assetInteraction.clearMultiselect();
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const onForceDelete = () => {
if ($showDeleteModal) {
isShowDeleteConfirmation = true;
return;
}
handlePromiseError(trashOrDelete(true));
};
const onStackAssets = async () => {
const result = await stackAssets(assetInteraction.selectedAssets);
updateStackedAssetInTimeline(timelineManager, result);
onEscape();
};
const toggleArchive = async () => {
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
timelineManager.updateAssetOperation(ids, (asset) => {
asset.visibility = visibility;
return { remove: false };
});
deselectAllAssets();
};
const handleSelectAsset = (asset: TimelineAsset) => { const handleSelectAsset = (asset: TimelineAsset) => {
if (!timelineManager.albumAssets.has(asset.id)) { if (!timelineManager.albumAssets.has(asset.id)) {
assetInteraction.selectAsset(asset); assetInteraction.selectAsset(asset);
@@ -516,7 +573,15 @@
let shiftKeyIsDown = $state(false); let shiftKeyIsDown = $state(false);
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') { if (event.key === 'Shift') {
event.preventDefault(); event.preventDefault();
shiftKeyIsDown = true; shiftKeyIsDown = true;
@@ -524,11 +589,16 @@
}; };
const onKeyUp = (event: KeyboardEvent) => { const onKeyUp = (event: KeyboardEvent) => {
if (searchStore.isSearchEnabled) {
return;
}
if (event.key === 'Shift') { if (event.key === 'Shift') {
event.preventDefault(); event.preventDefault();
shiftKeyIsDown = false; shiftKeyIsDown = false;
} }
}; };
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => { const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) { if (asset) {
void selectAssetCandidates(asset); void selectAssetCandidates(asset);
@@ -654,6 +724,72 @@
assetInteraction.setAssetSelectionCandidates(assets); assetInteraction.setAssetSelectionCandidates(assets);
}; };
const onSelectStart = (e: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
e.preventDefault();
}
};
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
let isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
let idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
$effect(() => {
if (isEmpty) {
assetInteraction.clearMultiselect();
}
});
const setFocusTo = setFocusToInit.bind(undefined, scrollToAsset, timelineManager);
const setFocusAsset = setFocusAssetInit.bind(undefined, scrollToAsset);
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: 'Escape' }, onShortcut: onEscape },
{ 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 (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;
})(),
);
$effect(() => { $effect(() => {
if (!lastAssetMouseEvent) { if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates(); assetInteraction.clearAssetSelectionCandidates();
@@ -680,17 +816,37 @@
}); });
</script> </script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} /> <svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
<HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} /> <HotModuleReload onAfterUpdate={handleAfterUpdate} onBeforeUpdate={handleBeforeUpdate} />
<TimelineKeyboardActions {#if isShowDeleteConfirmation}
scrollToAsset={(asset) => scrollToAsset(asset) ?? false} <DeleteAssetDialog
{timelineManager} size={idsSelectedAssets.length}
{assetInteraction} onCancel={() => (isShowDeleteConfirmation = false)}
bind:isShowDeleteConfirmation onConfirm={() => handlePromiseError(trashOrDelete(true))}
{onEscape} />
/> {/if}
{#if isShowSelectDate}
<ChangeDate
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) {
setFocusAsset(asset);
}
}
}}
onCancel={() => (isShowSelectDate = false)}
/>
{/if}
{#if timelineManager.months.length > 0} {#if timelineManager.months.length > 0}
<Scrubber <Scrubber
@@ -698,10 +854,10 @@
height={timelineManager.viewportHeight} height={timelineManager.viewportHeight}
timelineTopOffset={timelineManager.topSectionHeight} timelineTopOffset={timelineManager.topSectionHeight}
timelineBottomOffset={bottomSectionHeight} timelineBottomOffset={bottomSectionHeight}
{isInLeadOutSection} {leadout}
{timelineScrollPercent} {scrubOverallPercent}
{viewportTopMonthScrollPercent} {scrubberMonthPercent}
{viewportTopMonth} {scrubberMonth}
{onScrub} {onScrub}
bind:scrubberWidth bind:scrubberWidth
onScrubKeyDown={(evt) => { onScrubKeyDown={(evt) => {

View File

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

View File

@@ -23,11 +23,11 @@ export type TimelineDateTime = TimelineDate & {
millisecond: number; millisecond: number;
}; };
export type ScrubberListener = (scrubberData: { export type ScrubberListener = (
scrubberMonth: { year: number; month: number }; scrubberMonth: { year: number; month: number },
overallScrollPercent: number; overallScrollPercent: number,
scrubberMonthScrollPercent: number; scrubberMonthScrollPercent: number,
}) => void | Promise<void>; ) => void | Promise<void>;
// used for AssetResponseDto.dateTimeOriginal, amongst others // used for AssetResponseDto.dateTimeOriginal, amongst others
export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> => export const fromISODateTime = (isoDateTime: string, timeZone: string): DateTime<true> =>