diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt index 0de194024d..d6dbf22f31 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/LocalImagesImpl.kt @@ -33,6 +33,7 @@ data class Request( val callback: (Result?>) -> Unit ) +/** Set [exactSize] to decode at the smallest size that covers [target] without upscaling. */ @RequiresApi(Build.VERSION_CODES.Q) inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap { return ImageDecoder.decodeBitmap(this) { decoder, info, _ -> diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt index d0a377a634..f3b61adcd9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImages.g.kt @@ -47,7 +47,8 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() { /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface RemoteImageApi { - fun requestImage(url: String, requestId: Long, preferEncoded: Boolean, width: Long, height: Long, callback: (Result?>) -> 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?>) -> Unit) fun cancelRequest(requestId: Long) fun clearCache(callback: (Result) -> Unit) @@ -68,8 +69,8 @@ interface RemoteImageApi { val urlArg = args[0] as String val requestIdArg = args[1] as Long val preferEncodedArg = args[2] as Boolean - val widthArg = args[3] as Long - val heightArg = args[4] as Long + val widthArg = args[3] as Long? + val heightArg = args[4] as Long? api.requestImage(urlArg, requestIdArg, preferEncodedArg, widthArg, heightArg) { result: Result?> -> val error = result.exceptionOrNull() if (error != null) { diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt index 148cdeb929..cdc82c871c 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/images/RemoteImagesImpl.kt @@ -80,8 +80,8 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { url: String, requestId: Long, preferEncoded: Boolean, - width: Long, - height: Long, + width: Long?, + height: Long?, callback: (Result?>) -> Unit ) { val signal = CancellationSignal() @@ -105,15 +105,21 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi { if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { decodeExecutor.execute { val res = if (signal.isCanceled) null else try { - val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset)) - val target = Size(width.toInt(), height.toInt()) - val bitmap = source.decodeBitmap(target, exactSize = true) // The embedded preview a raw decodes to has no orientation, so read the container's. val orientation = if (isRawMime(contentType)) { readRawOrientation(NativeBuffer.wrap(buffer.pointer, buffer.offset), buffer.offset) } else { 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) { bitmap.toNativeBuffer() } else { diff --git a/mobile/ios/Runner/Images/RemoteImages.g.swift b/mobile/ios/Runner/Images/RemoteImages.g.swift index eb1bf01b13..238aeca2db 100644 --- a/mobile/ios/Runner/Images/RemoteImages.g.swift +++ b/mobile/ios/Runner/Images/RemoteImages.g.swift @@ -70,7 +70,8 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable /// Generated protocol from Pigeon that represents a handler of messages from Flutter. protocol RemoteImageApi { - func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64, height: Int64, 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 clearCache(completion: @escaping (Result) -> Void) } @@ -81,6 +82,7 @@ class RemoteImageApiSetup { /// Sets up an instance of `RemoteImageApi` to handle messages through the `binaryMessenger`. static func setUp(binaryMessenger: FlutterBinaryMessenger, api: RemoteImageApi?, messageChannelSuffix: String = "") { 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) if let api = api { requestImageChannel.setMessageHandler { message, reply in @@ -88,8 +90,8 @@ class RemoteImageApiSetup { let urlArg = args[0] as! String let requestIdArg = args[1] as! Int64 let preferEncodedArg = args[2] as! Bool - let widthArg = args[3] as! Int64 - let heightArg = args[4] as! Int64 + 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 { case .success(let res): diff --git a/mobile/ios/Runner/Images/RemoteImagesImpl.swift b/mobile/ios/Runner/Images/RemoteImagesImpl.swift index b7de76cb86..d6b2ce1b09 100644 --- a/mobile/ios/Runner/Images/RemoteImagesImpl.swift +++ b/mobile/ios/Runner/Images/RemoteImagesImpl.swift @@ -35,7 +35,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { kCGImageSourceCreateThumbnailFromImageAlways: true ] - func requestImage(url: String, requestId: Int64, preferEncoded: Bool, width: Int64, height: Int64, 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)!) urlRequest.cachePolicy = .returnCacheDataElseLoad @@ -50,7 +50,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { task.resume() } - private static func handleCompletion(request: RemoteImageRequest, encoded: Bool, width: Int64, height: Int64, 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 { return request.completion(ImageProcessing.cancelledResult) } @@ -94,7 +94,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } var options = decodeOptions - if let maxPixelSize = targetMaxPixelSize(imageSource: imageSource, width: width, height: height) { + if let maxPixelSize = targetThumbnailRenderSize(imageSource: imageSource, width: width, height: height) { options[kCGImageSourceThumbnailMaxPixelSize] = maxPixelSize } @@ -130,8 +130,11 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { } } - private static func targetMaxPixelSize(imageSource: CGImageSource, width: Int64, height: Int64) -> Int? { - guard width > 0, + /// 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, @@ -139,9 +142,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi { return nil } - let orientation = (properties[kCGImagePropertyOrientation] as? NSNumber)?.intValue ?? 1 - let sourceWidth = (5...8).contains(orientation) ? pixelHeight.doubleValue : pixelWidth.doubleValue - let sourceHeight = (5...8).contains(orientation) ? pixelWidth.doubleValue : pixelHeight.doubleValue + 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 diff --git a/mobile/lib/infrastructure/loaders/image_request.dart b/mobile/lib/infrastructure/loaders/image_request.dart index 1a0075d93a..bbbbbef72d 100644 --- a/mobile/lib/infrastructure/loaders/image_request.dart +++ b/mobile/lib/infrastructure/loaders/image_request.dart @@ -40,7 +40,7 @@ abstract class ImageRequest { Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage( int address, int length, { - ui.Size size = ui.Size.zero, + ui.Size? size, }) async { final pointer = Pointer.fromAddress(address); if (_isCancelled) { @@ -78,7 +78,7 @@ abstract class ImageRequest { return (codec, descriptor); } - Future _fromEncodedPlatformImage(int address, int length, {ui.Size size = ui.Size.zero}) async { + Future _fromEncodedPlatformImage(int address, int length, {ui.Size? size}) async { final result = await _codecFromEncodedPlatformImage(address, length, size: size); if (result == null) { return null; @@ -102,8 +102,8 @@ abstract class ImageRequest { return frame; } - (int, int)? _targetSize(int width, int height, ui.Size size) { - if (size.width <= 0 || size.height <= 0) { + (int, int)? _targetSize(int width, int height, ui.Size? size) { + if (size == null || size.width <= 0 || size.height <= 0) { return null; } diff --git a/mobile/lib/infrastructure/loaders/remote_image_request.dart b/mobile/lib/infrastructure/loaders/remote_image_request.dart index 6827bc76e2..4e2ad89d10 100644 --- a/mobile/lib/infrastructure/loaders/remote_image_request.dart +++ b/mobile/lib/infrastructure/loaders/remote_image_request.dart @@ -2,9 +2,11 @@ part of 'image_request.dart'; class RemoteImageRequest extends ImageRequest { final String uri; - final ui.Size size; - RemoteImageRequest({required this.uri, this.size = ui.Size.zero}); + /// Physical size to decode, or null for the source size. + final ui.Size? size; + + RemoteImageRequest({required this.uri, this.size}); @override Future load(ImageDecoderCallback decode, {double scale = 1.0}) async { @@ -16,8 +18,8 @@ class RemoteImageRequest extends ImageRequest { uri, requestId: requestId, preferEncoded: false, - width: size.width.ceil(), - height: size.height.ceil(), + 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. final frame = switch (info) { @@ -35,7 +37,13 @@ class RemoteImageRequest extends ImageRequest { return null; } - final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true, width: 0, height: 0); + final info = await remoteImageApi.requestImage( + uri, + requestId: requestId, + preferEncoded: true, + width: null, + height: null, + ); if (info == null) { return null; } diff --git a/mobile/lib/platform/remote_image_api.g.dart b/mobile/lib/platform/remote_image_api.g.dart index 64d56bb882..f8a965356c 100644 --- a/mobile/lib/platform/remote_image_api.g.dart +++ b/mobile/lib/platform/remote_image_api.g.dart @@ -60,12 +60,13 @@ class RemoteImageApi { final String pigeonVar_messageChannelSuffix; + /// Width and height are the physical decode size, or null for the source size. Future?> requestImage( String url, { required int requestId, required bool preferEncoded, - required int width, - required int height, + int? width, + int? height, }) async { final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix'; diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 92633661b8..faf03b7f20 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -403,8 +403,8 @@ class _AssetPageState extends ConsumerState { @override Widget build(BuildContext context) { - final (currentAsset, initialThumbnail) = ref.watch( - assetViewerProvider.select((s) => (s.currentAsset, s.initialThumbnail)), + final (currentAsset, thumbnailSize) = ref.watch( + assetViewerProvider.select((s) => (s.currentAsset, s.thumbnailSize)), ); _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); @@ -461,7 +461,7 @@ class _AssetPageState extends ConsumerState { isCurrent: isCurrent, isPlayingMotionVideo: isPlayingMotionVideo, localFilePath: viewIntentFilePath, - remoteThumbnailSize: initialThumbnail?.tag == displayAsset.heroTag ? initialThumbnail!.size : null, + remoteThumbnailSize: thumbnailSize, ), ), if (showingOcr && displayAsset.width != null && displayAsset.height != null) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart index 4d1856b90d..8c153e9995 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_preloader.dart @@ -17,7 +17,8 @@ class AssetPreloader { 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)); _timer?.cancel(); _timer = Timer(Durations.medium4, () async { @@ -33,13 +34,14 @@ class AssetPreloader { } _prevStream?.removeListener(_dummyListener); _nextStream?.removeListener(_dummyListener); - _prevStream = prev != null ? _resolveImage(prev, size) : null; - _nextStream = next != null ? _resolveImage(next, size) : null; + _prevStream = prev != null ? _resolveImage(prev, size, thumbnailSize) : null; + _nextStream = next != null ? _resolveImage(next, size, thumbnailSize) : null; }); } - ImageStream _resolveImage(BaseAsset asset, Size size) { - return getFullImageProvider(asset, size: size).resolve(ImageConfiguration.empty)..addListener(_dummyListener); + ImageStream _resolveImage(BaseAsset asset, Size size, Size? thumbnailSize) { + return getFullImageProvider(asset, size: size, remoteThumbnailSize: thumbnailSize).resolve(ImageConfiguration.empty) + ..addListener(_dummyListener); } void dispose() { diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 558af8361f..39479b56c0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -64,6 +64,7 @@ class AssetViewer extends ConsumerStatefulWidget { @override ConsumerState createState() => _AssetViewerState(); + /// Sets the asset and thumbnail size before opening the viewer. static void setAsset(WidgetRef ref, BaseAsset asset, {Size? thumbnailSize}) { ref.read(assetViewerProvider.notifier).reset(); @@ -72,10 +73,6 @@ class AssetViewer extends ConsumerStatefulWidget { ref.read(assetViewerProvider.notifier).setControls(false); } - _setAsset(ref, asset, thumbnailSize: thumbnailSize); - } - - static void _setAsset(WidgetRef ref, BaseAsset asset, {Size? thumbnailSize}) { ref.read(assetViewerProvider.notifier).setAsset(asset, thumbnailSize: thumbnailSize); } } @@ -162,7 +159,11 @@ class _AssetViewerState extends ConsumerState { } void _onAssetInit(Duration timeStamp) { - _preloader.preload(widget.initialIndex, context.sizeData); + _preloader.preload( + widget.initialIndex, + context.sizeData, + thumbnailSize: ref.read(assetViewerProvider).thumbnailSize, + ); _handleCasting(); } @@ -174,8 +175,8 @@ class _AssetViewerState extends ConsumerState { return; } - AssetViewer._setAsset(ref, asset); - _preloader.preload(index, context.sizeData); + ref.read(assetViewerProvider.notifier).setAsset(asset); + _preloader.preload(index, context.sizeData, thumbnailSize: ref.read(assetViewerProvider).thumbnailSize); _handleCasting(); _stackChildrenKeepAlive?.close(); _stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive(); diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index 8b46840c0a..05db05b80a 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -200,12 +200,7 @@ ImageProvider? getThumbnailImageProvider( final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId; final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : ""; return assetId != null - ? RemoteImageProvider.thumbnail( - assetId: assetId, - thumbhash: thumbhash, - edited: edited, - size: remoteSize ?? Size.zero, - ) + ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited, size: remoteSize) : null; } diff --git a/mobile/lib/presentation/widgets/images/remote_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart index 0a825aff7c..b2dbc32f01 100644 --- a/mobile/lib/presentation/widgets/images/remote_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -13,16 +13,14 @@ class RemoteImageProvider extends CancellableImageProvider with CancellableImageProviderMixin { final String url; final bool edited; - final Size size; - RemoteImageProvider({required this.url, this.edited = true, this.size = Size.zero}); + /// Physical size to decode, or null for the source size. + final Size? size; - RemoteImageProvider.thumbnail({ - required String assetId, - required String thumbhash, - this.edited = true, - this.size = Size.zero, - }) : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited); + 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); @override Future obtainKey(ImageConfiguration configuration) { @@ -68,6 +66,8 @@ class RemoteFullImageProvider extends CancellableImageProvider [ DiagnosticsProperty('Image provider', this), @@ -113,7 +109,7 @@ class RemoteFullImageProvider extends CancellableImageProvider [ diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index 535868b13e..1e549cd981 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -25,7 +25,9 @@ class Thumbnail extends StatefulWidget { required String remoteId, required String thumbhash, this.fit = BoxFit.cover, - Size size = Size.zero, + + /// Physical size to decode, or null for the source size. + Size? size, super.key, }) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash, size: size), thumbhashProvider = null; @@ -33,9 +35,11 @@ class Thumbnail extends StatefulWidget { Thumbnail.fromAsset({ required BaseAsset? asset, this.fit = BoxFit.cover, + + /// Decode size for local thumbnails. This does not affect the widget size. Size size = kThumbnailResolution, - /// The physical decode size for remote thumbnails. This does not affect the widget size. + /// Physical size to decode for remote thumbnails. Size? remoteSize, super.key, }) : thumbhashProvider = switch (asset) { diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index c3b439f8cd..05f40e7903 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -27,6 +27,8 @@ class ThumbnailTile extends ConsumerStatefulWidget { final BaseAsset? asset; final Size size; + + /// Physical size to decode for remote thumbnails. final Size? remoteSize; final BoxFit fit; final bool showStorageIndicator; diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart index 35b61cfc52..86c44d4a7f 100644 --- a/mobile/lib/presentation/widgets/timeline/constants.dart +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -11,13 +11,3 @@ const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); const Size kThumbnailResolution = Size.square(320); const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB - -Size getThumbnailResolution(Size size, double pixelRatio) { - final width = size.width * pixelRatio; - final height = size.height * pixelRatio; - if (!width.isFinite || !height.isFinite || width <= 0 || height <= 0) { - return Size.zero; - } - - return Size(width.ceilToDouble(), height.ceilToDouble()); -} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 54bc7a448d..bc066e6ee3 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -10,7 +10,6 @@ import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/constants.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/segment.model.dart'; @@ -213,7 +212,7 @@ class _AssetTileWidget extends ConsumerWidget { int assetIndex, BaseAsset asset, int? heroOffset, - Size? remoteSize, + Size remoteSize, ) async { final multiSelectState = ref.read(multiSelectProvider); @@ -262,8 +261,7 @@ class _AssetTileWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final resolution = getThumbnailResolution(size, MediaQuery.devicePixelRatioOf(context)); - final remoteSize = resolution == Size.zero ? null : resolution; + final remoteSize = size * MediaQuery.devicePixelRatioOf(context); final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0; diff --git a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart index c65937139c..c3023ea248 100644 --- a/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart +++ b/mobile/lib/providers/asset_viewer/asset_viewer.provider.dart @@ -13,7 +13,9 @@ class AssetViewerState { final bool isZoomed; final bool showingOcr; final BaseAsset? currentAsset; - final ({String tag, Size size})? initialThumbnail; + + /// Physical thumbnail size retained while paging through the viewer. + final Size? thumbnailSize; final int stackIndex; const AssetViewerState({ @@ -23,7 +25,7 @@ class AssetViewerState { this.isZoomed = false, this.showingOcr = false, this.currentAsset, - this.initialThumbnail, + this.thumbnailSize, this.stackIndex = 0, }); @@ -34,7 +36,7 @@ class AssetViewerState { bool? isZoomed, bool? showingOcr, BaseAsset? currentAsset, - ({String tag, Size size})? initialThumbnail, + Size? thumbnailSize, int? stackIndex, }) { return AssetViewerState( @@ -44,7 +46,7 @@ class AssetViewerState { isZoomed: isZoomed ?? this.isZoomed, showingOcr: showingOcr ?? this.showingOcr, currentAsset: currentAsset ?? this.currentAsset, - initialThumbnail: initialThumbnail ?? this.initialThumbnail, + thumbnailSize: thumbnailSize ?? this.thumbnailSize, stackIndex: stackIndex ?? this.stackIndex, ); } @@ -69,7 +71,7 @@ class AssetViewerState { other.isZoomed == isZoomed && other.showingOcr == showingOcr && other.currentAsset == currentAsset && - other.initialThumbnail == initialThumbnail && + other.thumbnailSize == thumbnailSize && other.stackIndex == stackIndex; } @@ -81,7 +83,7 @@ class AssetViewerState { isZoomed.hashCode ^ showingOcr.hashCode ^ currentAsset.hashCode ^ - initialThumbnail.hashCode ^ + thumbnailSize.hashCode ^ stackIndex.hashCode; } @@ -104,12 +106,7 @@ class AssetViewerStateNotifier extends Notifier { if (asset == state.currentAsset) { return; } - state = state.copyWith( - currentAsset: asset, - initialThumbnail: thumbnailSize == null ? null : (tag: asset.heroTag, size: thumbnailSize), - stackIndex: 0, - showingOcr: false, - ); + state = state.copyWith(currentAsset: asset, thumbnailSize: thumbnailSize, stackIndex: 0, showingOcr: false); _watchCurrentAsset(asset); } diff --git a/mobile/pigeon/remote_image_api.dart b/mobile/pigeon/remote_image_api.dart index bc2f96604c..277b7016fd 100644 --- a/mobile/pigeon/remote_image_api.dart +++ b/mobile/pigeon/remote_image_api.dart @@ -13,13 +13,14 @@ import 'package:pigeon/pigeon.dart'; ) @HostApi() abstract class RemoteImageApi { + /// Width and height are the physical decode size, or null for the source size. @async Map? requestImage( String url, { required int requestId, required bool preferEncoded, - required int width, - required int height, + int? width, + int? height, }); void cancelRequest(int requestId); diff --git a/mobile/test/infrastructure/loaders/remote_image_request_test.dart b/mobile/test/infrastructure/loaders/remote_image_request_test.dart index 66d953f842..fd1a4d6ac0 100644 --- a/mobile/test/infrastructure/loaders/remote_image_request_test.dart +++ b/mobile/test/infrastructure/loaders/remote_image_request_test.dart @@ -47,7 +47,7 @@ void main() { } test('passes the requested decode size to the platform', () async { - final request = RemoteImageRequest(uri: 'https://example.test/thumbnail', size: const ui.Size(320, 180)); + final request = RemoteImageRequest(uri: 'https://example.test/thumbnail', size: const ui.Size(319.1, 179.1)); await request.load((_, {getTargetSize}) => throw UnimplementedError()); @@ -57,14 +57,23 @@ void main() { 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], 0); - expect(args[4], 0); + expect(args[3], isNull); + expect(args[4], isNull); }); test('keeps portrait cover quality in a wide tile', () async { @@ -97,4 +106,12 @@ void main() { 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); + }); } diff --git a/mobile/test/presentation/widgets/images/thumbnail_resolution_test.dart b/mobile/test/presentation/widgets/images/thumbnail_resolution_test.dart deleted file mode 100644 index 1d1f859908..0000000000 --- a/mobile/test/presentation/widgets/images/thumbnail_resolution_test.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.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/config/app_config.dart'; -import 'package:immich_mobile/domain/models/store.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/store.service.dart'; -import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; -import 'package:immich_mobile/infrastructure/repositories/store.repository.dart'; -import 'package:immich_mobile/platform/remote_image_api.g.dart'; -import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; -import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; -import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; - -import '../../../test_utils.dart'; - -class _TimelineService implements TimelineService { - final BaseAsset asset; - - const _TimelineService(this.asset); - - @override - bool hasRange(int index, int count) => true; - - @override - List getAssets(int index, int count) => [asset]; - - @override - TimelineOrigin get origin => TimelineOrigin.main; - - @override - dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); -} - -class _ReadOnlyModeNotifier extends ReadOnlyModeNotifier { - @override - bool build() => false; -} - -void main() { - late Drift db; - - setUpAll(() async { - TestWidgetsFlutterBinding.ensureInitialized(); - db = Drift(DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true)); - await StoreService.init(storeRepository: DriftStoreRepository(db), listenUpdates: false); - await StoreService.I.put(StoreKey.serverEndpoint, 'https://example.test'); - }); - - tearDownAll(() async { - await StoreService.I.dispose(); - await db.close(); - }); - - test('uses the physical tile size', () { - expect(getThumbnailResolution(const Size.square(214), 3), const Size.square(642)); - expect(getThumbnailResolution(const Size.square(186.5), 3), const Size.square(560)); - expect(getThumbnailResolution(const Size(321, 214), 3), const Size(963, 642)); - }); - - test('does not cap large physical tiles', () { - expect(getThumbnailResolution(const Size(500, 250), 3), const Size(1500, 750)); - }); - - test('marks invalid tile sizes as not ready', () { - expect(getThumbnailResolution(Size.zero, 3), Size.zero); - expect(getThumbnailResolution(const Size(double.infinity, 100), 3), Size.zero); - }); - - test('changes only timeline remote thumbnail size', () { - final remote = TestUtils.createRemoteAsset(id: 'remote'); - final local = TestUtils.createLocalAsset(id: 'local'); - final grid = Thumbnail.fromAsset(asset: remote, remoteSize: const Size.square(642)); - final other = Thumbnail.fromAsset(asset: remote); - final localGrid = Thumbnail.fromAsset(asset: local, remoteSize: const Size.square(642)); - final viewer = getFullImageProvider(remote, remoteThumbnailSize: const Size.square(642)); - - expect((grid.imageProvider! as RemoteImageProvider).size, const Size.square(642)); - expect((other.imageProvider! as RemoteImageProvider).size, Size.zero); - expect((localGrid.imageProvider! as LocalThumbProvider).size, kThumbnailResolution); - expect((viewer as RemoteFullImageProvider).thumbnailSize, const Size.square(642)); - }); - - testWidgets('keeps the tile while its remote decode size is unavailable', (tester) async { - const channel = BasicMessageChannel( - 'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage', - RemoteImageApi.pigeonChannelCodec, - ); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler( - channel, - (_) async => [null], - ); - addTearDown( - () => - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(channel, null), - ); - final asset = TestUtils.createRemoteAsset(id: 'remote'); - final service = _TimelineService(asset); - const segment = FixedSegment( - firstIndex: 0, - lastIndex: 1, - startOffset: 0, - endOffset: 100, - firstAssetIndex: 0, - bucket: Bucket(assetCount: 1), - tileHeight: 100, - columnCount: 4, - headerExtent: 0, - spacing: 0, - header: HeaderType.none, - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - timelineServiceProvider.overrideWithValue(service), - timelineArgsProvider.overrideWithValue(const TimelineArgs(maxWidth: 100, maxHeight: 100)), - appConfigProvider.overrideWithValue(const AppConfig()), - readonlyModeProvider.overrideWith(_ReadOnlyModeNotifier.new), - ], - child: MaterialApp( - home: Align( - alignment: Alignment.topLeft, - child: SizedBox.square( - dimension: 100, - child: MediaQuery( - data: const MediaQueryData(devicePixelRatio: 0), - child: Builder(builder: (context) => segment.builder(context, 1)), - ), - ), - ), - ), - ), - ); - - expect(find.byType(ThumbnailTile), findsOneWidget); - expect(tester.widget(find.byType(ThumbnailTile)).remoteSize, null); - }); -} diff --git a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart index 9a6f54aa5e..9d593c6933 100644 --- a/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart +++ b/mobile/test/providers/asset_viewer/asset_viewer_provider_test.dart @@ -109,19 +109,19 @@ void main() { expect(container.read(assetViewerProvider).currentAsset, updatedTwo); }); - test('keeps the tapped tile size scoped to its asset', () { + 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).initialThumbnail, (tag: first.heroTag, size: const Size.square(642))); + expect(container.read(assetViewerProvider).thumbnailSize, const Size.square(642)); notifier.setAsset(second); - expect(container.read(assetViewerProvider).initialThumbnail?.tag, first.heroTag); + expect(container.read(assetViewerProvider).thumbnailSize, const Size.square(642)); notifier.reset(); - expect(container.read(assetViewerProvider).initialThumbnail, isNull); + expect(container.read(assetViewerProvider).thumbnailSize, isNull); }); }); }