light at the end of the tunnel

This commit is contained in:
mertalev 2025-07-23 20:08:48 +03:00
parent 85b8ccc911
commit b13bd8df98
No known key found for this signature in database
GPG Key ID: DF6ABC77AAD98C95
23 changed files with 563 additions and 572 deletions

View File

@ -1,13 +1,52 @@
#include <jni.h> #include <jni.h>
#include <stdlib.h>
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_allocateNative(
JNIEnv *env, jclass clazz, jint size)
{
void *ptr = malloc(size);
return (jlong)ptr;
}
JNIEXPORT jlong JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_allocateNative(
JNIEnv *env, jclass clazz, jint size)
{
void *ptr = malloc(size);
return (jlong)ptr;
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_freeNative(
JNIEnv *env, jclass clazz, jlong address)
{
if (address != 0)
{
free((void *)address);
}
}
JNIEXPORT void JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_freeNative(
JNIEnv *env, jclass clazz, jlong address)
{
if (address != 0)
{
free((void *)address);
}
}
JNIEXPORT jobject JNICALL JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapPointer( Java_app_alextran_immich_images_ThumbnailsImpl_00024Companion_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) { JNIEnv *env, jclass clazz, jlong address, jint capacity)
{
return (*env)->NewDirectByteBuffer(env, (void*)address, capacity); return (*env)->NewDirectByteBuffer(env, (void*)address, capacity);
} }
JNIEXPORT jobject JNICALL JNIEXPORT jobject JNICALL
Java_app_alextran_immich_images_ThumbnailsImpl_wrapPointer( Java_app_alextran_immich_images_ThumbnailsImpl_wrapAsBuffer(
JNIEnv *env, jclass clazz, jlong address, jint capacity) { JNIEnv *env, jclass clazz, jlong address, jint capacity)
{
return (*env)->NewDirectByteBuffer(env, (void*)address, capacity); return (*env)->NewDirectByteBuffer(env, (void*)address, capacity);
} }

View File

@ -1,7 +1,21 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.annotation.GlideModule 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 import com.bumptech.glide.module.AppGlideModule
@GlideModule @GlideModule
class AppGlideModule : AppGlideModule() class AppGlideModule : AppGlideModule() {
override fun applyOptions(context: Context, builder: GlideBuilder) {
super.applyOptions(context, builder)
// disable caching as this is already done on the Flutter side
builder.setMemoryCache(MemoryCacheAdapter())
builder.setDiskCache(DiskCacheAdapter.Factory())
builder.setBitmapPool(BitmapPoolAdapter())
}
}

View File

@ -59,7 +59,7 @@ private open class ThumbnailsPigeonCodec : 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 ThumbnailApi { interface ThumbnailApi {
fun setThumbnailToBuffer(pointer: Long, assetId: String, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit) fun setThumbnailToBuffer(assetId: String, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit)
companion object { companion object {
/** The codec used by ThumbnailApi. */ /** The codec used by ThumbnailApi. */
@ -75,11 +75,10 @@ interface ThumbnailApi {
if (api != null) { if (api != null) {
channel.setMessageHandler { message, reply -> channel.setMessageHandler { message, reply ->
val args = message as List<Any?> val args = message as List<Any?>
val pointerArg = args[0] as Long val assetIdArg = args[0] as String
val assetIdArg = args[1] as String val widthArg = args[1] as Long
val widthArg = args[2] as Long val heightArg = args[2] as Long
val heightArg = args[3] as Long api.setThumbnailToBuffer(assetIdArg, widthArg, heightArg) { result: Result<Map<String, Long>> ->
api.setThumbnailToBuffer(pointerArg, assetIdArg, widthArg, heightArg) { result: Result<Map<String, Long>> ->
val error = result.exceptionOrNull() val error = result.exceptionOrNull()
if (error != null) { if (error != null) {
reply.reply(ThumbnailsPigeonUtils.wrapError(error)) reply.reply(ThumbnailsPigeonUtils.wrapError(error))

View File

@ -4,15 +4,19 @@ import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.graphics.* import android.graphics.*
import android.media.MediaMetadataRetriever
import android.media.ThumbnailUtils
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.MediaStore.Images
import android.provider.MediaStore.Video
import android.util.Size import android.util.Size
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.max import kotlin.math.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
import com.bumptech.glide.load.DecodeFormat
import kotlin.time.TimeSource
class ThumbnailsImpl(context: Context) : ThumbnailApi { class ThumbnailsImpl(context: Context) : ThumbnailApi {
private val ctx: Context = context.applicationContext private val ctx: Context = context.applicationContext
@ -36,122 +40,140 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
} }
@JvmStatic @JvmStatic
external fun wrapPointer(address: Long, capacity: Int): ByteBuffer external fun allocateNative(size: Int): Long
@JvmStatic
external fun freeNative(pointer: Long)
@JvmStatic
external fun wrapAsBuffer(address: Long, capacity: Int): ByteBuffer
} }
override fun setThumbnailToBuffer( override fun setThumbnailToBuffer(
pointer: Long, assetId: String, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit
assetId: String,
width: Long,
height: Long,
callback: (Result<Unit>) -> Unit
) { ) {
threadPool.execute { threadPool.execute {
try { try {
val targetWidth = width.toInt() setThumbnailToBufferInternal(assetId, width, height, callback)
val targetHeight = height.toInt()
val cursor = contentResolver.query(URI, PROJECTION, SELECTION, arrayOf(assetId), null)
?: return@execute callback(Result.failure(RuntimeException("Asset not found")))
cursor.use { c ->
if (!c.moveToNext()) {
return@execute callback(Result.failure(RuntimeException("Asset not found")))
}
val mediaType = c.getInt(1)
val bitmap = when (mediaType) {
MEDIA_TYPE_IMAGE -> decodeImageThumbnail(assetId, targetWidth, targetHeight)
MEDIA_TYPE_VIDEO -> decodeVideoThumbnail(assetId, targetWidth, targetHeight)
else -> return@execute callback(Result.failure(RuntimeException("Unsupported media type")))
}
val croppedBitmap = ThumbnailUtils.extractThumbnail(
bitmap,
targetWidth,
targetHeight,
ThumbnailUtils.OPTIONS_RECYCLE_INPUT
)
val buffer = wrapPointer(pointer, (width * height * 4).toInt())
croppedBitmap.copyPixelsToBuffer(buffer)
croppedBitmap.recycle()
callback(Result.success(Unit))
}
} catch (e: Exception) { } catch (e: Exception) {
callback(Result.failure(e)) callback(Result.failure(e))
} }
} }
} }
private fun decodeImageThumbnail(assetId: String, targetWidth: Int, targetHeight: Int): Bitmap { private fun setThumbnailToBufferInternal(
val uri = assetId: String, width: Long, height: Long, callback: (Result<Map<String, Long>>) -> Unit
ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, assetId.toLong()) ) {
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")))
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 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) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null) contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null)
} else { } else {
decodeSampledBitmap(uri, targetWidth, targetHeight) 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 { private fun decodeVideoThumbnail(assetId: String, targetWidth: Int, targetHeight: Int): Bitmap {
val uri = return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, assetId.toLong()) val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, assetId.toLong())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null)
return contentResolver.loadThumbnail(uri, Size(targetWidth, targetHeight), null)
}
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(ctx, uri)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
retriever.getScaledFrameAtTime(
0L,
MediaMetadataRetriever.OPTION_NEXT_SYNC,
targetWidth,
targetHeight
)
} else { } else {
retriever.getFrameAtTime(0L) Video.Thumbnails.getThumbnail(
} ?: throw RuntimeException("Failed to extract video frame") contentResolver,
} finally { assetId.toLong(),
retriever.release() Video.Thumbnails.MINI_KIND,
} BitmapFactory.Options().apply {
}
private fun decodeSampledBitmap(uri: Uri, targetWidth: Int, targetHeight: Int): Bitmap {
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
}
options.apply {
inSampleSize = getSampleSize(this, targetWidth, targetHeight)
inJustDecodeBounds = false
inPreferredConfig = Bitmap.Config.ARGB_8888 inPreferredConfig = Bitmap.Config.ARGB_8888
} }
)
return contentResolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, options)
} ?: throw RuntimeException("Failed to decode bitmap")
}
private fun getSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
val height = options.outHeight
val width = options.outWidth
var inSampleSize = 1
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
} }
} }
return inSampleSize 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()
)
} }
} }

