From ac9ab276aa4f1ba7074b312dd5d4ab42b235c805 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Wed, 6 Aug 2025 23:57:33 -0400 Subject: [PATCH] request cancellation for ios --- .../app/alextran/immich/AppGlideModule.kt | 1 - .../alextran/immich/images/Thumbnails.g.kt | 45 ++- .../alextran/immich/images/ThumbnailsImpl.kt | 299 +++++++++--------- mobile/ios/Runner/Images/Thumbnails.g.swift | 39 ++- mobile/ios/Runner/Images/ThumbnailsImpl.swift | 89 +++++- mobile/lib/constants/constants.dart | 4 +- .../repositories/asset_media.repository.dart | 163 ++++++++-- mobile/lib/platform/thumbnail_api.g.dart | 58 +++- .../widgets/images/image_provider.dart | 20 +- .../widgets/images/local_image_provider.dart | 46 +-- .../widgets/images/remote_image_provider.dart | 74 +++-- .../widgets/images/thumbnail.widget.dart | 5 + .../widgets/images/thumbnail_tile.widget.dart | 4 +- mobile/pigeon/thumbnail_api.dart | 5 + 14 files changed, 628 insertions(+), 224 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt index d87ab3b63c..7b589cd80f 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/AppGlideModule.kt @@ -5,7 +5,6 @@ import com.bumptech.glide.GlideBuilder import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPoolAdapter import com.bumptech.glide.load.engine.cache.DiskCacheAdapter -import com.bumptech.glide.load.engine.cache.MemoryCache import com.bumptech.glide.load.engine.cache.MemoryCacheAdapter import com.bumptech.glide.module.AppGlideModule diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt index a70de4300b..7dbd654e9e 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/Thumbnails.g.kt @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// Autogenerated from Pigeon (v26.0.0), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -60,6 +60,8 @@ private open class ThumbnailsPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface ThumbnailApi { fun getThumbnailBuffer(assetId: String, width: Long, height: Long, callback: (Result>) -> Unit) + fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, callback: (Result>) -> Unit) + fun cancelImageRequest(requestId: Long) companion object { /** The codec used by ThumbnailApi. */ @@ -92,6 +94,47 @@ interface ThumbnailApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val assetIdArg = args[0] as String + val requestIdArg = args[1] as Long + val widthArg = args[2] as Long + val heightArg = args[3] as Long + api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg) { result: Result> -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(ThumbnailsPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(ThumbnailsPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val requestIdArg = args[0] as Long + val wrapped: List = try { + api.cancelImageRequest(requestIdArg) + listOf(null) + } catch (exception: Throwable) { + ThumbnailsPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt index 804cdcb3c1..c54ade651b 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/ThumbnailsImpl.kt @@ -19,161 +19,176 @@ import com.bumptech.glide.load.DecodeFormat import kotlin.time.TimeSource class ThumbnailsImpl(context: Context) : ThumbnailApi { - private val ctx: Context = context.applicationContext - private val contentResolver: ContentResolver = ctx.contentResolver - private val threadPool = - Executors.newFixedThreadPool(max(4, Runtime.getRuntime().availableProcessors())) + private val ctx: Context = context.applicationContext + private val contentResolver: ContentResolver = ctx.contentResolver + private val threadPool = + Executors.newFixedThreadPool(max(4, Runtime.getRuntime().availableProcessors())) - companion object { - val PROJECTION = arrayOf( - MediaStore.MediaColumns.DATE_MODIFIED, - MediaStore.Files.FileColumns.MEDIA_TYPE, - ) - const val SELECTION = "${MediaStore.MediaColumns._ID} = ?" - val URI: Uri = MediaStore.Files.getContentUri("external") + companion object { + val PROJECTION = arrayOf( + MediaStore.MediaColumns.DATE_MODIFIED, + MediaStore.Files.FileColumns.MEDIA_TYPE, + ) + const val SELECTION = "${MediaStore.MediaColumns._ID} = ?" + val URI: Uri = MediaStore.Files.getContentUri("external") - const val MEDIA_TYPE_IMAGE = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE - const val MEDIA_TYPE_VIDEO = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO + const val MEDIA_TYPE_IMAGE = MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE + const val MEDIA_TYPE_VIDEO = MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO - init { - System.loadLibrary("native_buffer") - } - - @JvmStatic - external fun allocateNative(size: Int): Long - - @JvmStatic - external fun freeNative(pointer: Long) - - @JvmStatic - external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer + init { + System.loadLibrary("native_buffer") } - override fun getThumbnailBuffer( - assetId: String, width: Long, height: Long, callback: (Result>) -> Unit - ) { - threadPool.execute { - try { - getThumbnailBufferInternal(assetId, width, height, callback) - } catch (e: Exception) { - callback(Result.failure(e)) - } - } + @JvmStatic + external fun allocateNative(size: Int): Long + + @JvmStatic + external fun freeNative(pointer: Long) + + @JvmStatic + external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer + } + + override fun requestImage( + assetId: String, + requestId: Long, + width: Long, + height: Long, + callback: (Result>) -> Unit + ) { + // TODO: Implement request cancellation + getThumbnailBuffer(assetId, width, height, callback) + } + + override fun cancelImageRequest(requestId: Long) { + // TODO: Implement request cancellation + } + + override fun getThumbnailBuffer( + assetId: String, width: Long, height: Long, callback: (Result>) -> Unit + ) { + threadPool.execute { + try { + getThumbnailBufferInternal(assetId, width, height, callback) + } catch (e: Exception) { + callback(Result.failure(e)) + } } + } - private fun getThumbnailBufferInternal( - assetId: String, width: Long, height: Long, callback: (Result>) -> Unit - ) { - val targetWidth = width.toInt() - val targetHeight = height.toInt() + private fun getThumbnailBufferInternal( + assetId: String, width: Long, height: Long, callback: (Result>) -> Unit + ) { + val targetWidth = width.toInt() + val targetHeight = height.toInt() - val cursor = contentResolver.query(URI, PROJECTION, SELECTION, arrayOf(assetId), null) - ?: return callback(Result.failure(RuntimeException("Asset not found"))) + val cursor = contentResolver.query(URI, PROJECTION, SELECTION, arrayOf(assetId), null) + ?: return callback(Result.failure(RuntimeException("Asset not found"))) - cursor.use { c -> - if (!c.moveToNext()) { - return callback(Result.failure(RuntimeException("Asset not found"))) - } + cursor.use { c -> + if (!c.moveToNext()) { + return callback(Result.failure(RuntimeException("Asset not found"))) + } - val mediaType = c.getInt(1) - val bitmap = when (mediaType) { - MEDIA_TYPE_IMAGE -> decodeImage(assetId, targetWidth, targetHeight) - MEDIA_TYPE_VIDEO -> decodeVideoThumbnail(assetId, targetWidth, targetHeight) - else -> return callback(Result.failure(RuntimeException("Unsupported media type"))) - } + val mediaType = c.getInt(1) + val bitmap = when (mediaType) { + MEDIA_TYPE_IMAGE -> decodeImage(assetId, targetWidth, targetHeight) + MEDIA_TYPE_VIDEO -> decodeVideoThumbnail(assetId, targetWidth, targetHeight) + else -> return callback(Result.failure(RuntimeException("Unsupported media type"))) + } - val actualWidth = bitmap.width - val actualHeight = bitmap.height + val actualWidth = bitmap.width + val actualHeight = bitmap.height - val size = actualWidth * actualHeight * 4 - val pointer = allocateNative(size) - try { - val buffer = wrapAsBuffer(pointer, size) - bitmap.copyPixelsToBuffer(buffer) - bitmap.recycle() - callback( - Result.success( - mapOf( - "pointer" to pointer, - "width" to actualWidth.toLong(), - "height" to actualHeight.toLong() - ) - ) - ) - } catch (e: Exception) { - freeNative(pointer) - callback(Result.failure(e)) - } - } - } - - private fun decodeImage(assetId: String, targetWidth: Int, targetHeight: Int): Bitmap { - val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, assetId.toLong()) - - if (targetHeight <= 768 && targetWidth <= 768) { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null) - } else { - Images.Thumbnails.getThumbnail( - contentResolver, - assetId.toLong(), - Images.Thumbnails.MINI_KIND, - BitmapFactory.Options().apply { - inPreferredConfig = Bitmap.Config.ARGB_8888 - } - ) - } - } - - return decodeSource(uri, targetWidth, targetHeight) - } - - private fun decodeVideoThumbnail(assetId: String, targetWidth: Int, targetHeight: Int): Bitmap { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, assetId.toLong()) - contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null) - } else { - Video.Thumbnails.getThumbnail( - contentResolver, - assetId.toLong(), - Video.Thumbnails.MINI_KIND, - BitmapFactory.Options().apply { - inPreferredConfig = Bitmap.Config.ARGB_8888 - } + val size = actualWidth * actualHeight * 4 + val pointer = allocateNative(size) + try { + val buffer = wrapAsBuffer(pointer, size) + bitmap.copyPixelsToBuffer(buffer) + bitmap.recycle() + callback( + Result.success( + mapOf( + "pointer" to pointer, + "width" to actualWidth.toLong(), + "height" to actualHeight.toLong() ) - } - } - - private fun decodeSource(uri: Uri, targetWidth: Int, targetHeight: Int): Bitmap { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val source = ImageDecoder.createSource(contentResolver, uri) - - ImageDecoder.decodeBitmap(source) { decoder, info, _ -> - val sampleSize = - getSampleSize(info.size.width, info.size.height, targetWidth, targetHeight) - decoder.setTargetSampleSize(sampleSize) - decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE - decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) - } - } else { - Glide.with(ctx) - .asBitmap() - .priority(Priority.IMMEDIATE) - .load(uri) - .disallowHardwareConfig() - .format(DecodeFormat.PREFER_ARGB_8888) - .submit(targetWidth, targetHeight).get() - } - } - - private fun getSampleSize(fullWidth: Int, fullHeight: Int, reqWidth: Int, reqHeight: Int): Int { - return 1 shl max( - 0, floor( - min( - log2(fullWidth / (2.0 * reqWidth)), - log2(fullHeight / (2.0 * reqHeight)), - ) - ).toInt() + ) ) + } catch (e: Exception) { + freeNative(pointer) + callback(Result.failure(e)) + } } + } + + private fun decodeImage(assetId: String, targetWidth: Int, targetHeight: Int): Bitmap { + val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, assetId.toLong()) + + if (targetHeight <= 768 && targetWidth <= 768) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null) + } else { + Images.Thumbnails.getThumbnail( + contentResolver, + assetId.toLong(), + Images.Thumbnails.MINI_KIND, + BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + ) + } + } + + return decodeSource(uri, targetWidth, targetHeight) + } + + private fun decodeVideoThumbnail(assetId: String, targetWidth: Int, targetHeight: Int): Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, assetId.toLong()) + contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null) + } else { + Video.Thumbnails.getThumbnail( + contentResolver, + assetId.toLong(), + Video.Thumbnails.MINI_KIND, + BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + ) + } + } + + private fun decodeSource(uri: Uri, targetWidth: Int, targetHeight: Int): Bitmap { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val source = ImageDecoder.createSource(contentResolver, uri) + + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val sampleSize = + getSampleSize(info.size.width, info.size.height, targetWidth, targetHeight) + decoder.setTargetSampleSize(sampleSize) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB)) + } + } else { + Glide.with(ctx) + .asBitmap() + .priority(Priority.IMMEDIATE) + .load(uri) + .disallowHardwareConfig() + .format(DecodeFormat.PREFER_ARGB_8888) + .submit(targetWidth, targetHeight).get() + } + } + + private fun getSampleSize(fullWidth: Int, fullHeight: Int, reqWidth: Int, reqHeight: Int): Int { + return 1 shl max( + 0, floor( + min( + log2(fullWidth / (2.0 * reqWidth)), + log2(fullHeight / (2.0 * reqHeight)), + ) + ).toInt() + ) + } } diff --git a/mobile/ios/Runner/Images/Thumbnails.g.swift b/mobile/ios/Runner/Images/Thumbnails.g.swift index 6fbf28a474..c5f866a5ef 100644 --- a/mobile/ios/Runner/Images/Thumbnails.g.swift +++ b/mobile/ios/Runner/Images/Thumbnails.g.swift @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// Autogenerated from Pigeon (v26.0.0), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -71,6 +71,8 @@ class ThumbnailsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol ThumbnailApi { func getThumbnailBuffer(assetId: String, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void) + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void) + func cancelImageRequest(requestId: Int64) throws } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -98,5 +100,40 @@ class ThumbnailApiSetup { } else { getThumbnailBufferChannel.setMessageHandler(nil) } + let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + requestImageChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let assetIdArg = args[0] as! String + let requestIdArg = args[1] as! Int64 + let widthArg = args[2] as! Int64 + let heightArg = args[3] as! Int64 + api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + requestImageChannel.setMessageHandler(nil) + } + let cancelImageRequestChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + cancelImageRequestChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestIdArg = args[0] as! Int64 + do { + try api.cancelImageRequest(requestId: requestIdArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + cancelImageRequestChannel.setMessageHandler(nil) + } } } diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift index b774572268..38bb69741f 100644 --- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift +++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift @@ -3,6 +3,12 @@ import Flutter import MobileCoreServices import Photos +struct Request { + var managerId: Int32? + var workItem: DispatchWorkItem? + var isCancelled = false +} + class ThumbnailApiImpl: ThumbnailApi { private static let cacheManager = PHImageManager.default() private static let fetchOptions = { @@ -22,6 +28,7 @@ class ThumbnailApiImpl: ThumbnailApi { private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB() private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue + private static var requests = [Int64: Request]() func getThumbnailBuffer(assetId: String, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { Self.processingQueue.async { @@ -39,7 +46,7 @@ class ThumbnailApiImpl: ThumbnailApi { completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) return } - + let pointer = UnsafeMutableRawPointer.allocate( byteCount: Int(cgImage.width) * Int(cgImage.height) * 4, alignment: MemoryLayout.alignment @@ -65,4 +72,84 @@ class ThumbnailApiImpl: ThumbnailApi { ) } } + + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { + var request = Request() + let item = DispatchWorkItem { + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject + else { completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))); return } + + request.managerId = Self.cacheManager.requestImage( + for: asset, + targetSize: CGSize(width: Double(width), height: Double(height)), + contentMode: .aspectFit, + options: Self.requestOptions, + resultHandler: { (image, info) -> Void in + defer { Self.requests[requestId] = nil } + guard let image = image, + let cgImage = image.cgImage else { + return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) + } + + if (request.isCancelled) { + return completion(.failure(PigeonError(code: "cancelled", message: nil, details: nil))) + } + + let pointer = UnsafeMutableRawPointer.allocate( + byteCount: Int(cgImage.width) * Int(cgImage.height) * 4, + alignment: MemoryLayout.alignment + ) + + if (request.isCancelled) { + pointer.deallocate() + return completion(.failure(PigeonError(code: "cancelled", message: nil, details: nil))) + } + + guard let context = CGContext( + data: pointer, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: 8, + bytesPerRow: cgImage.width * 4, + space: Self.rgbColorSpace, + bitmapInfo: Self.bitmapInfo + ) else { + pointer.deallocate() + return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil))) + } + + if (request.isCancelled) { + pointer.deallocate() + return completion(.failure(PigeonError(code: "cancelled", message: nil, details: nil))) + } + + context.interpolationQuality = .none + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) + + if (request.isCancelled) { + pointer.deallocate() + return completion(.failure(PigeonError(code: "cancelled", message: nil, details: nil))) + } + + completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) + } + ) + } + + request.workItem = item + Self.requests[requestId] = request + Self.processingQueue.async(execute: item) + } + + func cancelImageRequest(requestId: Int64) { + guard var request = Self.requests.removeValue(forKey: requestId) else { return } + request.isCancelled = true + if let item = request.workItem { + item.cancel() + } + + if let managerId = request.managerId { + Self.cacheManager.cancelImageRequest(managerId) + } + } } diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 52d4100dc1..3516a0e1c7 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -31,8 +31,8 @@ const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadOppositeSize = 64; const Size kTimelineThumbnailTileSize = Size.square(256.0); -const Size kTimelineThumbnailSize = Size.square(384.0); -const int kTimelineImageCacheMemory = 250 * 1024 * 1024; +const Size kTimelineThumbnailSize = Size.square(256.0); +const int kTimelineImageCacheMemory = 100 * 1024 * 1024; // Widget keys const String appShareGroupId = "group.app.immich.share"; diff --git a/mobile/lib/infrastructure/repositories/asset_media.repository.dart b/mobile/lib/infrastructure/repositories/asset_media.repository.dart index dd8cc244c8..e399fd21cb 100644 --- a/mobile/lib/infrastructure/repositories/asset_media.repository.dart +++ b/mobile/lib/infrastructure/repositories/asset_media.repository.dart @@ -1,45 +1,164 @@ +import 'dart:async'; import 'dart:ffi'; -import 'dart:typed_data'; -import 'dart:ui'; +import 'dart:io'; import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:ffi/ffi.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:logging/logging.dart'; -class AssetMediaRepository { - const AssetMediaRepository(); - Future getLocalThumbnail(String localId, ui.Size size) async { - final info = await thumbnailApi.getThumbnailBuffer(localId, width: size.width.toInt(), height: size.height.toInt()); +abstract class ImageRequest { + static int _nextRequestId = 0; + + final int requestId = _nextRequestId++; + bool _isCancelled = false; + + get isCancelled => _isCancelled; + + ImageRequest(); + + Future load(ImageDecoderCallback decode, {double scale = 1.0}); + + void cancel() { + if (isCancelled) { + return; + } + _isCancelled = true; + return _onCancelled(); + } + + void _onCancelled(); +} + +class LocalImageRequest extends ImageRequest { + final String localId; + final int width; + final int height; + + LocalImageRequest({required this.localId, required ui.Size size}) + : width = size.width.toInt(), + height = size.height.toInt(); + + @override + Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { + if (_isCancelled) { + return null; + } + + final Map info = await thumbnailApi.requestImage( + localId, + requestId: requestId, + width: width, + height: height, + ); final pointer = Pointer.fromAddress(info['pointer']!); - final actualWidth = info['width']!; - final actualHeight = info['height']!; - final actualSize = actualWidth * actualHeight * 4; - try { + if (_isCancelled) { + return null; + } + + final actualWidth = info['width']!; + final actualHeight = info['height']!; + final actualSize = actualWidth * actualHeight * 4; + final buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize)); + if (_isCancelled) { + return null; + } + final descriptor = ui.ImageDescriptor.raw( buffer, width: actualWidth, height: actualHeight, pixelFormat: ui.PixelFormat.rgba8888, ); - return await descriptor.instantiateCodec(); + final codec = await descriptor.instantiateCodec(); + if (_isCancelled) { + return null; + } + + final frame = await codec.getNextFrame(); + if (_isCancelled) { + return null; + } + + return ImageInfo(image: frame.image, scale: scale); } finally { malloc.free(pointer); } } - Future getThumbnail(String id, {int quality = 80, ui.Size size = const ui.Size.square(256)}) => - AssetEntity( - id: id, - // The below fields are not used in thumbnailDataWithSize but are required - // to create an AssetEntity instance. It is faster to create a dummy AssetEntity - // instance than to fetch the asset from the device first. - typeInt: AssetType.image.index, - width: size.width.toInt(), - height: size.height.toInt(), - ).thumbnailDataWithSize(ThumbnailSize(size.width.toInt(), size.height.toInt()), quality: quality); + @override + Future _onCancelled() { + return thumbnailApi.cancelImageRequest(requestId); + } +} + +class RemoteImageRequest extends ImageRequest { + static final log = Logger('RemoteImageRequest'); + static final cacheManager = RemoteImageCacheManager(); + static final client = HttpClient(); + String uri; + Map headers; + HttpClientRequest? _request; + + RemoteImageRequest({required this.uri, required this.headers}); + + @override + Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { + final headers = ApiService.getRequestHeaders(); + final file = await cacheManager.getFileFromCache(uri); + if (file != null) { + final buffer = await ImmutableBuffer.fromFilePath(file.file.path); + final codec = await decode(buffer); + final frame = await codec.getNextFrame(); + return ImageInfo(image: frame.image, scale: scale); + } + + final request = _request = await client.getUrl(Uri.parse(uri)); + try { + for (final entry in headers.entries) { + request.headers.set(entry.key, entry.value); + } + final response = await request.close(); + final bytes = await consolidateHttpClientResponseBytes(response); + cacheManager.putFile(uri, bytes).catchError((e) => log.severe('Failed to cache image', e)); + if (_isCancelled) { + return null; + } + final buffer = await ImmutableBuffer.fromUint8List(bytes); + if (_isCancelled) { + return null; + } + final codec = await decode(buffer); + if (_isCancelled) { + return null; + } + final frame = await codec.getNextFrame(); + if (_isCancelled) { + return null; + } + return ImageInfo(image: frame.image, scale: scale); + } catch (e) { + if (e is HttpException && e.message.endsWith('aborted')) { + return null; + } + rethrow; + } finally { + _request = null; + } + } + + @override + void _onCancelled() { + _request?.abort(); + _request = null; + } } diff --git a/mobile/lib/platform/thumbnail_api.g.dart b/mobile/lib/platform/thumbnail_api.g.dart index de598d3411..fc3a60c5b4 100644 --- a/mobile/lib/platform/thumbnail_api.g.dart +++ b/mobile/lib/platform/thumbnail_api.g.dart @@ -1,4 +1,4 @@ -// Autogenerated from Pigeon (v25.3.2), do not edit directly. +// Autogenerated from Pigeon (v26.0.0), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -76,4 +76,60 @@ class ThumbnailApi { return (pigeonVar_replyList[0] as Map?)!.cast(); } } + + Future> requestImage( + String assetId, { + required int requestId, + required int width, + required int height, + }) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ThumbnailApi.requestImage$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([assetId, requestId, width, height]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as Map?)!.cast(); + } + } + + Future cancelImageRequest(int requestId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.immich_mobile.ThumbnailApi.cancelImageRequest$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([requestId]); + final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 0b45e53405..b428937bc0 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -1,11 +1,29 @@ import 'package:flutter/widgets.dart'; -import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; + +abstract class CancellableImageProvider { + void cancel(); +} + +mixin class CancellableImageProviderMixin implements CancellableImageProvider { + ImageRequest? request; + + @override + void cancel() { + final request = this.request; + if (request == null) { + return; + } + this.request = null; + return request.cancel(); + } +} ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) { // Create new provider and cache it diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 32a79be616..2671c11f75 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -8,13 +8,11 @@ import 'package:immich_mobile/infrastructure/repositories/asset_media.repository import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; -class LocalThumbProvider extends ImageProvider { - static const _assetMediaRepository = AssetMediaRepository(); - +class LocalThumbProvider extends ImageProvider with CancellableImageProviderMixin { final String id; final Size size; - const LocalThumbProvider({required this.id, this.size = kTimelineThumbnailSize}); + LocalThumbProvider({required this.id, this.size = kTimelineThumbnailSize}); @override Future obtainKey(ImageConfiguration configuration) { @@ -23,8 +21,8 @@ class LocalThumbProvider extends ImageProvider { @override ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) { - return OneFrameImageStreamCompleter( - _codec(key), + return OneFramePlaceholderImageStreamCompleter( + _codec(key, decode), informationCollector: () => [ DiagnosticsProperty('Id', key.id), DiagnosticsProperty('Size', key.size), @@ -32,9 +30,16 @@ class LocalThumbProvider extends ImageProvider { ); } - Future _codec(LocalThumbProvider key) async { - final codec = await _assetMediaRepository.getLocalThumbnail(key.id, key.size); - return ImageInfo(image: (await codec.getNextFrame()).image, scale: 1.0); + Stream _codec(LocalThumbProvider key, ImageDecoderCallback decode) async* { + final request = this.request = LocalImageRequest(localId: key.id, size: size); + try { + final image = await request.load(decode); + if (image != null) { + yield image; + } + } finally { + this.request = null; + } } @override @@ -50,13 +55,11 @@ class LocalThumbProvider extends ImageProvider { int get hashCode => id.hashCode ^ size.hashCode; } -class LocalFullImageProvider extends ImageProvider { - static const _assetMediaRepository = AssetMediaRepository(); - +class LocalFullImageProvider extends ImageProvider with CancellableImageProviderMixin { final String id; final Size size; - const LocalFullImageProvider({required this.id, required this.size}); + LocalFullImageProvider({required this.id, required this.size}); @override Future obtainKey(ImageConfiguration configuration) { @@ -78,12 +81,19 @@ class LocalFullImageProvider extends ImageProvider { Stream _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* { final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; - final codec = await _assetMediaRepository.getLocalThumbnail( - key.id, - Size(size.width * devicePixelRatio, size.height * devicePixelRatio), + final request = this.request = LocalImageRequest( + localId: key.id, + size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio), ); - final frame = await codec.getNextFrame(); - yield ImageInfo(image: frame.image, scale: 1.0); + + try { + final image = await request.load(decode); + if (image != null) { + yield image; + } + } finally { + this.request = null; + } } @override diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index d9ab78053f..8a51e0ad77 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -1,22 +1,22 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; -import 'package:immich_mobile/providers/image/cache/image_loader.dart'; import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; -class RemoteThumbProvider extends ImageProvider { +class RemoteThumbProvider extends ImageProvider with CancellableImageProviderMixin { final String assetId; final CacheManager? cacheManager; - const RemoteThumbProvider({required this.assetId, this.cacheManager}); + RemoteThumbProvider({required this.assetId, this.cacheManager}); @override Future obtainKey(ImageConfiguration configuration) { @@ -26,9 +26,8 @@ class RemoteThumbProvider extends ImageProvider { @override ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) { final cache = cacheManager ?? RemoteImageCacheManager(); - return MultiFrameImageStreamCompleter( - codec: _codec(key, cache, decode), - scale: 1.0, + return OneFramePlaceholderImageStreamCompleter( + _codec(key, cache, decode), informationCollector: () => [ DiagnosticsProperty('Image provider', this), DiagnosticsProperty('Asset Id', key.assetId), @@ -36,10 +35,17 @@ class RemoteThumbProvider extends ImageProvider { ); } - Future _codec(RemoteThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async { + Stream _codec(RemoteThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async* { final preview = getThumbnailUrlForRemoteId(key.assetId); - - return ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode); + final request = this.request = RemoteImageRequest(uri: preview, headers: ApiService.getRequestHeaders()); + try { + final image = await request.load(decode); + if (image != null) { + yield image; + } + } finally { + this.request = null; + } } @override @@ -56,11 +62,11 @@ class RemoteThumbProvider extends ImageProvider { int get hashCode => assetId.hashCode; } -class RemoteFullImageProvider extends ImageProvider { +class RemoteFullImageProvider extends ImageProvider with CancellableImageProviderMixin { final String assetId; final CacheManager? cacheManager; - const RemoteFullImageProvider({required this.assetId, this.cacheManager}); + RemoteFullImageProvider({required this.assetId, this.cacheManager}); @override Future obtainKey(ImageConfiguration configuration) { @@ -81,27 +87,33 @@ class RemoteFullImageProvider extends ImageProvider { } Stream _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* { - ImageInfo? imageInfo; - final originalImageFuture = AppSetting.get(Setting.loadOriginal) - ? ImageLoader.loadImageFromCache( - getOriginalUrlForRemoteId(key.assetId), - cache: cache, - decode: decode, - ).then((image) => image.getNextFrame()).then((frame) => imageInfo = ImageInfo(image: frame.image, scale: 1.0)) - : null; - - final previewImageFuture = - ImageLoader.loadImageFromCache(getPreviewUrlForRemoteId(key.assetId), cache: cache, decode: decode) - .then((image) async => imageInfo == null ? await image.getNextFrame() : null) - .then((frame) => imageInfo == null ? ImageInfo(image: frame!.image, scale: 1.0) : null); - - final previewImage = await previewImageFuture; - if (previewImage != null) { - yield previewImage; + try { + final request = this.request = RemoteImageRequest( + uri: getPreviewUrlForRemoteId(key.assetId), + headers: ApiService.getRequestHeaders(), + ); + final image = await request.load(decode); + if (image == null) { + return; + } + yield image; + } finally { + request = null; } - if (originalImageFuture != null) { - yield await originalImageFuture; + if (AppSetting.get(Setting.loadOriginal)) { + try { + final request = this.request = RemoteImageRequest( + uri: getOriginalUrlForRemoteId(key.assetId), + headers: ApiService.getRequestHeaders(), + ); + final image = await request.load(decode); + if (image != null) { + yield image; + } + } finally { + request = null; + } } } diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index b6724c0a00..7ffbb6a9c8 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:logging/logging.dart'; @@ -226,6 +227,10 @@ class _ThumbnailState extends State { void dispose() { _stopListeningToStream(); _providerImage?.dispose(); + final imageProvider = widget.imageProvider; + if (imageProvider is CancellableImageProvider) { + (imageProvider as CancellableImageProvider).cancel(); + } super.dispose(); } } diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 11ec6ca430..1634a1df5d 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -56,8 +56,6 @@ class ThumbnailTile extends ConsumerWidget { ) : const BoxDecoration(); - final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null; - final bool storageIndicator = showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))); @@ -86,7 +84,7 @@ class ThumbnailTile extends ConsumerWidget { ), ), ), - if (hasStack) + if (asset is RemoteAsset && asset.stackId != null) asset.isVideo ? const Align( alignment: Alignment.topRight, diff --git a/mobile/pigeon/thumbnail_api.dart b/mobile/pigeon/thumbnail_api.dart index 2072c14b11..710f545781 100644 --- a/mobile/pigeon/thumbnail_api.dart +++ b/mobile/pigeon/thumbnail_api.dart @@ -20,4 +20,9 @@ abstract class ThumbnailApi { required int width, required int height, }); + + @async + Map requestImage(String assetId, {required int requestId, required int width, required int height}); + + void cancelImageRequest(int requestId); }