mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat(mobile): optimized thumbnail widget (#21073)
* thumbnail widget * use animation ticker, improvements * use static thumbnail resolution for now * fix android sample size * free memory sooner * formatting * tweaks * wait for disposal * remove debug prints * take two on animation * fix * remote constructor * missed one * unused imports * unnecessary import * formatting
This commit is contained in:
parent
ab2849781a
commit
fb59fa343d
18 changed files with 421 additions and 125 deletions
|
|
@ -221,8 +221,8 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
|
||||||
return 1 shl max(
|
return 1 shl max(
|
||||||
0, floor(
|
0, floor(
|
||||||
min(
|
min(
|
||||||
log2(fullWidth / (2.0 * reqWidth)),
|
log2(fullWidth / reqWidth.toDouble()),
|
||||||
log2(fullHeight / (2.0 * reqHeight)),
|
log2(fullHeight / reqHeight.toDouble()),
|
||||||
)
|
)
|
||||||
).toInt()
|
).toInt()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ const String kDownloadGroupLivePhoto = 'group_livephoto';
|
||||||
|
|
||||||
// Timeline constants
|
// Timeline constants
|
||||||
const int kTimelineNoneSegmentSize = 120;
|
const int kTimelineNoneSegmentSize = 120;
|
||||||
const int kTimelineAssetLoadBatchSize = 256;
|
const int kTimelineAssetLoadBatchSize = 1024;
|
||||||
const int kTimelineAssetLoadOppositeSize = 64;
|
const int kTimelineAssetLoadOppositeSize = 64;
|
||||||
|
|
||||||
// Widget keys
|
// Widget keys
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:ffi/ffi.dart';
|
import 'package:ffi/ffi.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
|
||||||
|
|
@ -41,41 +40,47 @@ abstract class ImageRequest {
|
||||||
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
|
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
|
||||||
final address = info['pointer'];
|
final address = info['pointer'];
|
||||||
if (address == null) {
|
if (address == null) {
|
||||||
if (!kReleaseMode) {
|
|
||||||
debugPrint('Platform image request for $requestId was cancelled');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||||
|
if (_isCancelled) {
|
||||||
|
malloc.free(pointer);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int actualWidth;
|
||||||
|
final int actualHeight;
|
||||||
|
final int actualSize;
|
||||||
|
final ui.ImmutableBuffer buffer;
|
||||||
try {
|
try {
|
||||||
if (_isCancelled) {
|
actualWidth = info['width']!;
|
||||||
return null;
|
actualHeight = info['height']!;
|
||||||
}
|
actualSize = actualWidth * actualHeight * 4;
|
||||||
|
buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
|
||||||
final actualWidth = info['width']!;
|
|
||||||
final actualHeight = info['height']!;
|
|
||||||
final actualSize = actualWidth * actualHeight * 4;
|
|
||||||
|
|
||||||
final buffer = await ImmutableBuffer.fromUint8List(pointer.asTypedList(actualSize));
|
|
||||||
if (_isCancelled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final descriptor = ui.ImageDescriptor.raw(
|
|
||||||
buffer,
|
|
||||||
width: actualWidth,
|
|
||||||
height: actualHeight,
|
|
||||||
pixelFormat: ui.PixelFormat.rgba8888,
|
|
||||||
);
|
|
||||||
final codec = await descriptor.instantiateCodec();
|
|
||||||
if (_isCancelled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await codec.getNextFrame();
|
|
||||||
} finally {
|
} finally {
|
||||||
malloc.free(pointer);
|
malloc.free(pointer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_isCancelled) {
|
||||||
|
buffer.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final descriptor = ui.ImageDescriptor.raw(
|
||||||
|
buffer,
|
||||||
|
width: actualWidth,
|
||||||
|
height: actualHeight,
|
||||||
|
pixelFormat: ui.PixelFormat.rgba8888,
|
||||||
|
);
|
||||||
|
final codec = await descriptor.instantiateCodec();
|
||||||
|
if (_isCancelled) {
|
||||||
|
buffer.dispose();
|
||||||
|
descriptor.dispose();
|
||||||
|
codec.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await codec.getNextFrame();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ part of 'image_request.dart';
|
||||||
|
|
||||||
class RemoteImageRequest extends ImageRequest {
|
class RemoteImageRequest extends ImageRequest {
|
||||||
static final log = Logger('RemoteImageRequest');
|
static final log = Logger('RemoteImageRequest');
|
||||||
static final client = HttpClient()..maxConnectionsPerHost = 32;
|
static final client = HttpClient()..maxConnectionsPerHost = 16;
|
||||||
final RemoteCacheManager? cacheManager;
|
final RemoteCacheManager? cacheManager;
|
||||||
final String uri;
|
final String uri;
|
||||||
final Map<String, String> headers;
|
final Map<String, String> headers;
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class DriftBackupAssetDetailPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
child: Thumbnail(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
|
child: Thumbnail.fromAsset(asset: asset, size: const Size(64, 64), fit: BoxFit.cover),
|
||||||
),
|
),
|
||||||
trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)),
|
trailing: const Padding(padding: EdgeInsets.only(right: 24, left: 8), child: Icon(Icons.image_search)),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
|
|
||||||
|
|
@ -224,7 +224,7 @@ class FileDetailDialog extends ConsumerWidget {
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
),
|
),
|
||||||
child: asset != null
|
child: asset != null
|
||||||
? Thumbnail(asset: asset, size: const Size(512, 512), fit: BoxFit.cover)
|
? Thumbnail.fromAsset(asset: asset, size: const Size(128, 128), fit: BoxFit.cover)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
||||||
final asset = selectedAssets.elementAt(index);
|
final asset = selectedAssets.elementAt(index);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onBackgroundTapped,
|
onTap: onBackgroundTapped,
|
||||||
child: Thumbnail(asset: asset),
|
child: Thumbnail.fromAsset(asset: asset),
|
||||||
);
|
);
|
||||||
}, childCount: selectedAssets.length),
|
}, childCount: selectedAssets.length),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,11 @@ class _PlaceTile extends StatelessWidget {
|
||||||
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
|
title: Text(place.$1, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
|
||||||
leading: ClipRRect(
|
leading: ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
child: Thumbnail(size: const Size(80, 80), fit: BoxFit.cover, remoteId: place.$2),
|
child: SizedBox(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
child: Thumbnail.remote(remoteId: place.$2, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ class _AlbumList extends ConsumerWidget {
|
||||||
leading: album.thumbnailAssetId != null
|
leading: album.thumbnailAssetId != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||||
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
|
child: SizedBox(width: 80, height: 80, child: Thumbnail.remote(remoteId: album.thumbnailAssetId!)),
|
||||||
)
|
)
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
width: 80,
|
width: 80,
|
||||||
|
|
@ -577,7 +577,7 @@ class _GridAlbumCard extends ConsumerWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: album.thumbnailAssetId != null
|
child: album.thumbnailAssetId != null
|
||||||
? Thumbnail(remoteId: album.thumbnailAssetId)
|
? Thumbnail.remote(remoteId: album.thumbnailAssetId!)
|
||||||
: Container(
|
: Container(
|
||||||
color: context.colorScheme.surfaceContainerHighest,
|
color: context.colorScheme.surfaceContainerHighest,
|
||||||
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
|
child: const Icon(Icons.photo_album_rounded, size: 40, color: Colors.grey),
|
||||||
|
|
|
||||||
|
|
@ -536,7 +536,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
child: Thumbnail(asset: asset, fit: BoxFit.contain),
|
child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,26 +150,3 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
|
||||||
|
|
||||||
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
bool _shouldUseLocalAsset(BaseAsset asset) =>
|
||||||
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
|
asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage));
|
||||||
|
|
||||||
ImageInfo? getCachedImage(ImageProvider key) {
|
|
||||||
ImageInfo? thumbnail;
|
|
||||||
final ImageStreamCompleter? stream = PaintingBinding.instance.imageCache.putIfAbsent(
|
|
||||||
key,
|
|
||||||
() => throw Exception(), // don't bother loading if it isn't cached
|
|
||||||
onError: (_, __) {},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (stream != null) {
|
|
||||||
void listener(ImageInfo info, bool synchronousCall) {
|
|
||||||
thumbnail = info;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
stream.addListener(ImageStreamListener(listener));
|
|
||||||
} finally {
|
|
||||||
stream.removeListener(ImageStreamListener(listener));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return thumbnail;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class LocalAlbumThumbnail extends ConsumerWidget {
|
||||||
|
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||||
child: Thumbnail(asset: data),
|
child: Thumbnail.fromAsset(asset: data),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
error: (error, stack) {
|
error: (error, stack) {
|
||||||
|
|
|
||||||
|
|
@ -30,24 +30,25 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
DiagnosticsProperty<Size>('Size', key.size),
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
],
|
],
|
||||||
)..addOnLastListenerRemovedCallback(cancel);
|
onDispose: cancel,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||||
return loadRequest(LocalImageRequest(localId: key.id, size: size, assetType: key.assetType), decode);
|
return loadRequest(LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType), decode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
if (other is LocalThumbProvider) {
|
if (other is LocalThumbProvider) {
|
||||||
return id == other.id && size == other.size;
|
return id == other.id;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode ^ size.hashCode;
|
int get hashCode => id.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||||
|
|
@ -67,7 +68,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||||
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getCachedImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
],
|
],
|
||||||
)..addOnLastListenerRemovedCallback(cancel);
|
onDispose: cancel,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||||
|
|
@ -73,7 +74,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
|
||||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, decode),
|
_codec(key, decode),
|
||||||
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
|
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId)),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,367 @@
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/theme_extensions.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/thumb_hash_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:octo_image/octo_image.dart';
|
|
||||||
|
|
||||||
class Thumbnail extends StatelessWidget {
|
final log = Logger('ThumbnailWidget');
|
||||||
const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key})
|
|
||||||
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
|
||||||
|
|
||||||
final BaseAsset? asset;
|
enum ThumbhashMode { enabled, disabled, only }
|
||||||
final String? remoteId;
|
|
||||||
final Size size;
|
class Thumbnail extends StatefulWidget {
|
||||||
|
final ImageProvider? imageProvider;
|
||||||
|
final ImageProvider? thumbhashProvider;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
|
||||||
|
const Thumbnail({this.imageProvider, this.fit = BoxFit.cover, this.thumbhashProvider, super.key});
|
||||||
|
|
||||||
|
Thumbnail.remote({required String remoteId, this.fit = BoxFit.cover, Size size = kThumbnailResolution, super.key})
|
||||||
|
: imageProvider = RemoteThumbProvider(assetId: remoteId),
|
||||||
|
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.
|
||||||
|
Size size = kThumbnailResolution,
|
||||||
|
super.key,
|
||||||
|
}) : thumbhashProvider = switch (asset) {
|
||||||
|
RemoteAsset() when asset.thumbHash != null && asset.localId == null => ThumbHashProvider(
|
||||||
|
thumbHash: asset.thumbHash!,
|
||||||
|
),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
imageProvider = switch (asset) {
|
||||||
|
RemoteAsset() =>
|
||||||
|
asset.localId == null
|
||||||
|
? RemoteThumbProvider(assetId: asset.id)
|
||||||
|
: LocalThumbProvider(id: asset.localId!, size: size, assetType: asset.type),
|
||||||
|
LocalAsset() => LocalThumbProvider(id: asset.id, size: size, assetType: asset.type),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Thumbnail> createState() => _ThumbnailState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThumbnailState extends State<Thumbnail> with SingleTickerProviderStateMixin {
|
||||||
|
ui.Image? _providerImage;
|
||||||
|
ui.Image? _previousImage;
|
||||||
|
|
||||||
|
late AnimationController _fadeController;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
|
||||||
|
ImageStream? _imageStream;
|
||||||
|
ImageStreamListener? _imageStreamListener;
|
||||||
|
ImageStream? _thumbhashStream;
|
||||||
|
ImageStreamListener? _thumbhashStreamListener;
|
||||||
|
|
||||||
|
static final _gradientCache = <ColorScheme, Gradient>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fadeController = AnimationController(duration: const Duration(milliseconds: 100), vsync: this);
|
||||||
|
_fadeAnimation = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut);
|
||||||
|
_fadeController.addStatusListener(_onAnimationStatusChanged);
|
||||||
|
_loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAnimationStatusChanged(AnimationStatus status) {
|
||||||
|
if (status == AnimationStatus.completed) {
|
||||||
|
_previousImage?.dispose();
|
||||||
|
_previousImage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadFromThumbhashProvider() {
|
||||||
|
_stopListeningToThumbhashStream();
|
||||||
|
final thumbhashProvider = widget.thumbhashProvider;
|
||||||
|
if (thumbhashProvider == null || _providerImage != null) return;
|
||||||
|
|
||||||
|
final thumbhashStream = _thumbhashStream = thumbhashProvider.resolve(ImageConfiguration.empty);
|
||||||
|
final thumbhashStreamListener = _thumbhashStreamListener = ImageStreamListener(
|
||||||
|
(ImageInfo imageInfo, bool synchronousCall) {
|
||||||
|
_stopListeningToThumbhashStream();
|
||||||
|
if (!mounted || _providerImage != null) {
|
||||||
|
imageInfo.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_providerImage = imageInfo.image;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (exception, stackTrace) {
|
||||||
|
log.severe('Error loading thumbhash', exception, stackTrace);
|
||||||
|
_stopListeningToThumbhashStream();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
thumbhashStream.addListener(thumbhashStreamListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadFromImageProvider() {
|
||||||
|
_stopListeningToImageStream();
|
||||||
|
final imageProvider = widget.imageProvider;
|
||||||
|
if (imageProvider == null) return;
|
||||||
|
|
||||||
|
final imageStream = _imageStream = imageProvider.resolve(ImageConfiguration.empty);
|
||||||
|
final imageStreamListener = _imageStreamListener = ImageStreamListener(
|
||||||
|
(ImageInfo imageInfo, bool synchronousCall) {
|
||||||
|
_stopListeningToStream();
|
||||||
|
if (!mounted) {
|
||||||
|
imageInfo.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_providerImage == imageInfo.image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (synchronousCall && _providerImage == null) {
|
||||||
|
_fadeController.value = 1.0;
|
||||||
|
} else if (_fadeController.isAnimating) {
|
||||||
|
_fadeController.forward();
|
||||||
|
} else {
|
||||||
|
_fadeController.forward(from: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_previousImage?.dispose();
|
||||||
|
if (_providerImage != null) {
|
||||||
|
_previousImage = _providerImage;
|
||||||
|
} else {
|
||||||
|
_previousImage = null;
|
||||||
|
}
|
||||||
|
_providerImage = imageInfo.image;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (exception, stackTrace) {
|
||||||
|
log.severe('Error loading image: $exception', exception, stackTrace);
|
||||||
|
_stopListeningToImageStream();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
imageStream.addListener(imageStreamListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopListeningToImageStream() {
|
||||||
|
if (_imageStreamListener != null && _imageStream != null) {
|
||||||
|
_imageStream!.removeListener(_imageStreamListener!);
|
||||||
|
}
|
||||||
|
_imageStream = null;
|
||||||
|
_imageStreamListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopListeningToThumbhashStream() {
|
||||||
|
if (_thumbhashStreamListener != null && _thumbhashStream != null) {
|
||||||
|
_thumbhashStream!.removeListener(_thumbhashStreamListener!);
|
||||||
|
}
|
||||||
|
_thumbhashStream = null;
|
||||||
|
_thumbhashStreamListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopListeningToStream() {
|
||||||
|
_stopListeningToImageStream();
|
||||||
|
_stopListeningToThumbhashStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(Thumbnail oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
|
if (widget.imageProvider != oldWidget.imageProvider) {
|
||||||
|
if (_fadeController.isAnimating) {
|
||||||
|
_fadeController.stop();
|
||||||
|
_previousImage?.dispose();
|
||||||
|
_previousImage = null;
|
||||||
|
}
|
||||||
|
_loadFromImageProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_providerImage == null && oldWidget.thumbhashProvider != widget.thumbhashProvider) {
|
||||||
|
_loadFromThumbhashProvider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void reassemble() {
|
||||||
|
super.reassemble();
|
||||||
|
_loadImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadImage() {
|
||||||
|
_loadFromImageProvider();
|
||||||
|
_loadFromThumbhashProvider();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
final colorScheme = context.colorScheme;
|
||||||
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
|
final gradient = _gradientCache[colorScheme] ??= LinearGradient(
|
||||||
|
colors: [colorScheme.surfaceContainer, colorScheme.surfaceContainer.darken(amount: .1)],
|
||||||
return OctoImage.fromSet(
|
begin: Alignment.topCenter,
|
||||||
image: provider,
|
end: Alignment.bottomCenter,
|
||||||
octoSet: OctoSet(
|
|
||||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
|
||||||
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset),
|
|
||||||
),
|
|
||||||
fadeOutDuration: const Duration(milliseconds: 100),
|
|
||||||
fadeInDuration: Duration.zero,
|
|
||||||
width: size.width,
|
|
||||||
height: size.height,
|
|
||||||
fit: fit,
|
|
||||||
placeholderFadeInDuration: Duration.zero,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _fadeAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return _ThumbnailLeaf(
|
||||||
|
image: _providerImage,
|
||||||
|
previousImage: _previousImage,
|
||||||
|
fadeValue: _fadeAnimation.value,
|
||||||
|
fit: widget.fit,
|
||||||
|
placeholderGradient: gradient,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_fadeController.removeStatusListener(_onAnimationStatusChanged);
|
||||||
|
_fadeController.dispose();
|
||||||
|
_stopListeningToStream();
|
||||||
|
_providerImage?.dispose();
|
||||||
|
_previousImage?.dispose();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) {
|
class _ThumbnailLeaf extends LeafRenderObjectWidget {
|
||||||
return (context) => thumbHash == null
|
final ui.Image? image;
|
||||||
? const ThumbnailPlaceholder()
|
final ui.Image? previousImage;
|
||||||
: FadeInPlaceholderImage(
|
final double fadeValue;
|
||||||
placeholder: const ThumbnailPlaceholder(),
|
final BoxFit fit;
|
||||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
final Gradient placeholderGradient;
|
||||||
fit: fit ?? BoxFit.cover,
|
|
||||||
);
|
const _ThumbnailLeaf({
|
||||||
|
required this.image,
|
||||||
|
required this.previousImage,
|
||||||
|
required this.fadeValue,
|
||||||
|
required this.fit,
|
||||||
|
required this.placeholderGradient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
RenderObject createRenderObject(BuildContext context) {
|
||||||
|
return _ThumbnailRenderBox(
|
||||||
|
image: image,
|
||||||
|
previousImage: previousImage,
|
||||||
|
fadeValue: fadeValue,
|
||||||
|
fit: fit,
|
||||||
|
placeholderGradient: placeholderGradient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateRenderObject(BuildContext context, _ThumbnailRenderBox renderObject) {
|
||||||
|
renderObject
|
||||||
|
..image = image
|
||||||
|
..previousImage = previousImage
|
||||||
|
..fadeValue = fadeValue
|
||||||
|
..fit = fit
|
||||||
|
..placeholderGradient = placeholderGradient;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) =>
|
class _ThumbnailRenderBox extends RenderBox {
|
||||||
(context, e, s) {
|
ui.Image? _image;
|
||||||
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
|
ui.Image? _previousImage;
|
||||||
provider?.evict();
|
double _fadeValue;
|
||||||
return Stack(
|
BoxFit _fit;
|
||||||
alignment: Alignment.center,
|
Gradient _placeholderGradient;
|
||||||
children: [
|
|
||||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
@override
|
||||||
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
|
bool isRepaintBoundary = true;
|
||||||
],
|
|
||||||
|
_ThumbnailRenderBox({
|
||||||
|
required ui.Image? image,
|
||||||
|
required ui.Image? previousImage,
|
||||||
|
required double fadeValue,
|
||||||
|
required BoxFit fit,
|
||||||
|
required Gradient placeholderGradient,
|
||||||
|
}) : _image = image,
|
||||||
|
_previousImage = previousImage,
|
||||||
|
_fadeValue = fadeValue,
|
||||||
|
_fit = fit,
|
||||||
|
_placeholderGradient = placeholderGradient;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(PaintingContext context, Offset offset) {
|
||||||
|
final rect = offset & size;
|
||||||
|
final canvas = context.canvas;
|
||||||
|
|
||||||
|
if (_previousImage != null && _fadeValue < 1.0) {
|
||||||
|
paintImage(
|
||||||
|
canvas: canvas,
|
||||||
|
rect: rect,
|
||||||
|
image: _previousImage!,
|
||||||
|
fit: _fit,
|
||||||
|
filterQuality: FilterQuality.low,
|
||||||
|
opacity: 1.0 - _fadeValue,
|
||||||
);
|
);
|
||||||
};
|
} else if (_image == null || _fadeValue < 1.0) {
|
||||||
|
final paint = Paint()..shader = _placeholderGradient.createShader(rect);
|
||||||
|
canvas.drawRect(rect, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_image != null) {
|
||||||
|
paintImage(
|
||||||
|
canvas: canvas,
|
||||||
|
rect: rect,
|
||||||
|
image: _image!,
|
||||||
|
fit: _fit,
|
||||||
|
filterQuality: FilterQuality.low,
|
||||||
|
opacity: _fadeValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void performLayout() {
|
||||||
|
size = constraints.biggest;
|
||||||
|
}
|
||||||
|
|
||||||
|
set image(ui.Image? value) {
|
||||||
|
if (_image != value) {
|
||||||
|
_image = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set previousImage(ui.Image? value) {
|
||||||
|
if (_previousImage != value) {
|
||||||
|
_previousImage = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set fadeValue(double value) {
|
||||||
|
if (_fadeValue != value) {
|
||||||
|
_fadeValue = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set fit(BoxFit value) {
|
||||||
|
if (_fit != value) {
|
||||||
|
_fit = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set placeholderGradient(Gradient value) {
|
||||||
|
if (_placeholderGradient != value) {
|
||||||
|
_placeholderGradient = value;
|
||||||
|
markNeedsPaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
|
|
||||||
class ThumbnailTile extends ConsumerWidget {
|
class ThumbnailTile extends ConsumerWidget {
|
||||||
const ThumbnailTile(
|
const ThumbnailTile(
|
||||||
this.asset, {
|
this.asset, {
|
||||||
this.size = const Size.square(256),
|
this.size = kThumbnailResolution,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
this.showStorageIndicator,
|
this.showStorageIndicator,
|
||||||
this.lockSelection = false,
|
this.lockSelection = false,
|
||||||
|
|
@ -21,7 +22,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final BaseAsset asset;
|
final BaseAsset? asset;
|
||||||
final Size size;
|
final Size size;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
final bool? showStorageIndicator;
|
final bool? showStorageIndicator;
|
||||||
|
|
@ -30,6 +31,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = this.asset;
|
||||||
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
final heroIndex = heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
|
||||||
|
|
||||||
final assetContainerColor = context.isDarkTheme
|
final assetContainerColor = context.isDarkTheme
|
||||||
|
|
@ -52,7 +54,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
)
|
)
|
||||||
: const BoxDecoration();
|
: const BoxDecoration();
|
||||||
|
|
||||||
final hasStack = asset is RemoteAsset && (asset as RemoteAsset).stackId != null;
|
final hasStack = asset is RemoteAsset && asset.stackId != null;
|
||||||
|
|
||||||
final bool storageIndicator =
|
final bool storageIndicator =
|
||||||
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
|
showStorageIndicator ?? ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator)));
|
||||||
|
|
@ -71,8 +73,8 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Hero(
|
child: Hero(
|
||||||
tag: '${asset.heroTag}_$heroIndex',
|
tag: '${asset?.heroTag ?? ''}_$heroIndex',
|
||||||
child: Thumbnail(asset: asset, fit: fit, size: size),
|
child: Thumbnail.fromAsset(asset: asset, size: size),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (hasStack)
|
if (hasStack)
|
||||||
|
|
@ -83,7 +85,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
|
child: const _TileOverlayIcon(Icons.burst_mode_rounded),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (asset.isVideo)
|
if (asset != null && asset.isVideo)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
@ -91,7 +93,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
child: _VideoIndicator(asset.duration),
|
child: _VideoIndicator(asset.duration),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (storageIndicator)
|
if (storageIndicator && asset != null)
|
||||||
switch (asset.storage) {
|
switch (asset.storage) {
|
||||||
AssetState.local => const Align(
|
AssetState.local => const Align(
|
||||||
alignment: Alignment.bottomRight,
|
alignment: Alignment.bottomRight,
|
||||||
|
|
@ -115,7 +117,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
if (asset.isFavorite)
|
if (asset != null && asset.isFavorite)
|
||||||
const Align(
|
const Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class DriftMemoryCard extends ConsumerWidget {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 205,
|
width: 205,
|
||||||
height: 200,
|
height: 200,
|
||||||
child: Thumbnail(remoteId: memory.assets[0].id, fit: BoxFit.cover),
|
child: Thumbnail.remote(remoteId: memory.assets[0].id, fit: BoxFit.cover),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'dart:ui';
|
||||||
|
|
||||||
const double kTimelineHeaderExtent = 80.0;
|
const double kTimelineHeaderExtent = 80.0;
|
||||||
const Size kTimelineFixedTileExtent = Size.square(256);
|
const Size kTimelineFixedTileExtent = Size.square(256);
|
||||||
const Size kThumbnailResolution = kTimelineFixedTileExtent;
|
const Size kThumbnailResolution = kTimelineFixedTileExtent; // TODO: make the resolution vary based on actual tile size
|
||||||
const double kTimelineSpacing = 2.0;
|
const double kTimelineSpacing = 2.0;
|
||||||
const int kTimelineColumnCount = 3;
|
const int kTimelineColumnCount = 3;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue