fix(mobile): decode remote thumbnails at displayed size (#29965)

* fix(mobile): decode remote thumbnails at displayed size

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

* clean up decode size naming and guards
This commit is contained in:
Santo Shakil 2026-08-10 21:14:23 +06:00 committed by GitHub
parent c52bf9995b
commit 9862e50aab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 386 additions and 71 deletions

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:ffi/ffi.dart';
@ -36,7 +37,11 @@ abstract class ImageRequest {
void _onCancelled();
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(
int address,
int length, {
ui.Size? decodeSize,
}) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {
malloc.free(pointer);
@ -62,7 +67,8 @@ abstract class ImageRequest {
return null;
}
final codec = await descriptor.instantiateCodec();
final target = _targetSize(descriptor.width, descriptor.height, decodeSize);
final codec = await descriptor.instantiateCodec(targetWidth: target?.$1, targetHeight: target?.$2);
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
@ -72,8 +78,8 @@ abstract class ImageRequest {
return (codec, descriptor);
}
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
final result = await _codecFromEncodedPlatformImage(address, length);
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length, {ui.Size? decodeSize}) async {
final result = await _codecFromEncodedPlatformImage(address, length, decodeSize: decodeSize);
if (result == null) {
return null;
}
@ -96,6 +102,19 @@ abstract class ImageRequest {
return frame;
}
(int, int)? _targetSize(int width, int height, ui.Size? decodeSize) {
if (width <= 0 || height <= 0 || decodeSize == null || decodeSize.width <= 0 || decodeSize.height <= 0) {
return null;
}
final scale = math.max(decodeSize.width / width, decodeSize.height / height);
if (scale >= 1) {
return null;
}
return ((width * scale).ceil(), (height * scale).ceil());
}
Future<ui.FrameInfo?> _fromDecodedPlatformImage(int address, int width, int height, int rowBytes) async {
final pointer = Pointer<Uint8>.fromAddress(address);
if (_isCancelled) {

View file

@ -3,7 +3,10 @@ part of 'image_request.dart';
class RemoteImageRequest extends ImageRequest {
final String uri;
RemoteImageRequest({required this.uri});
/// Physical size to decode, or null for the source size.
final ui.Size? decodeSize;
RemoteImageRequest({required this.uri, this.decodeSize});
@override
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
@ -11,10 +14,20 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
final info = await remoteImageApi.requestImage(
uri,
requestId: requestId,
preferEncoded: false,
width: decodeSize?.width.ceil(),
height: decodeSize?.height.ceil(),
);
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
final frame = switch (info) {
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(pointer, length),
{'pointer': final int pointer, 'length': final int length} => await _fromEncodedPlatformImage(
pointer,
length,
decodeSize: decodeSize,
),
{
'pointer': final int pointer,
'width': final int width,
@ -33,7 +46,13 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: true);
final info = await remoteImageApi.requestImage(
uri,
requestId: requestId,
preferEncoded: true,
width: null,
height: null,
);
if (info == null) {
return null;
}

View file

@ -333,9 +333,15 @@ class _AssetPageState extends ConsumerState<AssetPage> {
required bool isCurrent,
required bool isPlayingMotionVideo,
required String? localFilePath,
required Size? remoteThumbnailSize,
}) {
final size = context.sizeData;
final imageProvider = getFullImageProvider(asset, size: size, localFilePath: localFilePath);
final imageProvider = getFullImageProvider(
asset,
size: size,
localFilePath: localFilePath,
remoteThumbnailSize: remoteThumbnailSize,
);
if (asset.isImage && !isPlayingMotionVideo) {
return PhotoView(
@ -397,7 +403,9 @@ class _AssetPageState extends ConsumerState<AssetPage> {
@override
Widget build(BuildContext context) {
final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
final (currentAsset, thumbnailSize) = ref.watch(
assetViewerProvider.select((s) => (s.currentAsset, s.thumbnailSize)),
);
_showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails));
final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex));
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
@ -454,6 +462,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
isCurrent: isCurrent,
isPlayingMotionVideo: isPlayingMotionVideo,
localFilePath: viewIntentFilePath,
remoteThumbnailSize: thumbnailSize,
),
),
if (showingOcr && displayAsset.width != null && displayAsset.height != null)

View file

@ -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() {

View file

@ -65,7 +65,8 @@ class AssetViewer extends ConsumerStatefulWidget {
@override
ConsumerState createState() => _AssetViewerState();
static void setAsset(WidgetRef ref, BaseAsset asset) {
/// Sets the asset and thumbnail size before opening the viewer.
static void setAsset(WidgetRef ref, BaseAsset asset, {Size? thumbnailSize}) {
ref.read(assetViewerProvider.notifier).reset();
// Hide controls by default for videos
@ -73,11 +74,7 @@ class AssetViewer extends ConsumerStatefulWidget {
ref.read(assetViewerProvider.notifier).setControls(false);
}
_setAsset(ref, asset);
}
static void _setAsset(WidgetRef ref, BaseAsset asset) {
ref.read(assetViewerProvider.notifier).setAsset(asset);
ref.read(assetViewerProvider.notifier).setAsset(asset, thumbnailSize: thumbnailSize);
}
}
@ -163,7 +160,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();
}
@ -181,8 +182,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();

View file

@ -155,6 +155,7 @@ ImageProvider getFullImageProvider(
Size size = const Size(1080, 1920),
bool edited = true,
String? localFilePath,
Size? remoteThumbnailSize,
}) {
// Create new provider and cache it
final ImageProvider provider;
@ -189,13 +190,21 @@ ImageProvider getFullImageProvider(
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
edited: edited,
thumbnailSize: remoteThumbnailSize,
);
}
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
ImageProvider? getThumbnailImageProvider(
BaseAsset asset, {
Size size = kThumbnailResolution,
/// Physical size to decode for remote thumbnails, or null for the source size.
Size? remoteSize,
bool edited = true,
}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type, checksum: asset.checksum);
@ -203,7 +212,9 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
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) : null;
return assetId != null
? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash, edited: edited, decodeSize: remoteSize)
: null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View file

@ -14,10 +14,17 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
final String url;
final bool edited;
RemoteImageProvider({required this.url, this.edited = true});
/// Physical size to decode, or null for the source size.
final Size? decodeSize;
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash, this.edited = true})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
RemoteImageProvider({required this.url, this.edited = true, this.decodeSize});
RemoteImageProvider.thumbnail({
required String assetId,
required String thumbhash,
this.edited = true,
this.decodeSize,
}) : url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash, edited: edited);
@override
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
@ -37,7 +44,7 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
}
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url);
final request = this.request = RemoteImageRequest(uri: key.url, decodeSize: key.decodeSize);
return loadRequest(request, decode, isFinal: true);
}
@ -47,13 +54,13 @@ class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
return true;
}
if (other is RemoteImageProvider) {
return url == other.url && edited == other.edited;
return url == other.url && edited == other.edited && decodeSize == other.decodeSize;
}
return false;
}
@override
int get hashCode => url.hashCode ^ edited.hashCode;
int get hashCode => url.hashCode ^ edited.hashCode ^ decodeSize.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
@ -64,12 +71,16 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final bool isAnimated;
final bool edited;
/// Physical size of the thumbnail shown before the preview.
final Size? thumbnailSize;
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
required this.isAnimated,
this.edited = true,
this.thumbnailSize,
});
@override
@ -83,7 +94,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return AnimatedImageStreamCompleter(
stream: _animatedCodec(key, decode),
scale: 1.0,
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, decodeSize: key.thumbnailSize),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@ -96,7 +109,12 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(
RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
RemoteImageProvider.thumbnail(
assetId: key.assetId,
thumbhash: key.thumbhash,
edited: key.edited,
decodeSize: key.thumbnailSize,
),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),