View File

@ -70,7 +70,7 @@ class ThumbnailsPigeonCodec: 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 ThumbnailApi { protocol ThumbnailApi {
func setThumbnailToBuffer(pointer: Int64, assetId: String, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void) func setThumbnailToBuffer(assetId: String, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], Error>) -> Void)
} }
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@ -83,11 +83,10 @@ class ThumbnailApiSetup {
if let api = api { if let api = api {
setThumbnailToBufferChannel.setMessageHandler { message, reply in setThumbnailToBufferChannel.setMessageHandler { message, reply in
let args = message as! [Any?] let args = message as! [Any?]
let pointerArg = args[0] as! Int64 let assetIdArg = args[0] as! String
let assetIdArg = args[1] as! String let widthArg = args[1] as! Int64
let widthArg = args[2] as! Int64 let heightArg = args[2] as! Int64
let heightArg = args[3] as! Int64 api.setThumbnailToBuffer(assetId: assetIdArg, width: widthArg, height: heightArg) { result in
api.setThumbnailToBuffer(pointer: pointerArg, assetId: assetIdArg, width: widthArg, height: heightArg) { result in
switch result { switch result {
case .success(let res): case .success(let res):
reply(wrapResult(res)) reply(wrapResult(res))

View File

@ -14,7 +14,7 @@ class ThumbnailApiImpl: ThumbnailApi {
let requestOptions = PHImageRequestOptions() let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .exact requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true requestOptions.isSynchronous = true
requestOptions.version = .current requestOptions.version = .current
return requestOptions return requestOptions
@ -23,31 +23,44 @@ class ThumbnailApiImpl: ThumbnailApi {
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB() private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
func setThumbnailToBuffer(pointer: Int64, assetId: String, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { func setThumbnailToBuffer(assetId: String, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
guard let bufferPointer = UnsafeMutableRawPointer(bitPattern: Int(pointer))
else { completion(.failure(PigeonError(code: "", message: "Could not get buffer pointer for \(assetId)", details: nil))); return }
Self.processingQueue.async { Self.processingQueue.async {
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject 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 } else { completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))); return }
Self.cacheManager.requestImage( Self.cacheManager.requestImage(
for: asset, for: asset,
targetSize: CGSize(width: Double(width), height: Double(height)), targetSize: CGSize(width: Double(width), height: Double(height)),
contentMode: .aspectFill, contentMode: .aspectFit,
options: Self.requestOptions, options: Self.requestOptions,
resultHandler: { (image, info) -> Void in resultHandler: { (image, info) -> Void in
guard let image = image, guard let image = image,
let cgImage = image.cgImage, let cgImage = image.cgImage else {
let context = CGContext( completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
data: bufferPointer, return
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
guard let context = CGContext(
data: pointer,
width: cgImage.width, width: cgImage.width,
height: cgImage.height, height: cgImage.height,
bitsPerComponent: 8, bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4, bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace, space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo bitmapInfo: Self.bitmapInfo
) else { completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))); return } ) else {
pointer.deallocate()
completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
return
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
completion(.success(["width": Int64(cgImage.width), "height": Int64(cgImage.height)])) completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
} }
) )
} }

View File

@ -28,6 +28,9 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
const int kTimelineNoneSegmentSize = 120; const int kTimelineNoneSegmentSize = 120;
const int kTimelineAssetLoadBatchSize = 1024; const int kTimelineAssetLoadBatchSize = 1024;
const int kTimelineAssetLoadOppositeSize = 64; const int kTimelineAssetLoadOppositeSize = 64;
const double kTimelineThumbnailTileSize = 256.0;
const double kTimelineThumbnailSize = 384.0;
const int kTimelineImageCacheMemory = 250 * 1024 * 1024;
// Widget keys // Widget keys
const String kWidgetAuthToken = "widget_auth_token"; const String kWidgetAuthToken = "widget_auth_token";

View File

@ -1,12 +1,48 @@
import 'dart:ffi';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui'; import 'dart:ui';
import 'dart:ui' as ui;
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:ffi/ffi.dart';
class AssetMediaRepository { class AssetMediaRepository {
const AssetMediaRepository(); const AssetMediaRepository();
Future<Uint8List?> getThumbnail(String id, {int quality = 80, Size size = const Size.square(256)}) => AssetEntity( Future<ui.Codec> getLocalThumbnail(String localId, ui.Size size) async {
final info = await thumbnailApi.setThumbnailToBuffer(
localId,
width: size.width.toInt(),
height: size.height.toInt(),
);
final pointer = Pointer<Uint8>.fromAddress(info['pointer']!);
final actualWidth = info['width']!;
final actualHeight = info['height']!;
final actualSize = actualWidth * actualHeight * 4;
try {
final buffer =
await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: actualWidth,
height: actualHeight,
pixelFormat: ui.PixelFormat.rgba8888,
);
return await descriptor.instantiateCodec();
} finally {
malloc.free(pointer);
}
}
Future<Uint8List?> getThumbnail(
String id, {
int quality = 80,
ui.Size size = const ui.Size.square(256),
}) =>
AssetEntity(
id: id, id: id,
// The below fields are not used in thumbnailDataWithSize but are required // The below fields are not used in thumbnailDataWithSize but are required
// to create an AssetEntity instance. It is faster to create a dummy AssetEntity // to create an AssetEntity instance. It is faster to create a dummy AssetEntity
@ -14,5 +50,8 @@ class AssetMediaRepository {
typeInt: AssetType.image.index, typeInt: AssetType.image.index,
width: size.width.toInt(), width: size.width.toInt(),
height: size.height.toInt(), height: size.height.toInt(),
).thumbnailDataWithSize(ThumbnailSize(size.width.toInt(), size.height.toInt()), quality: quality); ).thumbnailDataWithSize(
ThumbnailSize(size.width.toInt(), size.height.toInt()),
quality: quality,
);
} }

View File

@ -69,6 +69,9 @@ Future<void> initApp() async {
} }
} }
PaintingBinding.instance.imageCache.maximumSizeBytes =
kTimelineImageCacheMemory;
await DynamicTheme.fetchSystemPalette(); await DynamicTheme.fetchSystemPalette();
final log = Logger("ImmichErrorLogger"); final log = Logger("ImmichErrorLogger");

View File

@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
), ),
child: asset != null child: asset != null
? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover) ? Thumbnail.fromBaseAsset(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
: null, : null,
), ),
), ),

