mirror of
https://github.com/immich-app/immich.git
synced 2026-07-23 13:54:05 +03:00
Compare commits
5 Commits
fix/auto-h
...
fix/29853-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd19e88347 | ||
|
|
10b8472ec2 | ||
|
|
ffd972b993 | ||
|
|
de0da62e17 | ||
|
|
33c218e751 |
@@ -33,12 +33,27 @@ data class Request(
|
|||||||
val callback: (Result<Map<String, Long>?>) -> Unit
|
val callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Set [exactSize] to decode at the smallest size that covers [target] without upscaling. */
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap {
|
||||||
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
|
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
|
||||||
if (target.width > 0 && target.height > 0) {
|
if (target.width > 0 && target.height > 0) {
|
||||||
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
if (exactSize) {
|
||||||
decoder.setTargetSampleSize(sample)
|
val fillScale = max(
|
||||||
|
target.width.toDouble() / info.size.width,
|
||||||
|
target.height.toDouble() / info.size.height
|
||||||
|
)
|
||||||
|
val scale = min(1.0, fillScale)
|
||||||
|
if (scale < 1) {
|
||||||
|
decoder.setTargetSize(
|
||||||
|
max(1, ceil(info.size.width * scale).toInt()),
|
||||||
|
max(1, ceil(info.size.height * scale).toInt())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sample = max(1, min(info.size.width / target.width, info.size.height / target.height))
|
||||||
|
decoder.setTargetSampleSize(sample)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ private open class RemoteImagesPigeonCodec : 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 RemoteImageApi {
|
interface RemoteImageApi {
|
||||||
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
/** Width and height are the physical decode size, or null for the source size. */
|
||||||
|
fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, width: Long?, height: Long?, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun clearCache(callback: (Result<Long>) -> Unit)
|
fun clearCache(callback: (Result<Long>) -> Unit)
|
||||||
|
|
||||||
@@ -68,7 +69,9 @@ interface RemoteImageApi {
|
|||||||
val urlArg = args[0] as String
|
val urlArg = args[0] as String
|
||||||
val requestIdArg = args[1] as Long
|
val requestIdArg = args[1] as Long
|
||||||
val preferEncodedArg = args[2] as Boolean
|
val preferEncodedArg = args[2] as Boolean
|
||||||
api.requestImage(urlArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
val widthArg = args[3] as Long?
|
||||||
|
val heightArg = args[4] as Long?
|
||||||
|
api.requestImage(urlArg, requestIdArg, preferEncodedArg, widthArg, heightArg) { result: Result<Map<String, Long>?> ->
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.graphics.ImageDecoder
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
|
import android.util.Size
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
@@ -79,6 +80,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
url: String,
|
url: String,
|
||||||
requestId: Long,
|
requestId: Long,
|
||||||
preferEncoded: Boolean,
|
preferEncoded: Boolean,
|
||||||
|
width: Long?,
|
||||||
|
height: Long?,
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
) {
|
) {
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
@@ -102,13 +105,21 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
decodeExecutor.execute {
|
decodeExecutor.execute {
|
||||||
val res = if (signal.isCanceled) null else try {
|
val res = if (signal.isCanceled) null else try {
|
||||||
val bitmap = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)).decodeBitmap()
|
|
||||||
// The embedded preview a raw decodes to has no orientation, so read the container's.
|
// The embedded preview a raw decodes to has no orientation, so read the container's.
|
||||||
val orientation = if (isRawMime(contentType)) {
|
val orientation = if (isRawMime(contentType)) {
|
||||||
readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset)
|
readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset)
|
||||||
} else {
|
} else {
|
||||||
ExifInterface.ORIENTATION_NORMAL
|
ExifInterface.ORIENTATION_NORMAL
|
||||||
}
|
}
|
||||||
|
val target = when (orientation) {
|
||||||
|
ExifInterface.ORIENTATION_TRANSPOSE,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_90,
|
||||||
|
ExifInterface.ORIENTATION_TRANSVERSE,
|
||||||
|
ExifInterface.ORIENTATION_ROTATE_270 -> Size(height?.toInt() ?: 0, width?.toInt() ?: 0)
|
||||||
|
else -> Size(width?.toInt() ?: 0, height?.toInt() ?: 0)
|
||||||
|
}
|
||||||
|
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
||||||
|
val bitmap = source.decodeBitmap(target, exactSize = true)
|
||||||
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||||
bitmap.toNativeBuffer()
|
bitmap.toNativeBuffer()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
8
mobile/ios/Runner/Images/RemoteImages.g.swift
generated
8
mobile/ios/Runner/Images/RemoteImages.g.swift
generated
@@ -70,7 +70,8 @@ class RemoteImagesPigeonCodec: 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 RemoteImageApi {
|
protocol RemoteImageApi {
|
||||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
/// Width and height are the physical decode size, or null for the source size.
|
||||||
|
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64?, height: Int64?, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -81,6 +82,7 @@ class RemoteImageApiSetup {
|
|||||||
/// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`.
|
/// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`.
|
||||||
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
|
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") {
|
||||||
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
|
||||||
|
/// Width and height are the physical decode size, or null for the source size.
|
||||||
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
let requestImageChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
if let api = api {
|
if let api = api {
|
||||||
requestImageChannel.setMessageHandler { message, reply in
|
requestImageChannel.setMessageHandler { message, reply in
|
||||||
@@ -88,7 +90,9 @@ class RemoteImageApiSetup {
|
|||||||
let urlArg = args[0] as! String
|
let urlArg = args[0] as! String
|
||||||
let requestIdArg = args[1] as! Int64
|
let requestIdArg = args[1] as! Int64
|
||||||
let preferEncodedArg = args[2] as! Bool
|
let preferEncodedArg = args[2] as! Bool
|
||||||
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
let widthArg: Int64? = nilOrValue(args[3])
|
||||||
|
let heightArg: Int64? = nilOrValue(args[4])
|
||||||
|
api.requestImage(url: urlArg, requestId: requestIdArg, preferEncoded: preferEncodedArg, width: widthArg, height: heightArg) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Accelerate
|
import Accelerate
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import ImageIO
|
||||||
import MobileCoreServices
|
import MobileCoreServices
|
||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
@@ -27,21 +28,21 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||||
renderingIntent: .perceptual
|
renderingIntent: .perceptual
|
||||||
)!
|
)!
|
||||||
private static let decodeOptions = [
|
private static let decodeOptions: [NSString: Any] = [
|
||||||
kCGImageSourceShouldCache: false,
|
kCGImageSourceShouldCache: false,
|
||||||
kCGImageSourceShouldCacheImmediately: true,
|
kCGImageSourceShouldCacheImmediately: true,
|
||||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||||
] as CFDictionary
|
]
|
||||||
|
|
||||||
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64?, height: Int64?, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||||
|
|
||||||
let request = RemoteImageRequest(id: requestId, completion: completion)
|
let request = RemoteImageRequest(id: requestId, completion: completion)
|
||||||
|
|
||||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||||
Self.handleCompletion(request: request, encoded: preferEncoded, data: data, response: response, error: error)
|
Self.handleCompletion(request: request, encoded: preferEncoded, width: width, height: height, data: data, response: response, error: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.task = task
|
request.task = task
|
||||||
@@ -49,7 +50,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, width: Int64?, height: Int64?, data: Data?, response: URLResponse?, error: Error?) {
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
@@ -87,8 +88,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
registry.remove(requestId: request.id)
|
||||||
|
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = decodeOptions
|
||||||
|
if let maxPixelSize = targetThumbnailRenderSize(imageSource: imageSource, width: width, height: height) {
|
||||||
|
options[kCGImageSourceThumbnailMaxPixelSize] = maxPixelSize
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else {
|
||||||
registry.remove(requestId: request.id)
|
registry.remove(requestId: request.id)
|
||||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||||
}
|
}
|
||||||
@@ -120,6 +130,34 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the longest rendered edge needed to cover the requested size.
|
||||||
|
private static func targetThumbnailRenderSize(imageSource: CGImageSource, width: Int64?, height: Int64?) -> Int? {
|
||||||
|
guard let width,
|
||||||
|
let height,
|
||||||
|
width > 0,
|
||||||
|
height > 0,
|
||||||
|
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
||||||
|
let pixelWidth = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||||
|
let pixelHeight = properties[kCGImagePropertyPixelHeight] as? NSNumber else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber)
|
||||||
|
.flatMap { CGImagePropertyOrientation(rawValue: $0.uint32Value) } ?? .up
|
||||||
|
let swapsDimensions: Bool
|
||||||
|
switch orientation {
|
||||||
|
case .leftMirrored, .right, .rightMirrored, .left:
|
||||||
|
swapsDimensions = true
|
||||||
|
default:
|
||||||
|
swapsDimensions = false
|
||||||
|
}
|
||||||
|
let sourceWidth = swapsDimensions ? pixelHeight.doubleValue : pixelWidth.doubleValue
|
||||||
|
let sourceHeight = swapsDimensions ? pixelWidth.doubleValue : pixelHeight.doubleValue
|
||||||
|
let fillScale = max(Double(width) / sourceWidth, Double(height) / sourceHeight)
|
||||||
|
let scale = min(1, fillScale)
|
||||||
|
return scale < 1 ? Int(ceil(max(sourceWidth * scale, sourceHeight * scale))) : nil
|
||||||
|
}
|
||||||
|
|
||||||
func cancelRequest(requestId: Int64) {
|
func cancelRequest(requestId: Int64) {
|
||||||
Self.registry.remove(requestId: requestId)?.cancel()
|
Self.registry.remove(requestId: requestId)?.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ffi';
|
import 'dart:ffi';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:ffi/ffi.dart';
|
import 'package:ffi/ffi.dart';
|
||||||
@@ -36,7 +37,11 @@ abstract class ImageRequest {
|
|||||||
|
|
||||||
void _onCancelled();
|
void _onCancelled();
|
||||||
|
|
||||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
|
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(
|
||||||
|
int address,
|
||||||
|
int length, {
|
||||||
|
ui.Size? size,
|
||||||
|
}) async {
|
||||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
malloc.free(pointer);
|
malloc.free(pointer);
|
||||||
@@ -62,7 +67,8 @@ abstract class ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final codec = await descriptor.instantiateCodec();
|
final target = _targetSize(descriptor.width, descriptor.height, size);
|
||||||
|
final codec = await descriptor.instantiateCodec(targetWidth: target?.$1, targetHeight: target?.$2);
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
descriptor.dispose();
|
descriptor.dispose();
|
||||||
codec.dispose();
|
codec.dispose();
|
||||||
@@ -72,8 +78,8 @@ abstract class ImageRequest {
|
|||||||
return (codec, descriptor);
|
return (codec, descriptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? size}) async {
|
||||||
final result = await _codecFromEncodedPlatformImage(address, length);
|
final result = await _codecFromEncodedPlatformImage(address, length, size: size);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,20 @@ abstract class ImageRequest {
|
|||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(int, int)? _targetSize(int width, int height, ui.Size? size) {
|
||||||
|
if (size == null || size.width <= 0 || size.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fillScale = math.max(size.width / width, size.height / height);
|
||||||
|
final scale = math.min(1.0, fillScale);
|
||||||
|
if (scale >= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((width * scale).ceil(), (height * scale).ceil());
|
||||||
|
}
|
||||||
|
|
||||||
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
|
||||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ part of 'image_request.dart';
|
|||||||
class RemoteImageRequest extends ImageRequest {
|
class RemoteImageRequest extends ImageRequest {
|
||||||
final String uri;
|
final String uri;
|
||||||
|
|
||||||
RemoteImageRequest({required this.uri});
|
/// Physical size to decode, or null for the source size.
|
||||||
|
final ui.Size? size;
|
||||||
|
|
||||||
|
RemoteImageRequest({required this.uri, this.size});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||||
@@ -11,10 +14,16 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
final info = await remoteImageApi.requestImage(
|
||||||
|
uri,
|
||||||
|
requestId: requestId,
|
||||||
|
preferEncoded: false,
|
||||||
|
width: size?.width.ceil(),
|
||||||
|
height: size?.height.ceil(),
|
||||||
|
);
|
||||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||||
final frame = switch (info) {
|
final frame = switch (info) {
|
||||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length, size: size),
|
||||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||||
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
await _fromDecodedPlatformImage(pointer, width, height, rowBytes),
|
||||||
_ => null,
|
_ => null,
|
||||||
@@ -28,7 +37,13 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
|
final info = await remoteImageApi.requestImage(
|
||||||
|
uri,
|
||||||
|
requestId: requestId,
|
||||||
|
preferEncoded: true,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
);
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
17
mobile/lib/platform/remote_image_api.g.dart
generated
17
mobile/lib/platform/remote_image_api.g.dart
generated
@@ -60,7 +60,14 @@ class RemoteImageApi {
|
|||||||
|
|
||||||
final String pigeonVar_messageChannelSuffix;
|
final String pigeonVar_messageChannelSuffix;
|
||||||
|
|
||||||
Future<Map<String, int>?> requestImage(String url, {required int requestId, required bool preferEncoded}) async {
|
/// Width and height are the physical decode size, or null for the source size.
|
||||||
|
Future<Map<String, int>?> requestImage(
|
||||||
|
String url, {
|
||||||
|
required int requestId,
|
||||||
|
required bool preferEncoded,
|
||||||
|
int? width,
|
||||||
|
int? height,
|
||||||
|
}) async {
|
||||||
final pigeonVar_channelName =
|
final pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||||
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
final pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
@@ -68,7 +75,13 @@ class RemoteImageApi {
|
|||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, requestId, preferEncoded]);
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
|
||||||
|
url,
|
||||||
|
requestId,
|
||||||
|
preferEncoded,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
]);
|
||||||
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
|
||||||
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
|
||||||
|
|||||||
@@ -333,9 +333,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
required bool isCurrent,
|
required bool isCurrent,
|
||||||
required bool isPlayingMotionVideo,
|
required bool isPlayingMotionVideo,
|
||||||
required String? localFilePath,
|
required String? localFilePath,
|
||||||
|
required Size? remoteThumbnailSize,
|
||||||
}) {
|
}) {
|
||||||
final size = context.sizeData;
|
final size = context.sizeData;
|
||||||
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
|
final imageProvider = getFullImageProvider(
|
||||||
|
asset,
|
||||||
|
size: size,
|
||||||
|
localFilePath: localFilePath,
|
||||||
|
remoteThumbnailSize: remoteThumbnailSize,
|
||||||
|
);
|
||||||
|
|
||||||
if (asset.isImage && !isPlayingMotionVideo) {
|
if (asset.isImage && !isPlayingMotionVideo) {
|
||||||
return PhotoView(
|
return PhotoView(
|
||||||
@@ -397,7 +403,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
|
final (currentAsset, thumbnailSize) = ref.watch(
|
||||||
|
assetViewerProvider.select((s) => (s.currentAsset, s.thumbnailSize)),
|
||||||
|
);
|
||||||
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
|
||||||
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
|
||||||
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
|
||||||
@@ -453,6 +461,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
isPlayingMotionVideo: isPlayingMotionVideo,
|
isPlayingMotionVideo: isPlayingMotionVideo,
|
||||||
localFilePath: viewIntentFilePath,
|
localFilePath: viewIntentFilePath,
|
||||||
|
remoteThumbnailSize: thumbnailSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showingOcr && displayAsset.width != null && displayAsset.height != null)
|
if (showingOcr && displayAsset.width != null && displayAsset.height != null)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ class AssetPreloader {
|
|||||||
|
|
||||||
AssetPreloader({required this.timelineService, required this.mounted});
|
AssetPreloader({required this.timelineService, required this.mounted});
|
||||||
|
|
||||||
void preload(int index, Size size) {
|
/// Preloads adjacent images with the current thumbnail size.
|
||||||
|
void preload(int index, Size size, {Size? thumbnailSize}) {
|
||||||
unawaited(timelineService.preloadAssets(index));
|
unawaited(timelineService.preloadAssets(index));
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = Timer(Durations.medium4, () async {
|
_timer = Timer(Durations.medium4, () async {
|
||||||
@@ -33,13 +34,14 @@ class AssetPreloader {
|
|||||||
}
|
}
|
||||||
_prevStream?.removeListener(_dummyListener);
|
_prevStream?.removeListener(_dummyListener);
|
||||||
_nextStream?.removeListener(_dummyListener);
|
_nextStream?.removeListener(_dummyListener);
|
||||||
_prevStream = prev != null ? _resolveImage(prev, size) : null;
|
_prevStream = prev != null ? _resolveImage(prev, size, thumbnailSize) : null;
|
||||||
_nextStream = next != null ? _resolveImage(next, size) : null;
|
_nextStream = next != null ? _resolveImage(next, size, thumbnailSize) : null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageStream _resolveImage(BaseAsset asset, Size size) {
|
ImageStream _resolveImage(BaseAsset asset, Size size, Size? thumbnailSize) {
|
||||||
return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener);
|
return getFullImageProvider(asset, size: size, remoteThumbnailSize: thumbnailSize).resolve(ImageConfiguration.empty)
|
||||||
|
..addListener(_dummyListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
@override
|
@override
|
||||||
ConsumerState createState() => _AssetViewerState();
|
ConsumerState createState() => _AssetViewerState();
|
||||||
|
|
||||||
static void setAsset(WidgetRef ref, BaseAsset asset) {
|
/// Sets the asset and thumbnail size before opening the viewer.
|
||||||
|
static void setAsset(WidgetRef ref, BaseAsset asset, {Size? thumbnailSize}) {
|
||||||
ref.read(assetViewerProvider.notifier).reset();
|
ref.read(assetViewerProvider.notifier).reset();
|
||||||
|
|
||||||
// Hide controls by default for videos
|
// Hide controls by default for videos
|
||||||
@@ -72,11 +73,7 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_setAsset(ref, asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset, thumbnailSize: thumbnailSize);
|
||||||
}
|
|
||||||
|
|
||||||
static void _setAsset(WidgetRef ref, BaseAsset asset) {
|
|
||||||
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +159,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetInit(Duration timeStamp) {
|
void _onAssetInit(Duration timeStamp) {
|
||||||
_preloader.preload(widget.initialIndex, context.sizeData);
|
_preloader.preload(
|
||||||
|
widget.initialIndex,
|
||||||
|
context.sizeData,
|
||||||
|
thumbnailSize: ref.read(assetViewerProvider).thumbnailSize,
|
||||||
|
);
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +175,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AssetViewer._setAsset(ref, asset);
|
ref.read(assetViewerProvider.notifier).setAsset(asset);
|
||||||
_preloader.preload(index, context.sizeData);
|
_preloader.preload(index, context.sizeData, thumbnailSize: ref.read(assetViewerProvider).thumbnailSize);
|
||||||
_handleCasting();
|
_handleCasting();
|
||||||
_stackChildrenKeepAlive?.close();
|
_stackChildrenKeepAlive?.close();
|
||||||
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ ImageProvider getFullImageProvider(
|
|||||||
Size size = const Size(1080, 1920),
|
Size size = const Size(1080, 1920),
|
||||||
bool edited = true,
|
bool edited = true,
|
||||||
String? localFilePath,
|
String? localFilePath,
|
||||||
|
Size? remoteThumbnailSize,
|
||||||
}) {
|
}) {
|
||||||
// Create new provider and cache it
|
// Create new provider and cache it
|
||||||
final ImageProvider provider;
|
final ImageProvider provider;
|
||||||
@@ -178,13 +179,19 @@ ImageProvider getFullImageProvider(
|
|||||||
assetType: asset.type,
|
assetType: asset.type,
|
||||||
isAnimated: asset.isAnimatedImage,
|
isAnimated: asset.isAnimatedImage,
|
||||||
edited: edited,
|
edited: edited,
|
||||||
|
thumbnailSize: remoteThumbnailSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
|
ImageProvider? getThumbnailImageProvider(
|
||||||
|
BaseAsset asset, {
|
||||||
|
Size size = kThumbnailResolution,
|
||||||
|
Size? remoteSize,
|
||||||
|
bool edited = true,
|
||||||
|
}) {
|
||||||
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, size: size, assetType: asset.type);
|
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||||
@@ -192,7 +199,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
|
|||||||
|
|
||||||
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
|
||||||
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
|
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
|
||||||
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
|
return assetId != null
|
||||||
|
? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited, size: remoteSize)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
|||||||
final String url;
|
final String url;
|
||||||
final bool edited;
|
final bool edited;
|
||||||
|
|
||||||
RemoteImageProvider({required this.url, this.edited = true});
|
/// Physical size to decode, or null for the source size.
|
||||||
|
final Size? size;
|
||||||
|
|
||||||
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true})
|
RemoteImageProvider({required this.url, this.edited = true, this.size});
|
||||||
|
|
||||||
|
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true, this.size})
|
||||||
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
|
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -37,7 +40,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
|
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
|
||||||
final request = this.request = RemoteImageRequest(uri: key.url);
|
final request = this.request = RemoteImageRequest(uri: key.url, size: key.size);
|
||||||
return loadRequest(request, decode, isFinal: true);
|
return loadRequest(request, decode, isFinal: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,13 +50,13 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (other is RemoteImageProvider) {
|
if (other is RemoteImageProvider) {
|
||||||
return url == other.url && edited == other.edited;
|
return url == other.url && edited == other.edited && size == other.size;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => url.hashCode ^ edited.hashCode;
|
int get hashCode => url.hashCode ^ edited.hashCode ^ size.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||||
@@ -64,12 +67,16 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
final bool isAnimated;
|
final bool isAnimated;
|
||||||
final bool edited;
|
final bool edited;
|
||||||
|
|
||||||
|
/// Physical size of the thumbnail shown before the preview.
|
||||||
|
final Size? thumbnailSize;
|
||||||
|
|
||||||
RemoteFullImageProvider({
|
RemoteFullImageProvider({
|
||||||
required this.assetId,
|
required this.assetId,
|
||||||
required this.thumbhash,
|
required this.thumbhash,
|
||||||
required this.assetType,
|
required this.assetType,
|
||||||
required this.isAnimated,
|
required this.isAnimated,
|
||||||
this.edited = true,
|
this.edited = true,
|
||||||
|
this.thumbnailSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -83,7 +90,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
return AnimatedImageStreamCompleter(
|
return AnimatedImageStreamCompleter(
|
||||||
stream: _animatedCodec(key, decode),
|
stream: _animatedCodec(key, decode),
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
|
initialImage: getInitialImage(
|
||||||
|
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, size: key.thumbnailSize),
|
||||||
|
),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
@@ -96,7 +105,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
|||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getInitialImage(
|
initialImage: getInitialImage(
|
||||||
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
|
RemoteImageProvider.thumbnail(
|
||||||
|
assetId: key.assetId,
|
||||||
|
thumbhash: key.thumbhash,
|
||||||
|
edited: key.edited,
|
||||||
|
size: key.thumbnailSize,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
|
|||||||
@@ -25,17 +25,22 @@ class Thumbnail extends StatefulWidget {
|
|||||||
required String remoteId,
|
required String remoteId,
|
||||||
required String thumbhash,
|
required String thumbhash,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
Size size = kThumbnailResolution,
|
|
||||||
|
/// Physical size to decode, or null for the source size.
|
||||||
|
Size? size,
|
||||||
super.key,
|
super.key,
|
||||||
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
|
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash, size: size),
|
||||||
thumbhashProvider = null;
|
thumbhashProvider = null;
|
||||||
|
|
||||||
Thumbnail.fromAsset({
|
Thumbnail.fromAsset({
|
||||||
required BaseAsset? asset,
|
required BaseAsset? asset,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
|
|
||||||
/// The logical UI size of the thumbnail. This is only used to determine the ideal image resolution and does not affect the widget size.
|
/// Decode size for local thumbnails. This does not affect the widget size.
|
||||||
Size size = kThumbnailResolution,
|
Size size = kThumbnailResolution,
|
||||||
|
|
||||||
|
/// Physical size to decode for remote thumbnails.
|
||||||
|
Size? remoteSize,
|
||||||
super.key,
|
super.key,
|
||||||
}) : thumbhashProvider = switch (asset) {
|
}) : thumbhashProvider = switch (asset) {
|
||||||
RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider(
|
RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider(
|
||||||
@@ -43,7 +48,7 @@ class Thumbnail extends StatefulWidget {
|
|||||||
),
|
),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
|
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size, remoteSize: remoteSize);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<Thumbnail> createState() => _ThumbnailState();
|
State<Thumbnail> createState() => _ThumbnailState();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
|
|||||||
const ThumbnailTile(
|
const ThumbnailTile(
|
||||||
this.asset, {
|
this.asset, {
|
||||||
this.size = kThumbnailResolution,
|
this.size = kThumbnailResolution,
|
||||||
|
this.remoteSize,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
this.showStorageIndicator = false,
|
this.showStorageIndicator = false,
|
||||||
this.lockSelection = false,
|
this.lockSelection = false,
|
||||||
@@ -26,6 +27,9 @@ class ThumbnailTile extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
final BaseAsset? asset;
|
final BaseAsset? asset;
|
||||||
final Size size;
|
final Size size;
|
||||||
|
|
||||||
|
/// Physical size to decode for remote thumbnails.
|
||||||
|
final Size? remoteSize;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
final bool showStorageIndicator;
|
final bool showStorageIndicator;
|
||||||
final bool lockSelection;
|
final bool lockSelection;
|
||||||
@@ -108,7 +112,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
|
|||||||
// but other solutions have failed thus far.
|
// but other solutions have failed thus far.
|
||||||
key: ValueKey(isCurrentAsset),
|
key: ValueKey(isCurrentAsset),
|
||||||
tag: '${asset?.heroTag}_$heroIndex',
|
tag: '${asset?.heroTag}_$heroIndex',
|
||||||
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
|
child: Thumbnail.fromAsset(asset: asset, size: widget.size, remoteSize: widget.remoteSize),
|
||||||
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
|
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
|
||||||
placeholderBuilder: (context, heroSize, child) {
|
placeholderBuilder: (context, heroSize, child) {
|
||||||
if (!_hideIndicators) {
|
if (!_hideIndicators) {
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ const double kScrubberThumbHeight = 48.0;
|
|||||||
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
|
||||||
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
|
||||||
|
|
||||||
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
|
const Size kThumbnailResolution = Size.square(320);
|
||||||
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
|
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB
|
||||||
|
|||||||
@@ -144,19 +144,6 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
TimelineService timelineService,
|
TimelineService timelineService,
|
||||||
bool isDynamicLayout,
|
bool isDynamicLayout,
|
||||||
) {
|
) {
|
||||||
final children = [
|
|
||||||
for (int i = 0; i < assets.length; i++)
|
|
||||||
TimelineAssetIndexWrapper(
|
|
||||||
assetIndex: assetIndex + i,
|
|
||||||
segmentIndex: 0, // For simplicity, using 0 for now
|
|
||||||
child: _AssetTileWidget(
|
|
||||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
|
||||||
asset: assets[i],
|
|
||||||
assetIndex: assetIndex + i,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
final widths = List.filled(assets.length, tileHeight);
|
final widths = List.filled(assets.length, tileHeight);
|
||||||
|
|
||||||
if (isDynamicLayout) {
|
if (isDynamicLayout) {
|
||||||
@@ -186,6 +173,20 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final children = [
|
||||||
|
for (int i = 0; i < assets.length; i++)
|
||||||
|
TimelineAssetIndexWrapper(
|
||||||
|
assetIndex: assetIndex + i,
|
||||||
|
segmentIndex: 0, // For simplicity, using 0 for now
|
||||||
|
child: _AssetTileWidget(
|
||||||
|
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||||
|
asset: assets[i],
|
||||||
|
assetIndex: assetIndex + i,
|
||||||
|
size: Size(widths[i], tileHeight),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return TimelineDragRegion(
|
return TimelineDragRegion(
|
||||||
child: TimelineRow(
|
child: TimelineRow(
|
||||||
height: tileHeight,
|
height: tileHeight,
|
||||||
@@ -201,10 +202,18 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
class _AssetTileWidget extends ConsumerWidget {
|
class _AssetTileWidget extends ConsumerWidget {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
final int assetIndex;
|
final int assetIndex;
|
||||||
|
final Size size;
|
||||||
|
|
||||||
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
|
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size});
|
||||||
|
|
||||||
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
|
Future _handleOnTap(
|
||||||
|
BuildContext ctx,
|
||||||
|
WidgetRef ref,
|
||||||
|
int assetIndex,
|
||||||
|
BaseAsset asset,
|
||||||
|
int? heroOffset,
|
||||||
|
Size remoteSize,
|
||||||
|
) async {
|
||||||
final multiSelectState = ref.read(multiSelectProvider);
|
final multiSelectState = ref.read(multiSelectProvider);
|
||||||
|
|
||||||
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
|
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
|
||||||
@@ -212,7 +221,7 @@ 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;
|
||||||
AssetViewer.setAsset(ref, asset);
|
AssetViewer.setAsset(ref, asset, thumbnailSize: remoteSize);
|
||||||
unawaited(
|
unawaited(
|
||||||
ctx.pushRoute(
|
ctx.pushRoute(
|
||||||
AssetViewerRoute(
|
AssetViewerRoute(
|
||||||
@@ -252,6 +261,8 @@ class _AssetTileWidget extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final remoteSize = size * MediaQuery.devicePixelRatioOf(context);
|
||||||
|
|
||||||
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||||
|
|
||||||
final lockSelection = _getLockSelectionStatus(ref);
|
final lockSelection = _getLockSelectionStatus(ref);
|
||||||
@@ -261,10 +272,11 @@ class _AssetTileWidget extends ConsumerWidget {
|
|||||||
|
|
||||||
return RepaintBoundary(
|
return RepaintBoundary(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
|
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset, remoteSize),
|
||||||
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
|
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
|
||||||
child: ThumbnailTile(
|
child: ThumbnailTile(
|
||||||
asset,
|
asset,
|
||||||
|
remoteSize: remoteSize,
|
||||||
lockSelection: lockSelection,
|
lockSelection: lockSelection,
|
||||||
showStorageIndicator: showStorageIndicator,
|
showStorageIndicator: showStorageIndicator,
|
||||||
showStackIndicator: showStackIndicator,
|
showStackIndicator: showStackIndicator,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
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';
|
||||||
@@ -12,6 +13,9 @@ class AssetViewerState {
|
|||||||
final bool isZoomed;
|
final bool isZoomed;
|
||||||
final bool showingOcr;
|
final bool showingOcr;
|
||||||
final BaseAsset? currentAsset;
|
final BaseAsset? currentAsset;
|
||||||
|
|
||||||
|
/// Physical thumbnail size retained while paging through the viewer.
|
||||||
|
final Size? thumbnailSize;
|
||||||
final int stackIndex;
|
final int stackIndex;
|
||||||
|
|
||||||
const AssetViewerState({
|
const AssetViewerState({
|
||||||
@@ -21,6 +25,7 @@ class AssetViewerState {
|
|||||||
this.isZoomed = false,
|
this.isZoomed = false,
|
||||||
this.showingOcr = false,
|
this.showingOcr = false,
|
||||||
this.currentAsset,
|
this.currentAsset,
|
||||||
|
this.thumbnailSize,
|
||||||
this.stackIndex = 0,
|
this.stackIndex = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,6 +36,7 @@ class AssetViewerState {
|
|||||||
bool? isZoomed,
|
bool? isZoomed,
|
||||||
bool? showingOcr,
|
bool? showingOcr,
|
||||||
BaseAsset? currentAsset,
|
BaseAsset? currentAsset,
|
||||||
|
Size? thumbnailSize,
|
||||||
int? stackIndex,
|
int? stackIndex,
|
||||||
}) {
|
}) {
|
||||||
return AssetViewerState(
|
return AssetViewerState(
|
||||||
@@ -40,6 +46,7 @@ class AssetViewerState {
|
|||||||
isZoomed: isZoomed ?? this.isZoomed,
|
isZoomed: isZoomed ?? this.isZoomed,
|
||||||
showingOcr: showingOcr ?? this.showingOcr,
|
showingOcr: showingOcr ?? this.showingOcr,
|
||||||
currentAsset: currentAsset ?? this.currentAsset,
|
currentAsset: currentAsset ?? this.currentAsset,
|
||||||
|
thumbnailSize: thumbnailSize ?? this.thumbnailSize,
|
||||||
stackIndex: stackIndex ?? this.stackIndex,
|
stackIndex: stackIndex ?? this.stackIndex,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,6 +71,7 @@ class AssetViewerState {
|
|||||||
other.isZoomed == isZoomed &&
|
other.isZoomed == isZoomed &&
|
||||||
other.showingOcr == showingOcr &&
|
other.showingOcr == showingOcr &&
|
||||||
other.currentAsset == currentAsset &&
|
other.currentAsset == currentAsset &&
|
||||||
|
other.thumbnailSize == thumbnailSize &&
|
||||||
other.stackIndex == stackIndex;
|
other.stackIndex == stackIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +83,7 @@ class AssetViewerState {
|
|||||||
isZoomed.hashCode ^
|
isZoomed.hashCode ^
|
||||||
showingOcr.hashCode ^
|
showingOcr.hashCode ^
|
||||||
currentAsset.hashCode ^
|
currentAsset.hashCode ^
|
||||||
|
thumbnailSize.hashCode ^
|
||||||
stackIndex.hashCode;
|
stackIndex.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,11 +102,11 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
|
|||||||
state = const AssetViewerState();
|
state = const AssetViewerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAsset(BaseAsset asset) {
|
void setAsset(BaseAsset asset, {Size? thumbnailSize}) {
|
||||||
if (asset == state.currentAsset) {
|
if (asset == state.currentAsset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false);
|
state = state.copyWith(currentAsset: asset, thumbnailSize: thumbnailSize, stackIndex: 0, showingOcr: false);
|
||||||
_watchCurrentAsset(asset);
|
_watchCurrentAsset(asset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,15 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
)
|
)
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class RemoteImageApi {
|
abstract class RemoteImageApi {
|
||||||
|
/// Width and height are the physical decode size, or null for the source size.
|
||||||
@async
|
@async
|
||||||
Map<String, int>? requestImage(String url, {required int requestId, required bool preferEncoded});
|
Map<String, int>? requestImage(
|
||||||
|
String url, {
|
||||||
|
required int requestId,
|
||||||
|
required bool preferEncoded,
|
||||||
|
int? width,
|
||||||
|
int? height,
|
||||||
|
});
|
||||||
|
|
||||||
void cancelRequest(int requestId);
|
void cancelRequest(int requestId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
|
import 'package:immich_mobile/platform/remote_image_api.g.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = BasicMessageChannel<Object?>(
|
||||||
|
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage',
|
||||||
|
RemoteImageApi.pigeonChannelCodec,
|
||||||
|
);
|
||||||
|
late List<Object?> args;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, (
|
||||||
|
message,
|
||||||
|
) async {
|
||||||
|
args = message! as List<Object?>;
|
||||||
|
return <Object?>[null];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<ui.Image> loadEncoded(String path, ui.Size size) async {
|
||||||
|
final bytes = await File(path).readAsBytes();
|
||||||
|
final pointer = malloc<Uint8>(bytes.length)..asTypedList(bytes.length).setAll(0, bytes);
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, (
|
||||||
|
message,
|
||||||
|
) async {
|
||||||
|
return <Object?>[
|
||||||
|
<Object?, Object?>{'pointer': pointer.address, 'length': bytes.length},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
final request = RemoteImageRequest(uri: 'https://example.test/fallback', size: size);
|
||||||
|
|
||||||
|
return (await request.load((_, {getTargetSize}) => throw UnimplementedError()))!.image;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('passes the requested decode size to the platform', () async {
|
||||||
|
final request = RemoteImageRequest(uri: 'https://example.test/thumbnail', size: const ui.Size(319.1, 179.1));
|
||||||
|
|
||||||
|
await request.load((_, {getTargetSize}) => throw UnimplementedError());
|
||||||
|
|
||||||
|
expect(args[0], 'https://example.test/thumbnail');
|
||||||
|
expect(args[2], isFalse);
|
||||||
|
expect(args[3], 320);
|
||||||
|
expect(args[4], 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves requests without a size unbounded', () async {
|
||||||
|
final request = RemoteImageRequest(uri: 'https://example.test/thumbnail');
|
||||||
|
|
||||||
|
await request.load((_, {getTargetSize}) => throw UnimplementedError());
|
||||||
|
|
||||||
|
expect(args[3], isNull);
|
||||||
|
expect(args[4], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves encoded animation requests unbounded', () async {
|
||||||
|
final request = RemoteImageRequest(uri: 'https://example.test/animation', size: const ui.Size(320, 180));
|
||||||
|
|
||||||
|
await request.loadCodec();
|
||||||
|
|
||||||
|
expect(args[2], isTrue);
|
||||||
|
expect(args[3], isNull);
|
||||||
|
expect(args[4], isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps portrait cover quality in a wide tile', () async {
|
||||||
|
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size(963, 642));
|
||||||
|
|
||||||
|
expect(image.width, 963);
|
||||||
|
expect(image.height, 1453);
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves cover quality for extreme aspect ratios', () async {
|
||||||
|
final image = await loadEncoded('assets/immich-logo-inline-light.png', const ui.Size.square(320));
|
||||||
|
|
||||||
|
expect(image.width, 1311);
|
||||||
|
expect(image.height, 320);
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not upscale encoded fallback', () async {
|
||||||
|
final image = await loadEncoded('assets/feature_message/ocr.webp', const ui.Size.square(2000));
|
||||||
|
|
||||||
|
expect(image.width, 1206);
|
||||||
|
expect(image.height, 1819);
|
||||||
|
image.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the decode size in the provider cache key', () {
|
||||||
|
final small = RemoteImageProvider(url: 'https://example.test/thumbnail', size: const ui.Size.square(160));
|
||||||
|
final large = RemoteImageProvider(url: 'https://example.test/thumbnail', size: const ui.Size.square(320));
|
||||||
|
|
||||||
|
expect(small, isNot(large));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shares the cache key when no decode size is set', () {
|
||||||
|
final first = RemoteImageProvider(url: 'https://example.test/thumbnail');
|
||||||
|
final second = RemoteImageProvider(url: 'https://example.test/thumbnail');
|
||||||
|
|
||||||
|
expect(first, second);
|
||||||
|
expect(first.hashCode, second.hashCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@@ -107,5 +108,20 @@ void main() {
|
|||||||
await pumpEventQueue();
|
await pumpEventQueue();
|
||||||
expect(container.read(assetViewerProvider).currentAsset, updatedTwo);
|
expect(container.read(assetViewerProvider).currentAsset, updatedTwo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the tapped tile size while swiping', () {
|
||||||
|
final first = RemoteAssetFactory.create();
|
||||||
|
final second = RemoteAssetFactory.create();
|
||||||
|
final notifier = container.read(assetViewerProvider.notifier);
|
||||||
|
|
||||||
|
notifier.setAsset(first, thumbnailSize: const Size.square(642));
|
||||||
|
expect(container.read(assetViewerProvider).thumbnailSize, const Size.square(642));
|
||||||
|
|
||||||
|
notifier.setAsset(second);
|
||||||
|
expect(container.read(assetViewerProvider).thumbnailSize, const Size.square(642));
|
||||||
|
|
||||||
|
notifier.reset();
|
||||||
|
expect(container.read(assetViewerProvider).thumbnailSize, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user