View file

@ -26,17 +26,22 @@ class Thumbnail extends StatefulWidget {
required String remoteId,
required String thumbhash,
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
/// Physical size to decode, or null for the source size.
Size? decodeSize,
super.key,
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash, decodeSize: decodeSize),
thumbhashProvider = null;
Thumbnail.fromAsset({
required BaseAsset? asset,
this.fit = BoxFit.cover,
/// The logical UI size of the thumbnail. This is only used to determine the ideal image resolution and does not affect the widget size.
/// Decode size for local thumbnails. This does not affect the widget size.
Size size = kThumbnailResolution,
/// Physical size to decode for remote thumbnails.
Size? remoteSize,
super.key,
}) : thumbhashProvider = switch (asset) {
RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider(
@ -44,7 +49,7 @@ class Thumbnail extends StatefulWidget {
),
_ => null,
},
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size);
imageProvider = asset == null ? null : getThumbnailImageProvider(asset, size: size, remoteSize: remoteSize);
@override
State<Thumbnail> createState() => _ThumbnailState();

View file

@ -16,6 +16,7 @@ class ThumbnailTile extends ConsumerStatefulWidget {
const ThumbnailTile(
this.asset, {
this.size = kThumbnailResolution,
this.remoteSize,
this.fit = BoxFit.cover,
this.showStorageIndicator = false,
this.lockSelection = false,
@ -26,6 +27,9 @@ 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;
final bool lockSelection;
@ -108,7 +112,7 @@ class _ThumbnailTileState extends ConsumerState<ThumbnailTile> {
// but other solutions have failed thus far.
key: ValueKey(isCurrentAsset),
tag: '${asset?.heroTag}_$heroIndex',
child: Thumbnail.fromAsset(asset: asset, size: widget.size),
child: Thumbnail.fromAsset(asset: asset, size: widget.size, remoteSize: widget.remoteSize),
// Placeholderbuilder used to hide indicators on first hero animation, since flightShuttleBuilder isn't called until both source and destination hero exist in widget tree.
placeholderBuilder: (context, heroSize, child) {
if (!_hideIndicators) {

View file

@ -9,5 +9,5 @@ const double kScrubberThumbHeight = 48.0;
const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300);
const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800);
const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const Size kThumbnailResolution = Size.square(320);
const kThumbnailDiskCacheSize = 1024 << 20; // 1GiB

View file

@ -144,19 +144,6 @@ class _FixedSegmentRow extends ConsumerWidget {
TimelineService timelineService,
bool isDynamicLayout,
) {
final children = [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
];
final widths = List.filled(assets.length, tileHeight);
if (isDynamicLayout) {
@ -186,6 +173,20 @@ class _FixedSegmentRow extends ConsumerWidget {
}
}
final children = [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
size: Size(widths[i], tileHeight),
),
),
];
return TimelineDragRegion(
child: TimelineRow(
height: tileHeight,
@ -201,10 +202,18 @@ class _FixedSegmentRow extends ConsumerWidget {
class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset;
final int assetIndex;
final Size size;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
Future _handleOnTap(
BuildContext ctx,
WidgetRef ref,
int assetIndex,
BaseAsset asset,
int? heroOffset,
Size remoteSize,
) async {
final multiSelectState = ref.read(multiSelectProvider);
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
@ -216,7 +225,7 @@ class _AssetTileWidget extends ConsumerWidget {
}
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
AssetViewer.setAsset(ref, asset);
AssetViewer.setAsset(ref, asset, thumbnailSize: remoteSize);
unawaited(
ctx.pushRoute(
AssetViewerRoute(
@ -256,6 +265,8 @@ class _AssetTileWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final remoteSize = size * MediaQuery.devicePixelRatioOf(context);
final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final lockSelection = _getLockSelectionStatus(ref);
@ -265,10 +276,11 @@ class _AssetTileWidget extends ConsumerWidget {
return RepaintBoundary(
child: GestureDetector(
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset),
onTap: () => lockSelection ? null : _handleOnTap(context, ref, assetIndex, asset, heroOffset, remoteSize),
onLongPress: () => lockSelection || isReadonlyModeEnabled ? null : _handleOnLongPress(ref, asset),
child: ThumbnailTile(
asset,
remoteSize: remoteSize,
lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator,
showStackIndicator: showStackIndicator,

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -18,6 +19,9 @@ abstract class AssetViewerState with _$AssetViewerState {
@Default(false) bool isZoomed,
@Default(false) bool showingOcr,
BaseAsset? currentAsset,
/// Physical thumbnail size retained while paging through the viewer.
Size? thumbnailSize,
@Default(0) int stackIndex,
}) = _AssetViewerState;
}
@ -37,11 +41,17 @@ class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
state = const AssetViewerState();
}
void setAsset(BaseAsset asset) {
void setAsset(BaseAsset asset, {Size? thumbnailSize}) {
if (asset == state.currentAsset) {
return;
}
state = state.copyWith(currentAsset: asset, stackIndex: 0, showingOcr: false);
// Swiping to a neighbor passes no size; keep the tapped tile's so neighbors reuse it.
state = state.copyWith(
currentAsset: asset,
thumbnailSize: thumbnailSize ?? state.thumbnailSize,
stackIndex: 0,
showingOcr: false,
);
_watchCurrentAsset(asset);
}