View File

@ -52,7 +52,6 @@ class ThumbnailApi {
final String pigeonVar_messageChannelSuffix; final String pigeonVar_messageChannelSuffix;
Future<Map<String, int>> setThumbnailToBuffer( Future<Map<String, int>> setThumbnailToBuffer(
int pointer,
String assetId, { String assetId, {
required int width, required int width,
required int height, required int height,
@ -66,7 +65,7 @@ class ThumbnailApi {
binaryMessenger: pigeonVar_binaryMessenger, binaryMessenger: pigeonVar_binaryMessenger,
); );
final Future<Object?> pigeonVar_sendFuture = final Future<Object?> pigeonVar_sendFuture =
pigeonVar_channel.send(<Object?>[pointer, assetId, width, height]); pigeonVar_channel.send(<Object?>[assetId, width, height]);
final List<Object?>? pigeonVar_replyList = final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?; await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) { if (pigeonVar_replyList == null) {

View File

@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -163,8 +164,14 @@ class _PlaceTile extends StatelessWidget {
onTap: () => context.pushRoute(DriftPlaceDetailRoute(place: place.$1)), onTap: () => context.pushRoute(DriftPlaceDetailRoute(place: place.$1)),
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)), title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect( leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)), borderRadius: const BorderRadius.all(
child: Thumbnail(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2), Radius.circular(20),
),
child: Thumbnail(
imageProvider: RemoteThumbProvider(assetId: place.$2),
size: const Size(80, 80),
fit: BoxFit.cover,
),
), ),
); );
} }

View File

