mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
use nullable decode size and reuse the tapped thumbnail in the viewer
This commit is contained in:
@@ -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, _ ->
|
||||
|
||||
+4
-3
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user