use nullable decode size and reuse the tapped thumbnail in the viewer

This commit is contained in:
Santo Shakil
2026-07-18 00:47:57 +06:00
parent ffd972b993
commit 10b8472ec2
22 changed files with 135 additions and 254 deletions
@@ -33,6 +33,7 @@ data class Request(
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)
inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0), exactSize: Boolean = false): Bitmap {
return ImageDecoder.decodeBitmap(this) { decoder, info, _ ->
@@ -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<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 clearCache(callback: (Result<Long>) -> 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<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
@@ -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<Map<String, Long>?>) -> 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 {
+5 -3
View File
@@ -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<Int64, Error>) -> 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):
@@ -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
@@ -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<Uint8>.fromAddress(address);
if (_isCancelled) {
@@ -78,7 +78,7 @@ abstract class ImageRequest {
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size size = ui.Size.zero}) async {
Future<ui.FrameInfo?> _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;
}
@@ -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<ImageInfo?> 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;
}
+3 -2
View File
@@ -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<Map<String, int>?> 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';
@@ -403,8 +403,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@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<AssetPage> {
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
remoteThumbnailSize: initialThumbnail?.tag == displayAsset.heroTag ? initialThumbnail!.size : null,
remoteThumbnailSize: thumbnailSize,
),
),
if (showingOcr && displayAsset.width != null && displayAsset.height != null)
@@ -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() {
@@ -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<AssetViewer> {
}
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<AssetViewer> {
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();
@@ -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;
}
@@ -13,16 +13,14 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
with CancellableImageProviderMixin<RemoteImageProvider> {
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<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -68,6 +66,8 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final AssetType assetType;
final bool isAnimated;
final bool edited;
/// Physical size of the thumbnail shown before the preview.
final Size? thumbnailSize;
RemoteFullImageProvider({
@@ -91,11 +91,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(
RemoteImageProvider.thumbnail(
assetId: key.assetId,
thumbhash: key.thumbhash,
size: key.thumbnailSize ?? Size.zero,
),
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, size: key.thumbnailSize),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
@@ -113,7 +109,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
assetId: key.assetId,
thumbhash: key.thumbhash,
edited: key.edited,
size: key.thumbnailSize ?? Size.zero,
size: key.thumbnailSize,
),
),
informationCollector: () => <DiagnosticsNode>[
@@ -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) {
@@ -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;
@@ -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());
}
@@ -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;
@@ -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<AssetViewerState> {
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);
}
+3 -2
View File
@@ -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<String, int>? requestImage(
String url, {
required int requestId,
required bool preferEncoded,
required int width,
required int height,
int? width,
int? height,
});
void cancelRequest(int requestId);
@@ -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);
});
}
@@ -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<BaseAsset> 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<Object?>(
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage',
RemoteImageApi.pigeonChannelCodec,
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockDecodedMessageHandler(
channel,
(_) async => <Object?>[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<ThumbnailTile>(find.byType(ThumbnailTile)).remoteSize, null);
});
}
@@ -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);
});
});
}