@ -114,10 +114,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
super.dispose(); super.dispose();
} }
bool get showingBottomSheet => ref.read(assetViewerProvider.select((s) => s.showingBottomSheet)); bool get showingBottomSheet => ref.read(assetViewerProvider).showingBottomSheet;
Color get backgroundColor { Color get backgroundColor {
final opacity = ref.read(assetViewerProvider.select((s) => s.backgroundOpacity)); final opacity = ref.read(assetViewerProvider).backgroundOpacity;
return Colors.black.withAlpha(opacity); return Colors.black.withAlpha(opacity);
} }
@ -148,7 +148,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
unawaited( unawaited(
Future.wait([ Future.wait([
precacheImage( precacheImage(
getThumbnailImageProvider(asset: asset, size: screenSize), getThumbnailImageProvider(asset: asset),
context, context,
onError: (_, __) {}, onError: (_, __) {},
), ),
@ -177,9 +177,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
// Check if widget is still mounted before proceeding // Check if widget is still mounted before proceeding
if (!mounted) return; if (!mounted) return;
for (final offset in [-1, 1]) { unawaited(_precacheImage(index - 1));
unawaited(_precacheImage(index + offset)); unawaited(_precacheImage(index + 1));
}
}); });
_delayedOperations.add(timer); _delayedOperations.add(timer);
@ -482,7 +481,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
color: backgroundColor, color: backgroundColor,
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain, size: Size(ctx.width, ctx.height)), child: Thumbnail.fromBaseAsset(
asset: asset,
fit: BoxFit.contain,
),
); );
} }
@ -501,7 +503,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); asset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
} }
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
@ -520,8 +522,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
tightMode: true, tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained * 0.999, minScale: PhotoViewComputedScale.contained,
disableScaleGestures: showingBottomSheet, disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart, onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate, onDragUpdate: _onDragUpdate,
@ -532,7 +534,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
width: ctx.width, width: ctx.width,
height: ctx.height, height: ctx.height,
color: backgroundColor, color: backgroundColor,
child: Thumbnail.fromBaseAsset(asset: asset, fit: BoxFit.contain, size: size), child: Thumbnail.fromBaseAsset(
asset: asset,
fit: BoxFit.contain,
),
), ),
); );
} }
@ -550,9 +555,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown, onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99, initialScale: PhotoViewComputedScale.contained,
maxScale: 1.0, maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99, minScale: PhotoViewComputedScale.contained,
basePosition: Alignment.center, basePosition: Alignment.center,
child: SizedBox( child: SizedBox(
width: ctx.width, width: ctx.width,
@ -581,9 +586,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Rebuild the widget when the asset viewer state changes // Rebuild the widget when the asset viewer state changes
// Using multiple selectors to avoid unnecessary rebuilds for other state changes // Using multiple selectors to avoid unnecessary rebuilds for other state changes
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); assetViewerProvider.select(
ref.watch(assetViewerProvider.select((s) => s.stackIndex)); (s) =>
s.showingBottomSheet.hashCode ^
s.backgroundOpacity.hashCode ^
s.stackIndex.hashCode,
),
);
ref.watch(isPlayingMotionVideoProvider); ref.watch(isPlayingMotionVideoProvider);
// Listen for casting changes and send initial asset to the cast provider // Listen for casting changes and send initial asset to the cast provider

View File

@ -75,32 +75,49 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
} }
void setAsset(BaseAsset? asset) { void setAsset(BaseAsset? asset) {
if (asset != state.currentAsset) {
state = state.copyWith(currentAsset: asset, stackIndex: 0); state = state.copyWith(currentAsset: asset, stackIndex: 0);
} }
}
void setOpacity(int opacity) { void setOpacity(int opacity) {
state = state.copyWith(backgroundOpacity: opacity, showingControls: opacity == 255 ? true : state.showingControls); if (opacity != state.backgroundOpacity) {
state = state.copyWith(
backgroundOpacity: opacity,
showingControls: opacity == 255 ? true : state.showingControls,
);
}
} }
void setBottomSheet(bool showing) { void setBottomSheet(bool showing) {
state = state.copyWith(showingBottomSheet: showing, showingControls: showing ? true : state.showingControls); if (showing == state.showingBottomSheet) {
return;
}
state = state.copyWith(
showingBottomSheet: showing,
showingControls: showing || state.showingControls,
);
if (showing) { if (showing) {
ref.read(videoPlayerControlsProvider.notifier).pause(); ref.read(videoPlayerControlsProvider.notifier).pause();
} }
} }
void setControls(bool isShowing) { void setControls(bool isShowing) {
if (isShowing != state.showingControls) {
state = state.copyWith(showingControls: isShowing); state = state.copyWith(showingControls: isShowing);
} }
}
void toggleControls() { void toggleControls() {
state = state.copyWith(showingControls: !state.showingControls); state = state.copyWith(showingControls: !state.showingControls);
} }
void setStackIndex(int index) { void setStackIndex(int index) {
if (index != state.stackIndex) {
state = state.copyWith(stackIndex: index); state = state.copyWith(stackIndex: index);
} }
} }
}
final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>( final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
AssetViewerStateNotifier.new, AssetViewerStateNotifier.new,

View File

@ -1,4 +1,5 @@
import 'package:flutter/widgets.dart'; 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/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart'; import 'package:immich_mobile/domain/services/setting.service.dart';
@ -10,7 +11,7 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
final ImageProvider provider; final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) { if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, name: asset.name, size: size, type: asset.type); provider = LocalFullImageProvider(id: id, size: size);
} else { } else {
final String assetId; final String assetId;
if (asset is LocalAsset && asset.hasRemote) { if (asset is LocalAsset && asset.hasRemote) {
@ -26,8 +27,15 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
return provider; return provider;
} }
ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Size size = const Size.square(256)}) { ImageProvider getThumbnailImageProvider({
assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided'); BaseAsset? asset,
String? remoteId,
Size size = const Size.square(kTimelineThumbnailSize),
}) {
assert(
asset != null || remoteId != null,
'Either asset or remoteId must be provided',
);
if (remoteId != null) { if (remoteId != null) {
return RemoteThumbProvider(assetId: remoteId); return RemoteThumbProvider(assetId: remoteId);
@ -35,7 +43,11 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
if (_shouldUseLocalAsset(asset!)) { if (_shouldUseLocalAsset(asset!)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, name: asset.name, size: size); return LocalThumbProvider(
id: id,
// updatedAt: asset.updatedAt, TODO
size: size,
);
} }
final String assetId; final String assetId;

View File

@ -1,36 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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/infrastructure/repositories/asset_media.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
import 'package:logging/logging.dart';
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> { class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository(); static const _assetMediaRepository = AssetMediaRepository();
final CacheManager? cacheManager;
final String id; final String id;
final DateTime updatedAt;
final String name;
final Size size; final Size size;
final DateTime? updatedAt;
const LocalThumbProvider({ const LocalThumbProvider({
required this.id, required this.id,
required this.updatedAt, required this.size,
required this.name, this.updatedAt,
this.size = const Size.square(kTimelineFixedTileExtent),
this.cacheManager,
}); });
@override @override
@ -39,48 +24,34 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
} }
@override @override
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(
final cache = cacheManager ?? ThumbnailImageCacheManager(); LocalThumbProvider key,
return MultiFrameImageStreamCompleter( ImageDecoderCallback decode,
codec: _codec(key, cache, decode), ) {
scale: 1.0, return OneFrameImageStreamCompleter(
_codec(key),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt), DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
DiagnosticsProperty<String>('Name', key.name),
DiagnosticsProperty<Size>('Size', key.size), DiagnosticsProperty<Size>('Size', key.size),
], ],
); );
} }
Future<Codec> _codec(LocalThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async { Future<ImageInfo> _codec(LocalThumbProvider key) async {
final cacheKey = '${key.id}-${key.updatedAt}-${key.size.width}x${key.size.height}'; final codec =
await _assetMediaRepository.getLocalThumbnail(key.id, key.size);
final fileFromCache = await cache.getFileFromCache(cacheKey); return ImageInfo(image: (await codec.getNextFrame()).image, scale: 1.0);
if (fileFromCache != null) {
try {
final buffer = await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
return decode(buffer);
} catch (_) {}
}
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbnailBytes == null) {
PaintingBinding.instance.imageCache.evict(key);
throw StateError("Loading thumb for local photo ${key.name} failed");
}
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
unawaited(cache.putFile(cacheKey, thumbnailBytes));
return decode(buffer);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id && updatedAt == other.updatedAt; return id == other.id &&
size == other.size &&
updatedAt == other.updatedAt;
} }
return false; return false;
} }
@ -90,15 +61,12 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
} }
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> { class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository(); static const _assetMediaRepository = AssetMediaRepository();
final StorageRepository _storageRepository = const StorageRepository();
final String id; final String id;
final String name;
final Size size; final Size size;
final AssetType type;
const LocalFullImageProvider({required this.id, required this.name, required this.size, required this.type}); const LocalFullImageProvider({required this.id, required this.size});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@ -106,105 +74,32 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
} }
@override @override
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(
return MultiImageStreamCompleter( LocalFullImageProvider key,
codec: _codec(key, decode), ImageDecoderCallback decode,
scale: 1.0, ) {
informationCollector: () sync* { return OneFrameImageStreamCompleter(_codec(key));
yield ErrorDescription(name); }
},
Future<ImageInfo> _codec(LocalFullImageProvider key) async {
final devicePixelRatio =
PlatformDispatcher.instance.views.first.devicePixelRatio;
final codec = await _assetMediaRepository.getLocalThumbnail(
key.id,
Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
); );
} return ImageInfo(image: (await codec.getNextFrame()).image, scale: 1.0);
// Streams in each stage of the image as we ask for it
Stream<Codec> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
try {
switch (key.type) {
case AssetType.image:
yield* _decodeProgressive(key, decode);
break;
case AssetType.video:
final codec = await _getThumbnailCodec(key, decode);
if (codec == null) {
throw StateError("Failed to load preview for ${key.name}");
}
yield codec;
break;
case AssetType.other:
case AssetType.audio:
throw StateError('Unsupported asset type ${key.type}');
}
} catch (error, stack) {
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.name}', error, stack);
throw const ImageLoadingException('Could not load image from local storage');
}
}
Future<Codec?> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async {
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
if (thumbBytes == null) {
return null;
}
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
return decode(buffer);
}
Stream<Codec> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
final file = await _storageRepository.getFileForAsset(key.id);
if (file == null) {
throw StateError("Opening file for asset ${key.name} failed");
}
final fileSize = await file.length();
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
if (isProgressive) {
try {
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
final size = Size(
(key.size.width * progressiveMultiplier).clamp(256, 1024),
(key.size.height * progressiveMultiplier).clamp(256, 1024),
);
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (mediumThumb != null) {
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
yield await decode(mediumBuffer);
}
} catch (_) {}
}
// Load original only when the file is smaller or if the user wants to load original images
// Or load a slightly larger image for progressive loading
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
final size = Size(
(key.size.width * progressiveMultiplier).clamp(512, 2048),
(key.size.height * progressiveMultiplier).clamp(512, 2048),
);
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
if (highThumb != null) {
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
yield await decode(highBuffer);
}
return;
}
final buffer = await ImmutableBuffer.fromFilePath(file.path);
yield await decode(buffer);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalFullImageProvider) { if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && type == other.type && name == other.name; return id == other.id && size == other.size;
} }
return false; return false;
} }
@override @override
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode ^ name.hashCode; int get hashCode => id.hashCode ^ size.hashCode;
} }

View File

@ -1,146 +1,176 @@
import 'dart:ffi';
import 'package:flutter/material.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'dart:ui'; import 'dart:ui';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter/material.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/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash; import 'package:thumbhash/thumbhash.dart' as thumbhash;
import 'package:ffi/ffi.dart';
final log = Logger('ThumbnailWidget'); final log = Logger('ThumbnailWidget');
enum ThumbhashMode { enabled, disabled, only }
class Thumbnail extends StatefulWidget { class Thumbnail extends StatefulWidget {
final ImageProvider? imageProvider;
final BoxFit fit; final BoxFit fit;
final ui.Size size; final ui.Size size;
final String? blurhash; final String? blurhash;
final String? localId; final ThumbhashMode thumbhashMode;
final String? remoteId;
final bool thumbhashOnly;
const Thumbnail({ const Thumbnail({
this.imageProvider,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.size = const ui.Size.square(256), this.size = const ui.Size.square(kTimelineThumbnailSize),
this.blurhash, this.blurhash,
this.localId, this.thumbhashMode = ThumbhashMode.enabled,
this.remoteId,
this.thumbhashOnly = false,
super.key, super.key,
}); });
Thumbnail.fromAsset({ Thumbnail.fromAsset({
required Asset asset, required Asset asset,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.size = const ui.Size.square(256), this.size = const ui.Size.square(kTimelineThumbnailSize),
this.thumbhashOnly = false, this.thumbhashMode = ThumbhashMode.enabled,
super.key, super.key,
}) : blurhash = asset.thumbhash, }) : blurhash = asset.thumbhash,
localId = asset.localId, imageProvider = _getImageProviderFromAsset(asset, size);
remoteId = asset.remoteId;
Thumbnail.fromBaseAsset({ Thumbnail.fromBaseAsset({
required BaseAsset? asset, required BaseAsset? asset,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.size = const ui.Size.square(256), this.size = const ui.Size.square(kTimelineThumbnailSize),
this.thumbhashOnly = false, this.thumbhashMode = ThumbhashMode.enabled,
super.key, super.key,
}) : blurhash = switch (asset) { }) : blurhash = switch (asset) {
RemoteAsset() => asset.thumbHash, RemoteAsset() => asset.thumbHash,
_ => null, _ => null,
}, },
localId = switch (asset) { imageProvider = _getImageProviderFromBaseAsset(asset, size);
RemoteAsset() => asset.localId,
LocalAsset() => asset.id, static ImageProvider? _getImageProviderFromAsset(Asset asset, ui.Size size) {
_ => null, if (asset.localId != null) {
}, return LocalThumbProvider(id: asset.localId!, size: size);
remoteId = switch (asset) { } else if (asset.remoteId != null) {
RemoteAsset() => asset.id, return RemoteThumbProvider(assetId: asset.remoteId!);
LocalAsset() => asset.remoteId, }
_ => null, return null;
}; }
static ImageProvider? _getImageProviderFromBaseAsset(
BaseAsset? asset,
ui.Size size,
) {
switch (asset) {
case RemoteAsset():
if (asset.localId != null) {
return LocalThumbProvider(id: asset.localId!, size: size);
} else {
return RemoteThumbProvider(assetId: asset.id);
}
case LocalAsset():
return LocalThumbProvider(id: asset.id, size: size);
case null:
return null;
}
}
@override @override
State<Thumbnail> createState() => _ThumbnailState(); State<Thumbnail> createState() => _ThumbnailState();
} }
class _ThumbnailState extends State<Thumbnail> { class _ThumbnailState extends State<Thumbnail> {
ui.Image? _image; ui.Image? _thumbhashImage;
ui.Image? _providerImage;
ImageStream? _imageStream;
ImageStreamListener? _imageStreamListener;
static final _gradientCache = <ColorScheme, Gradient>{}; static final _gradientCache = <ColorScheme, Gradient>{};
static final _imageCache = ThumbnailImageCacheManager();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_decode(); _loadImage();
} }
@override @override
void didUpdateWidget(Thumbnail oldWidget) { void didUpdateWidget(Thumbnail oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.blurhash != widget.blurhash || if (oldWidget.imageProvider != widget.imageProvider ||
oldWidget.localId != widget.localId || oldWidget.blurhash != widget.blurhash ||
oldWidget.remoteId != widget.remoteId || (oldWidget.thumbhashMode == ThumbhashMode.disabled &&
(oldWidget.thumbhashOnly && !widget.thumbhashOnly)) { oldWidget.thumbhashMode != ThumbhashMode.disabled)) {
_decode(); _loadImage();
} }
} }
Future<void> _decode() async { @override
if (!mounted) { void reassemble() {
return; super.reassemble();
_loadImage();
} }
final thumbhashOnly = widget.thumbhashOnly; void _loadImage() {
final blurhash = widget.blurhash; _stopListeningToStream();
final imageFuture = thumbhashOnly ? Future.value(null) : _decodeThumbnail(); if (widget.thumbhashMode != ThumbhashMode.disabled &&
widget.blurhash != null) {
_decodeThumbhash();
}
if (blurhash != null && _image == null) { if (widget.thumbhashMode != ThumbhashMode.only &&
try { widget.imageProvider != null) {
await _decodeThumbhash(); _loadFromProvider();
} catch (e) {
log.severe('Error decoding thumbhash for ${widget.remoteId}: $e');
} }
} }
if (!mounted || thumbhashOnly) { void _loadFromProvider() {
return; final imageProvider = widget.imageProvider;
} if (imageProvider == null) return;
try { _imageStream = imageProvider.resolve(ImageConfiguration.empty);
final image = await imageFuture; _imageStreamListener = ImageStreamListener(
if (!mounted || image == null) { (ImageInfo imageInfo, bool synchronousCall) {
return; if (!mounted) return;
}
_image?.dispose(); _thumbhashImage?.dispose();
if (_providerImage != imageInfo.image) {
setState(() { setState(() {
_image = image; _providerImage = imageInfo.image;
}); });
} catch (e) {
log.severe('Error decoding thumbnail: $e');
} }
},
onError: (exception, stackTrace) {
log.severe('Error loading image: $exception', exception, stackTrace);
},
);
_imageStream?.addListener(_imageStreamListener!);
}
void _stopListeningToStream() {
if (_imageStreamListener != null && _imageStream != null) {
_imageStream!.removeListener(_imageStreamListener!);
}
_imageStream = null;
_imageStreamListener = null;
} }
Future<void> _decodeThumbhash() async { Future<void> _decodeThumbhash() async {
final blurhash = widget.blurhash; final blurhash = widget.blurhash;
if (blurhash == null || !mounted || _image != null) { if (blurhash == null || !mounted || _providerImage != null) {
return; return;
} }
try {
final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash)); final image = thumbhash.thumbHashToRGBA(base64.decode(blurhash));
final buffer = await ImmutableBuffer.fromUint8List(image.rgba); final buffer = await ImmutableBuffer.fromUint8List(image.rgba);
if (!mounted || _image != null) { if (!mounted || _providerImage != null) {
buffer.dispose(); buffer.dispose();
return; return;
} }
@ -154,7 +184,7 @@ class _ThumbnailState extends State<Thumbnail> {
final codec = await descriptor.instantiateCodec(); final codec = await descriptor.instantiateCodec();
if (!mounted || _image != null) { if (!mounted || _providerImage != null) {
buffer.dispose(); buffer.dispose();
descriptor.dispose(); descriptor.dispose();
codec.dispose(); codec.dispose();
@ -165,125 +195,20 @@ class _ThumbnailState extends State<Thumbnail> {
buffer.dispose(); buffer.dispose();
descriptor.dispose(); descriptor.dispose();
codec.dispose(); codec.dispose();
if (!mounted || _image != null) {
if (!mounted || _providerImage != null) {
frame.dispose(); frame.dispose();
return; return;
} }
setState(() { setState(() {
_image = frame; _providerImage = frame;
}); });
}
Future<ui.Image?> _decodeThumbnail() async {
if (!mounted) {
return null;
}
final stopwatch = Stopwatch()..start();
final codec = await _decodeThumb();
if (codec == null || !mounted) {
codec?.dispose();
return null;
}
final image = (await codec.getNextFrame()).image;
stopwatch.stop();
log.info(
'Decoded thumbnail for ${widget.remoteId ?? widget.localId} in ${stopwatch.elapsedMilliseconds} ms',
);
return image;
}
Future<ui.Codec?> _decodeThumb() {
final localId = widget.localId;
if (!mounted) {
return Future.value(null);
}
if (localId != null) {
final size = widget.size;
final width = size.width.toInt();
final height = size.height.toInt();
return _decodeLocal(localId, width, height);
}
final remoteId = widget.remoteId;
if (remoteId != null) {
return _decodeRemote(remoteId);
}
return Future.value(null);
}
Future<ui.Codec?> _decodeLocal(String localId, int width, int height) async {
final pointer = malloc<Uint8>(width * height * 4);
try {
final info = await thumbnailApi.setThumbnailToBuffer(
pointer.address,
localId,
width: width,
height: height,
);
if (!mounted) {
return null;
}
final actualWidth = info['width']!;
final actualHeight = info['height']!;
final actualSize = actualWidth * actualHeight * 4;
final buffer =
await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
if (!mounted) {
buffer.dispose();
return null;
}
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: actualWidth,
height: actualHeight,
pixelFormat: ui.PixelFormat.rgba8888,
);
return await descriptor.instantiateCodec();
} catch (e) { } catch (e) {
return null; log.severe('Error decoding thumbhash: $e');
} finally {
malloc.free(pointer);
} }
} }
Future<ui.Codec?> _decodeRemote(String remoteId) async {
final uri = getThumbnailUrlForRemoteId(remoteId);
final headers = ApiService.getRequestHeaders();
final stream = _imageCache.getFileStream(
uri,
key: uri,
withProgress: true,
headers: headers,
);
await for (final result in stream) {
if (!mounted) {
return null;
}
if (result is FileInfo) {
final buffer = await ImmutableBuffer.fromFilePath(result.file.path);
if (!mounted) {
buffer.dispose();
return null;
}
final descriptor = await ImageDescriptor.encoded(buffer);
if (!mounted) {
buffer.dispose();
descriptor.dispose();
return null;
}
return await descriptor.instantiateCodec();
}
}
return null;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = context.colorScheme; final colorScheme = context.colorScheme;
@ -296,8 +221,8 @@ class _ThumbnailState extends State<Thumbnail> {
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
); );
return _ThumbhashLeaf( return _ThumbnailLeaf(
image: _image, image: _providerImage ?? _thumbhashImage,
fit: widget.fit, fit: widget.fit,
placeholderGradient: gradient, placeholderGradient: gradient,
); );
@ -305,17 +230,18 @@ class _ThumbnailState extends State<Thumbnail> {
@override @override
void dispose() { void dispose() {
_image?.dispose(); _stopListeningToStream();
_thumbhashImage?.dispose();
super.dispose(); super.dispose();
} }
} }
class _ThumbhashLeaf extends LeafRenderObjectWidget { class _ThumbnailLeaf extends LeafRenderObjectWidget {
final ui.Image? image; final ui.Image? image;
final BoxFit fit; final BoxFit fit;
final Gradient placeholderGradient; final Gradient placeholderGradient;
const _ThumbhashLeaf({ const _ThumbnailLeaf({
required this.image, required this.image,
required this.fit, required this.fit,
required this.placeholderGradient, required this.placeholderGradient,
@ -323,7 +249,7 @@ class _ThumbhashLeaf extends LeafRenderObjectWidget {
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
return _ThumbhashRenderBox( return _ThumbnailRenderBox(
image: image, image: image,
fit: fit, fit: fit,
placeholderGradient: placeholderGradient, placeholderGradient: placeholderGradient,
@ -333,7 +259,7 @@ class _ThumbhashLeaf extends LeafRenderObjectWidget {
@override @override
void updateRenderObject( void updateRenderObject(
BuildContext context, BuildContext context,
_ThumbhashRenderBox renderObject, _ThumbnailRenderBox renderObject,
) { ) {
renderObject.fit = fit; renderObject.fit = fit;
renderObject.image = image; renderObject.image = image;
@ -341,7 +267,7 @@ class _ThumbhashLeaf extends LeafRenderObjectWidget {
} }
} }
class _ThumbhashRenderBox extends RenderBox { class _ThumbnailRenderBox extends RenderBox {
ui.Image? _image; ui.Image? _image;
BoxFit _fit; BoxFit _fit;
Gradient _placeholderGradient; Gradient _placeholderGradient;
@ -349,7 +275,7 @@ class _ThumbhashRenderBox extends RenderBox {
@override @override
bool isRepaintBoundary = true; bool isRepaintBoundary = true;
_ThumbhashRenderBox({ _ThumbnailRenderBox({
required ui.Image? image, required ui.Image? image,
required BoxFit fit, required BoxFit fit,
required Gradient placeholderGradient, required Gradient placeholderGradient,

View File

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart'; import 'package:immich_mobile/extensions/duration_extensions.dart';
@ -12,7 +13,7 @@ import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class ThumbnailTile extends ConsumerWidget { class ThumbnailTile extends ConsumerWidget {
const ThumbnailTile( const ThumbnailTile(
this.asset, { this.asset, {
this.size = const Size.square(256), this.size = const Size.square(kTimelineThumbnailTileSize),
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.showStorageIndicator = true, this.showStorageIndicator = true,
this.lockSelection = false, this.lockSelection = false,
@ -73,9 +74,9 @@ class ThumbnailTile extends ConsumerWidget {
tag: '${asset?.heroTag ?? ''}_$heroIndex', tag: '${asset?.heroTag ?? ''}_$heroIndex',
child: Thumbnail.fromBaseAsset( child: Thumbnail.fromBaseAsset(
asset: asset, asset: asset,
fit: fit, thumbhashMode: isScrubbing
size: size, ? ThumbhashMode.only
thumbhashOnly: isScrubbing, : ThumbhashMode.enabled,
), ),
), ),
), ),

View File

@ -6,12 +6,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -162,6 +166,12 @@ class _AssetTileWidget extends ConsumerWidget {
} else { } else {
await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1); await ref.read(timelineServiceProvider).loadAssets(assetIndex, 1);
ref.read(isPlayingMotionVideoProvider.notifier).playing = false; ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
ref.read(assetViewerProvider.notifier).setAsset(asset);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
}
ctx.pushRoute( ctx.pushRoute(
AssetViewerRoute( AssetViewerRoute(
initialIndex: assetIndex, initialIndex: assetIndex,

View File

@ -1,8 +1,5 @@
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
abstract class SegmentBuilder { abstract class SegmentBuilder {
final List<Bucket> buckets; final List<Bucket> buckets;
@ -17,19 +14,4 @@ abstract class SegmentBuilder {
HeaderType.monthAndDay => kTimelineHeaderExtent * 1.6, HeaderType.monthAndDay => kTimelineHeaderExtent * 1.6,
HeaderType.none => 0.0, HeaderType.none => 0.0,
}; };
static Widget buildPlaceholder(
BuildContext context,
int count, {
Size size = const Size.square(kTimelineFixedTileExtent),
double spacing = kTimelineSpacing,
}) =>
RepaintBoundary(
child: FixedTimelineRow(
dimension: size.height,
spacing: spacing,
textDirection: Directionality.of(context),
children: List.filled(count, const Thumbnail()),
),
);
} }

View File

@ -87,6 +87,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
class _SliverTimelineState extends ConsumerState<_SliverTimeline> { class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final _scrollController = ScrollController(); final _scrollController = ScrollController();
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
// late final KeepAliveLink asyncSegmentsLink;
@override @override
void initState() { void initState() {

View File

@ -421,6 +421,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
width: scaleBoundaries.childSize.width * scale, width: scaleBoundaries.childSize.width * scale,
fit: BoxFit.cover, fit: BoxFit.cover,
isAntiAlias: widget.filterQuality == FilterQuality.high,
); );
} }
} }

View File

@ -16,7 +16,6 @@ import 'package:pigeon/pigeon.dart';
abstract class ThumbnailApi { abstract class ThumbnailApi {
@async @async
Map<String, int> setThumbnailToBuffer( Map<String, int> setThumbnailToBuffer(
int pointer,
String assetId, { String assetId, {
required int width, required int width,
required int height, required int height,