mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
feat(mobile): platform image providers (#20927)
* platform image providers * use key * fix cache manager * more logs, cancel on dispose instead * split into separate files * fix saving to cache * cancel multi-stage provider * refactored `getInitialImage` * only wait for disposal for full images * cached image works * formatting * lower asset viewer ram usage --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
9ff37b6870
commit
99d6673503
15 changed files with 669 additions and 306 deletions
81
mobile/lib/infrastructure/loaders/image_request.dart
Normal file
81
mobile/lib/infrastructure/loaders/image_request.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:ffi/ffi.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/infrastructure/platform.provider.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
part 'local_image_request.dart';
|
||||||
|
part 'thumbhash_image_request.dart';
|
||||||
|
part 'remote_image_request.dart';
|
||||||
|
|
||||||
|
abstract class ImageRequest {
|
||||||
|
static int _nextRequestId = 0;
|
||||||
|
|
||||||
|
final int requestId = _nextRequestId++;
|
||||||
|
bool _isCancelled = false;
|
||||||
|
|
||||||
|
get isCancelled => _isCancelled;
|
||||||
|
|
||||||
|
ImageRequest();
|
||||||
|
|
||||||
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isCancelled = true;
|
||||||
|
return _onCancelled();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCancelled();
|
||||||
|
|
||||||
|
Future<ui.FrameInfo?> _fromPlatformImage(Map<String, int> info) async {
|
||||||
|
final address = info['pointer'];
|
||||||
|
if (address == null) {
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
debugPrint('Platform image request for $requestId was cancelled');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||||
|
try {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
malloc.free(pointer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
mobile/lib/infrastructure/loaders/local_image_request.dart
Normal file
45
mobile/lib/infrastructure/loaders/local_image_request.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
part of 'image_request.dart';
|
||||||
|
|
||||||
|
class LocalImageRequest extends ImageRequest {
|
||||||
|
final String localId;
|
||||||
|
final int width;
|
||||||
|
final int height;
|
||||||
|
final AssetType assetType;
|
||||||
|
|
||||||
|
LocalImageRequest({required this.localId, required ui.Size size, required this.assetType})
|
||||||
|
: width = size.width.toInt(),
|
||||||
|
height = size.height.toInt();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stopwatch? stopwatch;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch = Stopwatch()..start();
|
||||||
|
}
|
||||||
|
final Map<String, int> info = await thumbnailApi.requestImage(
|
||||||
|
localId,
|
||||||
|
requestId: requestId,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
isVideo: assetType == AssetType.video,
|
||||||
|
);
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch!.stop();
|
||||||
|
debugPrint('Local request $requestId took ${stopwatch.elapsedMilliseconds}ms for $localId of $width x $height');
|
||||||
|
}
|
||||||
|
final frame = await _fromPlatformImage(info);
|
||||||
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> _onCancelled() {
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
debugPrint('Local image request $requestId for $localId of size $width x $height was cancelled');
|
||||||
|
}
|
||||||
|
return thumbnailApi.cancelImageRequest(requestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
mobile/lib/infrastructure/loaders/remote_image_request.dart
Normal file
146
mobile/lib/infrastructure/loaders/remote_image_request.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
part of 'image_request.dart';
|
||||||
|
|
||||||
|
class RemoteImageRequest extends ImageRequest {
|
||||||
|
static final log = Logger('RemoteImageRequest');
|
||||||
|
static final client = HttpClient()..maxConnectionsPerHost = 32;
|
||||||
|
final RemoteCacheManager? cacheManager;
|
||||||
|
final String uri;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
HttpClientRequest? _request;
|
||||||
|
|
||||||
|
RemoteImageRequest({required this.uri, required this.headers, this.cacheManager});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
|
||||||
|
// so it ends up being a bottleneck. We only prefer fetching from it when it can skip the DB call.
|
||||||
|
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
|
||||||
|
if (cachedFileImage != null) {
|
||||||
|
return cachedFileImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Stopwatch? stopwatch;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch = Stopwatch()..start();
|
||||||
|
}
|
||||||
|
final buffer = await _downloadImage(uri);
|
||||||
|
if (buffer == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch!.stop();
|
||||||
|
debugPrint('Remote image download $requestId took ${stopwatch.elapsedMilliseconds}ms for $uri');
|
||||||
|
}
|
||||||
|
return await _decodeBuffer(buffer, decode, scale);
|
||||||
|
} catch (e) {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
|
||||||
|
if (cachedFileImage != null) {
|
||||||
|
return cachedFileImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_request = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final request = _request = await client.getUrl(Uri.parse(url));
|
||||||
|
if (_isCancelled) {
|
||||||
|
request.abort();
|
||||||
|
return _request = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final entry in headers.entries) {
|
||||||
|
request.headers.set(entry.key, entry.value);
|
||||||
|
}
|
||||||
|
final response = await request.close();
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final bytes = Uint8List(response.contentLength);
|
||||||
|
int offset = 0;
|
||||||
|
final subscription = response.listen((List<int> chunk) {
|
||||||
|
// this is important to break the response stream if the request is cancelled
|
||||||
|
if (_isCancelled) {
|
||||||
|
throw StateError('Cancelled request');
|
||||||
|
}
|
||||||
|
bytes.setAll(offset, chunk);
|
||||||
|
offset += chunk.length;
|
||||||
|
}, cancelOnError: true);
|
||||||
|
cacheManager?.putStreamedFile(url, response);
|
||||||
|
await subscription.asFuture();
|
||||||
|
return await ImmutableBuffer.fromUint8List(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImageInfo?> _loadCachedFile(
|
||||||
|
String url,
|
||||||
|
ImageDecoderCallback decode,
|
||||||
|
double scale, {
|
||||||
|
required bool inMemoryOnly,
|
||||||
|
}) async {
|
||||||
|
final cacheManager = this.cacheManager;
|
||||||
|
if (_isCancelled || cacheManager == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
||||||
|
if (_isCancelled || file == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final buffer = await ImmutableBuffer.fromFilePath(file.file.path);
|
||||||
|
return await _decodeBuffer(buffer, decode, scale);
|
||||||
|
} catch (e) {
|
||||||
|
log.severe('Failed to decode cached image', e);
|
||||||
|
_evictFile(url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _evictFile(String url) async {
|
||||||
|
try {
|
||||||
|
await cacheManager?.removeFile(url);
|
||||||
|
} catch (e) {
|
||||||
|
log.severe('Failed to remove cached image', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
|
||||||
|
if (_isCancelled) {
|
||||||
|
buffer.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final codec = await decode(buffer);
|
||||||
|
if (_isCancelled) {
|
||||||
|
buffer.dispose();
|
||||||
|
codec.dispose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
return ImageInfo(image: frame.image, scale: scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void _onCancelled() {
|
||||||
|
_request?.abort();
|
||||||
|
_request = null;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
debugPrint('Cancelled remote image request $requestId for $uri');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
part of 'image_request.dart';
|
||||||
|
|
||||||
|
class ThumbhashImageRequest extends ImageRequest {
|
||||||
|
final String thumbhash;
|
||||||
|
|
||||||
|
ThumbhashImageRequest({required this.thumbhash});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0}) async {
|
||||||
|
if (_isCancelled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
|
||||||
|
final frame = await _fromPlatformImage(info);
|
||||||
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void _onCancelled() {
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
debugPrint('Thumbhash request $requestId for $thumbhash was cancelled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import 'dart:typed_data';
|
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:photo_manager/photo_manager.dart';
|
|
||||||
|
|
||||||
class AssetMediaRepository {
|
|
||||||
const AssetMediaRepository();
|
|
||||||
|
|
||||||
Future<Uint8List?> getThumbnail(String id, {int quality = 80, Size size = const Size.square(256)}) => AssetEntity(
|
|
||||||
id: id,
|
|
||||||
// The below fields are not used in thumbnailDataWithSize but are required
|
|
||||||
// to create an AssetEntity instance. It is faster to create a dummy AssetEntity
|
|
||||||
// instance than to fetch the asset from the device first.
|
|
||||||
typeInt: AssetType.image.index,
|
|
||||||
width: size.width.toInt(),
|
|
||||||
height: size.height.toInt(),
|
|
||||||
).thumbnailDataWithSize(ThumbnailSize(size.width.toInt(), size.height.toInt()), quality: quality);
|
|
||||||
}
|
|
||||||
|
|
@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
|
||||||
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
|
||||||
import 'package:platform/platform.dart';
|
import 'package:platform/platform.dart';
|
||||||
|
|
@ -63,6 +64,7 @@ const double _kBottomSheetMinimumExtent = 0.4;
|
||||||
const double _kBottomSheetSnapExtent = 0.7;
|
const double _kBottomSheetSnapExtent = 0.7;
|
||||||
|
|
||||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
|
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||||
late PageController pageController;
|
late PageController pageController;
|
||||||
late DraggableScrollableController bottomSheetController;
|
late DraggableScrollableController bottomSheetController;
|
||||||
PersistentBottomSheetController? sheetCloseController;
|
PersistentBottomSheetController? sheetCloseController;
|
||||||
|
|
@ -90,6 +92,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
// Delayed operations that should be cancelled on disposal
|
// Delayed operations that should be cancelled on disposal
|
||||||
final List<Timer> _delayedOperations = [];
|
final List<Timer> _delayedOperations = [];
|
||||||
|
|
||||||
|
ImageStream? _prevPreCacheStream;
|
||||||
|
ImageStream? _nextPreCacheStream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
@ -110,6 +115,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
bottomSheetController.dispose();
|
bottomSheetController.dispose();
|
||||||
_cancelTimers();
|
_cancelTimers();
|
||||||
reloadSubscription?.cancel();
|
reloadSubscription?.cancel();
|
||||||
|
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||||
|
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -130,27 +137,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
double _getVerticalOffsetForBottomSheet(double extent) =>
|
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||||
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||||
|
|
||||||
Future<void> _precacheImage(int index) async {
|
ImageStream _precacheImage(BaseAsset asset) {
|
||||||
if (!mounted) {
|
final provider = getFullImageProvider(asset, size: context.sizeData);
|
||||||
return;
|
return provider.resolve(ImageConfiguration.empty)..addListener(_dummyListener);
|
||||||
}
|
|
||||||
|
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
|
||||||
final asset = await timelineService.getAssetAsync(index);
|
|
||||||
|
|
||||||
if (asset == null || !mounted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final screenSize = Size(context.width, context.height);
|
|
||||||
|
|
||||||
// Precache both thumbnail and full image for smooth transitions
|
|
||||||
unawaited(
|
|
||||||
Future.wait([
|
|
||||||
precacheImage(getThumbnailImageProvider(asset: asset), context, onError: (_, __) {}),
|
|
||||||
precacheImage(getFullImageProvider(asset, size: screenSize), context, onError: (_, __) {}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetChanged(int index) async {
|
void _onAssetChanged(int index) async {
|
||||||
|
|
@ -176,13 +165,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
_cancelTimers();
|
_cancelTimers();
|
||||||
// This will trigger the pre-caching of adjacent assets ensuring
|
// This will trigger the pre-caching of adjacent assets ensuring
|
||||||
// that they are ready when the user navigates to them.
|
// that they are ready when the user navigates to them.
|
||||||
final timer = Timer(Durations.medium4, () {
|
final timer = Timer(Durations.medium4, () async {
|
||||||
// Check if widget is still mounted before proceeding
|
// Check if widget is still mounted before proceeding
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
for (final offset in [-1, 1]) {
|
final (prevAsset, nextAsset) = await (
|
||||||
unawaited(_precacheImage(index + offset));
|
timelineService.getAssetAsync(index - 1),
|
||||||
}
|
timelineService.getAssetAsync(index + 1),
|
||||||
|
).wait;
|
||||||
|
if (!mounted) return;
|
||||||
|
_prevPreCacheStream?.removeListener(_dummyListener);
|
||||||
|
_nextPreCacheStream?.removeListener(_dummyListener);
|
||||||
|
_prevPreCacheStream = prevAsset != null ? _precacheImage(prevAsset) : null;
|
||||||
|
_nextPreCacheStream = nextAsset != null ? _precacheImage(nextAsset) : null;
|
||||||
});
|
});
|
||||||
_delayedOperations.add(timer);
|
_delayedOperations.add(timer);
|
||||||
|
|
||||||
|
|
@ -478,30 +473,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
return const Center(child: ImmichLoadingIndicator());
|
||||||
final asset = timelineService.getAssetSafe(index);
|
|
||||||
|
|
||||||
// If asset is not available in buffer, show a loading container
|
|
||||||
if (asset == null) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
color: backgroundColor,
|
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
BaseAsset displayAsset = asset;
|
|
||||||
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
|
|
||||||
if (stackChildren != null && stackChildren.isNotEmpty) {
|
|
||||||
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
|
|
||||||
}
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
height: double.infinity,
|
|
||||||
color: backgroundColor,
|
|
||||||
child: Thumbnail(asset: displayAsset, fit: BoxFit.contain),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,114 @@
|
||||||
|
import 'package:async/async.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.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/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/local_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/remote_image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
abstract class CancellableImageProvider<T extends Object> extends ImageProvider<T> {
|
||||||
|
void cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvider<T> {
|
||||||
|
static final _log = Logger('CancellableImageProviderMixin');
|
||||||
|
|
||||||
|
bool isCancelled = false;
|
||||||
|
ImageRequest? request;
|
||||||
|
CancelableOperation<ImageInfo?>? cachedOperation;
|
||||||
|
|
||||||
|
ImageInfo? getInitialImage(CancellableImageProvider provider) {
|
||||||
|
final completer = CancelableCompleter<ImageInfo?>(onCancel: provider.cancel);
|
||||||
|
final cachedStream = provider.resolve(const ImageConfiguration());
|
||||||
|
ImageInfo? cachedImage;
|
||||||
|
final listener = ImageStreamListener((image, synchronousCall) {
|
||||||
|
if (synchronousCall) {
|
||||||
|
cachedImage = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(image);
|
||||||
|
}
|
||||||
|
}, onError: completer.completeError);
|
||||||
|
|
||||||
|
cachedStream.addListener(listener);
|
||||||
|
if (cachedImage != null) {
|
||||||
|
cachedStream.removeListener(listener);
|
||||||
|
return cachedImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
completer.operation.valueOrCancellation().whenComplete(() {
|
||||||
|
cachedStream.removeListener(listener);
|
||||||
|
cachedOperation = null;
|
||||||
|
});
|
||||||
|
cachedOperation = completer.operation;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode) async* {
|
||||||
|
if (isCancelled) {
|
||||||
|
evict();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.request = request;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final image = await request.load(decode);
|
||||||
|
if (image == null || isCancelled) {
|
||||||
|
evict();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield image;
|
||||||
|
} finally {
|
||||||
|
this.request = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<ImageInfo> initialImageStream() async* {
|
||||||
|
final cachedOperation = this.cachedOperation;
|
||||||
|
if (cachedOperation == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final cachedImage = await cachedOperation.valueOrCancellation();
|
||||||
|
if (cachedImage != null && !isCancelled) {
|
||||||
|
yield cachedImage;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.severe('Error loading initial image', e, stack);
|
||||||
|
} finally {
|
||||||
|
this.cachedOperation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cancel() {
|
||||||
|
isCancelled = true;
|
||||||
|
final request = this.request;
|
||||||
|
if (request != null) {
|
||||||
|
this.request = null;
|
||||||
|
request.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
final operation = cachedOperation;
|
||||||
|
if (operation != null) {
|
||||||
|
this.cachedOperation = null;
|
||||||
|
operation.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
|
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
|
||||||
// Create new provider and cache it
|
// Create new provider and cache it
|
||||||
final ImageProvider provider;
|
final ImageProvider provider;
|
||||||
if (_shouldUseLocalAsset(asset)) {
|
if (_shouldUseLocalAsset(asset)) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
provider = LocalFullImageProvider(id: id, size: size, type: asset.type, updatedAt: asset.updatedAt);
|
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type);
|
||||||
} else {
|
} else {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
if (asset is LocalAsset && asset.hasRemote) {
|
if (asset is LocalAsset && asset.hasRemote) {
|
||||||
|
|
@ -36,7 +133,7 @@ ImageProvider getThumbnailImageProvider({BaseAsset? asset, String? remoteId, Siz
|
||||||
|
|
||||||
if (_shouldUseLocalAsset(asset!)) {
|
if (_shouldUseLocalAsset(asset!)) {
|
||||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||||
return LocalThumbProvider(id: id, updatedAt: asset.updatedAt, size: size);
|
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
final String assetId;
|
final String assetId;
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,21 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.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/domain/models/setting.model.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/extensions/codec_extensions.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
|
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
|
|
||||||
import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
|
|
||||||
class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
|
||||||
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
|
|
||||||
final CacheManager? cacheManager;
|
|
||||||
|
|
||||||
|
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||||
|
with CancellableImageProviderMixin<LocalThumbProvider> {
|
||||||
final String id;
|
final String id;
|
||||||
final DateTime updatedAt;
|
|
||||||
final Size size;
|
final Size size;
|
||||||
|
final AssetType assetType;
|
||||||
|
|
||||||
const LocalThumbProvider({
|
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
|
||||||
required this.id,
|
|
||||||
required this.updatedAt,
|
|
||||||
this.size = kThumbnailResolution,
|
|
||||||
this.cacheManager,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
|
@ -39,63 +24,39 @@ class LocalThumbProvider extends ImageProvider<LocalThumbProvider> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||||
final cache = cacheManager ?? ThumbnailImageCacheManager();
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
return MultiFrameImageStreamCompleter(
|
_codec(key, decode),
|
||||||
codec: _codec(key, cache, decode),
|
|
||||||
scale: 1.0,
|
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
|
||||||
DiagnosticsProperty<Size>('Size', key.size),
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
],
|
],
|
||||||
);
|
)..addOnLastListenerRemovedCallback(cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Codec> _codec(LocalThumbProvider key, CacheManager cache, ImageDecoderCallback decode) async {
|
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
|
||||||
final cacheKey = '${key.id}-${key.updatedAt}-${key.size.width}x${key.size.height}';
|
return loadRequest(LocalImageRequest(localId: key.id, size: size, assetType: key.assetType), decode);
|
||||||
|
|
||||||
final fileFromCache = await cache.getFileFromCache(cacheKey);
|
|
||||||
if (fileFromCache != null) {
|
|
||||||
try {
|
|
||||||
final buffer = await ImmutableBuffer.fromFilePath(fileFromCache.file.path);
|
|
||||||
return decode(buffer);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
final thumbnailBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
|
|
||||||
if (thumbnailBytes == null) {
|
|
||||||
PaintingBinding.instance.imageCache.evict(key);
|
|
||||||
throw StateError("Loading thumb for local photo ${key.id} failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes);
|
|
||||||
unawaited(cache.putFile(cacheKey, thumbnailBytes));
|
|
||||||
return decode(buffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 && updatedAt == other.updatedAt;
|
return id == other.id && size == other.size;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode ^ updatedAt.hashCode;
|
int get hashCode => id.hashCode ^ size.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProvider>
|
||||||
final AssetMediaRepository _assetMediaRepository = const AssetMediaRepository();
|
with CancellableImageProviderMixin<LocalFullImageProvider> {
|
||||||
final StorageRepository _storageRepository = const StorageRepository();
|
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final Size size;
|
final Size size;
|
||||||
final AssetType type;
|
final AssetType assetType;
|
||||||
final DateTime updatedAt; // temporary, only exists to fetch cached thumbnail until local disk cache is removed
|
|
||||||
|
|
||||||
const LocalFullImageProvider({required this.id, required this.size, required this.type, required this.updatedAt});
|
LocalFullImageProvider({required this.id, required this.assetType, required this.size});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
|
@ -106,114 +67,43 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
|
||||||
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)),
|
||||||
informationCollector: () => <DiagnosticsNode>[
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
DiagnosticsProperty<String>('Id', key.id),
|
DiagnosticsProperty<String>('Id', key.id),
|
||||||
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
|
|
||||||
DiagnosticsProperty<Size>('Size', key.size),
|
DiagnosticsProperty<Size>('Size', key.size),
|
||||||
],
|
],
|
||||||
|
onDispose: cancel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streams in each stage of the image as we ask for it
|
|
||||||
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
try {
|
yield* initialImageStream();
|
||||||
// First, yield the thumbnail image from LocalThumbProvider
|
|
||||||
final thumbProvider = LocalThumbProvider(id: key.id, updatedAt: key.updatedAt);
|
|
||||||
try {
|
|
||||||
final thumbCodec = await thumbProvider._codec(
|
|
||||||
thumbProvider,
|
|
||||||
thumbProvider.cacheManager ?? ThumbnailImageCacheManager(),
|
|
||||||
decode,
|
|
||||||
);
|
|
||||||
final thumbImageInfo = await thumbCodec.getImageInfo();
|
|
||||||
yield thumbImageInfo;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// Then proceed with the main image loading stream
|
if (isCancelled) {
|
||||||
final mainStream = switch (key.type) {
|
evict();
|
||||||
AssetType.image => _decodeProgressive(key, decode),
|
|
||||||
AssetType.video => _getThumbnailCodec(key, decode),
|
|
||||||
_ => throw StateError('Unsupported asset type ${key.type}'),
|
|
||||||
};
|
|
||||||
|
|
||||||
await for (final imageInfo in mainStream) {
|
|
||||||
yield imageInfo;
|
|
||||||
}
|
|
||||||
} catch (error, stack) {
|
|
||||||
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
|
|
||||||
throw const ImageLoadingException('Could not load image from local storage');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<ImageInfo> _getThumbnailCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
|
||||||
final thumbBytes = await _assetMediaRepository.getThumbnail(key.id, size: key.size);
|
|
||||||
if (thumbBytes == null) {
|
|
||||||
throw StateError("Failed to load preview for ${key.id}");
|
|
||||||
}
|
|
||||||
final buffer = await ImmutableBuffer.fromUint8List(thumbBytes);
|
|
||||||
final codec = await decode(buffer);
|
|
||||||
yield await codec.getImageInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<ImageInfo> _decodeProgressive(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
|
|
||||||
final file = await _storageRepository.getFileForAsset(key.id);
|
|
||||||
if (file == null) {
|
|
||||||
throw StateError("Opening file for asset ${key.id} failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
final fileSize = await file.length();
|
|
||||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
|
||||||
final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB
|
|
||||||
final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$'));
|
|
||||||
final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS);
|
|
||||||
|
|
||||||
if (isProgressive) {
|
|
||||||
try {
|
|
||||||
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2;
|
|
||||||
final size = Size(
|
|
||||||
(key.size.width * progressiveMultiplier).clamp(256, 1024),
|
|
||||||
(key.size.height * progressiveMultiplier).clamp(256, 1024),
|
|
||||||
);
|
|
||||||
final mediumThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
|
|
||||||
if (mediumThumb != null) {
|
|
||||||
final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb);
|
|
||||||
final codec = await decode(mediumBuffer);
|
|
||||||
yield await codec.getImageInfo();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load original only when the file is smaller or if the user wants to load original images
|
|
||||||
// Or load a slightly larger image for progressive loading
|
|
||||||
if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) {
|
|
||||||
final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6;
|
|
||||||
final size = Size(
|
|
||||||
(key.size.width * progressiveMultiplier).clamp(512, 2048),
|
|
||||||
(key.size.height * progressiveMultiplier).clamp(512, 2048),
|
|
||||||
);
|
|
||||||
final highThumb = await _assetMediaRepository.getThumbnail(key.id, size: size);
|
|
||||||
if (highThumb != null) {
|
|
||||||
final highBuffer = await ImmutableBuffer.fromUint8List(highThumb);
|
|
||||||
final codec = await decode(highBuffer);
|
|
||||||
yield await codec.getImageInfo();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final buffer = await ImmutableBuffer.fromFilePath(file.path);
|
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||||
final codec = await decode(buffer);
|
final request = LocalImageRequest(
|
||||||
yield await codec.getImageInfo();
|
localId: key.id,
|
||||||
|
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||||
|
assetType: key.assetType,
|
||||||
|
);
|
||||||
|
|
||||||
|
yield* loadRequest(request, 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 LocalFullImageProvider) {
|
if (other is LocalFullImageProvider) {
|
||||||
return id == other.id && size == other.size && type == other.type;
|
return id == other.id && size == other.size;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode ^ size.hashCode ^ type.hashCode;
|
int get hashCode => id.hashCode ^ size.hashCode;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import 'package:flutter/painting.dart';
|
||||||
|
|
||||||
/// An ImageStreamCompleter with support for loading multiple images.
|
/// An ImageStreamCompleter with support for loading multiple images.
|
||||||
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||||
ImageInfo? _initialImage;
|
void Function()? _onDispose;
|
||||||
|
|
||||||
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
|
/// The constructor to create an OneFramePlaceholderImageStreamCompleter. The [images]
|
||||||
/// should be the primary images to display (typically asynchronously as they load).
|
/// should be the primary images to display (typically asynchronously as they load).
|
||||||
|
|
@ -19,10 +19,14 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||||
Stream<ImageInfo> images, {
|
Stream<ImageInfo> images, {
|
||||||
ImageInfo? initialImage,
|
ImageInfo? initialImage,
|
||||||
InformationCollector? informationCollector,
|
InformationCollector? informationCollector,
|
||||||
|
void Function()? onDispose,
|
||||||
}) {
|
}) {
|
||||||
_initialImage = initialImage;
|
if (initialImage != null) {
|
||||||
|
setImage(initialImage);
|
||||||
|
}
|
||||||
|
_onDispose = onDispose;
|
||||||
images.listen(
|
images.listen(
|
||||||
_onImage,
|
setImage,
|
||||||
onError: (Object error, StackTrace stack) {
|
onError: (Object error, StackTrace stack) {
|
||||||
reportError(
|
reportError(
|
||||||
context: ErrorDescription('resolving a single-frame image stream'),
|
context: ErrorDescription('resolving a single-frame image stream'),
|
||||||
|
|
@ -35,33 +39,13 @@ class OneFramePlaceholderImageStreamCompleter extends ImageStreamCompleter {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onImage(ImageInfo image) {
|
|
||||||
setImage(image);
|
|
||||||
_initialImage?.dispose();
|
|
||||||
_initialImage = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void addListener(ImageStreamListener listener) {
|
|
||||||
final initialImage = _initialImage;
|
|
||||||
if (initialImage != null) {
|
|
||||||
try {
|
|
||||||
listener.onImage(initialImage.clone(), true);
|
|
||||||
} catch (exception, stack) {
|
|
||||||
reportError(
|
|
||||||
context: ErrorDescription('by a synchronously-called image listener'),
|
|
||||||
exception: exception,
|
|
||||||
stack: stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
super.addListener(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onDisposed() {
|
void onDisposed() {
|
||||||
_initialImage?.dispose();
|
final onDispose = _onDispose;
|
||||||
_initialImage = null;
|
if (onDispose != null) {
|
||||||
|
_onDispose = null;
|
||||||
|
onDispose();
|
||||||
|
}
|
||||||
super.onDisposed();
|
super.onDisposed();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/setting.service.dart';
|
import 'package:immich_mobile/domain/services/setting.service.dart';
|
||||||
import 'package:immich_mobile/extensions/codec_extensions.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
import 'package:immich_mobile/providers/image/cache/image_loader.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';
|
||||||
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
|
|
||||||
class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
|
||||||
|
with CancellableImageProviderMixin<RemoteThumbProvider> {
|
||||||
|
static final cacheManager = RemoteThumbnailCacheManager();
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final CacheManager? cacheManager;
|
|
||||||
|
|
||||||
const RemoteThumbProvider({required this.assetId, this.cacheManager});
|
RemoteThumbProvider({required this.assetId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
|
@ -26,33 +25,22 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
final chunkController = StreamController<ImageChunkEvent>();
|
_codec(key, decode),
|
||||||
return MultiFrameImageStreamCompleter(
|
|
||||||
codec: _codec(key, cache, decode, chunkController),
|
|
||||||
scale: 1.0,
|
|
||||||
chunkEvents: chunkController.stream,
|
|
||||||
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),
|
||||||
],
|
],
|
||||||
);
|
)..addOnLastListenerRemovedCallback(cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Codec> _codec(
|
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
|
||||||
RemoteThumbProvider key,
|
final request = RemoteImageRequest(
|
||||||
CacheManager cache,
|
uri: getThumbnailUrlForRemoteId(key.assetId),
|
||||||
ImageDecoderCallback decode,
|
headers: ApiService.getRequestHeaders(),
|
||||||
StreamController<ImageChunkEvent> chunkController,
|
cacheManager: cacheManager,
|
||||||
) async {
|
);
|
||||||
final preview = getThumbnailUrlForRemoteId(key.assetId);
|
return loadRequest(request, decode);
|
||||||
|
|
||||||
return ImageLoader.loadImageFromCache(
|
|
||||||
preview,
|
|
||||||
cache: cache,
|
|
||||||
decode: decode,
|
|
||||||
chunkEvents: chunkController,
|
|
||||||
).whenComplete(chunkController.close);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -69,11 +57,12 @@ class RemoteThumbProvider extends ImageProvider<RemoteThumbProvider> {
|
||||||
int get hashCode => assetId.hashCode;
|
int get hashCode => assetId.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
|
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
|
||||||
|
with CancellableImageProviderMixin<RemoteFullImageProvider> {
|
||||||
|
static final cacheManager = RemoteThumbnailCacheManager();
|
||||||
final String assetId;
|
final String assetId;
|
||||||
final CacheManager? cacheManager;
|
|
||||||
|
|
||||||
const RemoteFullImageProvider({required this.assetId, this.cacheManager});
|
RemoteFullImageProvider({required this.assetId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
|
@ -82,28 +71,49 @@ class RemoteFullImageProvider extends ImageProvider<RemoteFullImageProvider> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
|
||||||
final cache = cacheManager ?? RemoteImageCacheManager();
|
|
||||||
return OneFramePlaceholderImageStreamCompleter(
|
return OneFramePlaceholderImageStreamCompleter(
|
||||||
_codec(key, cache, decode),
|
_codec(key, decode),
|
||||||
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
|
initialImage: getCachedImage(RemoteThumbProvider(assetId: key.assetId)),
|
||||||
|
informationCollector: () => <DiagnosticsNode>[
|
||||||
|
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||||
|
DiagnosticsProperty<String>('Asset Id', key.assetId),
|
||||||
|
],
|
||||||
|
onDispose: cancel,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<ImageInfo> _codec(RemoteFullImageProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
|
Stream<ImageInfo> _codec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
|
||||||
final codec = await ImageLoader.loadImageFromCache(
|
yield* initialImageStream();
|
||||||
getPreviewUrlForRemoteId(key.assetId),
|
|
||||||
cache: cache,
|
if (isCancelled) {
|
||||||
decode: decode,
|
evict();
|
||||||
);
|
return;
|
||||||
yield await codec.getImageInfo();
|
}
|
||||||
|
|
||||||
|
final headers = ApiService.getRequestHeaders();
|
||||||
|
try {
|
||||||
|
final request = RemoteImageRequest(
|
||||||
|
uri: getPreviewUrlForRemoteId(key.assetId),
|
||||||
|
headers: headers,
|
||||||
|
cacheManager: cacheManager,
|
||||||
|
);
|
||||||
|
yield* loadRequest(request, decode);
|
||||||
|
} finally {
|
||||||
|
request = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
evict();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (AppSetting.get(Setting.loadOriginal)) {
|
if (AppSetting.get(Setting.loadOriginal)) {
|
||||||
final codec = await ImageLoader.loadImageFromCache(
|
try {
|
||||||
getOriginalUrlForRemoteId(key.assetId),
|
final request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
|
||||||
cache: cache,
|
yield* loadRequest(request, decode);
|
||||||
decode: decode,
|
} finally {
|
||||||
);
|
request = null;
|
||||||
yield await codec.getImageInfo();
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import 'dart:convert' hide Codec;
|
|
||||||
import 'dart:ui';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:thumbhash/thumbhash.dart';
|
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
|
||||||
|
|
||||||
class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
class ThumbHashProvider extends CancellableImageProvider<ThumbHashProvider>
|
||||||
|
with CancellableImageProviderMixin<ThumbHashProvider> {
|
||||||
final String thumbHash;
|
final String thumbHash;
|
||||||
|
|
||||||
const ThumbHashProvider({required this.thumbHash});
|
ThumbHashProvider({required this.thumbHash});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
Future<ThumbHashProvider> obtainKey(ImageConfiguration configuration) {
|
||||||
|
|
@ -17,12 +17,11 @@ class ThumbHashProvider extends ImageProvider<ThumbHashProvider> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
|
ImageStreamCompleter loadImage(ThumbHashProvider key, ImageDecoderCallback decode) {
|
||||||
return MultiFrameImageStreamCompleter(codec: _loadCodec(key, decode), scale: 1.0);
|
return OneFramePlaceholderImageStreamCompleter(_loadCodec(key, decode))..addOnLastListenerRemovedCallback(cancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Codec> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) async {
|
Stream<ImageInfo> _loadCodec(ThumbHashProvider key, ImageDecoderCallback decode) {
|
||||||
final image = thumbHashToRGBA(base64Decode(key.thumbHash));
|
return loadRequest(ThumbhashImageRequest(thumbhash: key.thumbHash), decode);
|
||||||
return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,136 @@
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
// ignore: implementation_imports
|
||||||
|
import 'package:flutter_cache_manager/src/cache_store.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// The cache manager for full size images [ImmichRemoteImageProvider]
|
abstract class RemoteCacheManager extends CacheManager {
|
||||||
class RemoteImageCacheManager extends CacheManager {
|
static final _log = Logger('RemoteCacheManager');
|
||||||
|
|
||||||
|
RemoteCacheManager.custom(super.config, CacheStore store)
|
||||||
|
// Unfortunately, CacheStore is not a public API
|
||||||
|
// ignore: invalid_use_of_visible_for_testing_member
|
||||||
|
: super.custom(cacheStore: store);
|
||||||
|
|
||||||
|
Future<void> putStreamedFile(
|
||||||
|
String url,
|
||||||
|
Stream<List<int>> source, {
|
||||||
|
String? key,
|
||||||
|
String? eTag,
|
||||||
|
Duration maxAge = const Duration(days: 30),
|
||||||
|
String fileExtension = 'file',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unlike `putFileStream`, this method handles request cancellation,
|
||||||
|
// does not make a (slow) DB call checking if the file is already cached,
|
||||||
|
// does not synchronously check if a file exists,
|
||||||
|
// and deletes the file on cancellation without making these checks again.
|
||||||
|
Future<void> putStreamedFileToStore(
|
||||||
|
CacheStore store,
|
||||||
|
String url,
|
||||||
|
Stream<List<int>> source, {
|
||||||
|
String? key,
|
||||||
|
String? eTag,
|
||||||
|
Duration maxAge = const Duration(days: 30),
|
||||||
|
String fileExtension = 'file',
|
||||||
|
}) async {
|
||||||
|
final path = '${const Uuid().v1()}.$fileExtension';
|
||||||
|
final file = await store.fileSystem.createFile(path);
|
||||||
|
final sink = file.openWrite();
|
||||||
|
try {
|
||||||
|
await source.pipe(sink);
|
||||||
|
} catch (e) {
|
||||||
|
await sink.close();
|
||||||
|
try {
|
||||||
|
await file.delete();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Failed to delete incomplete cache file: $e');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cacheObject = CacheObject(
|
||||||
|
url,
|
||||||
|
key: key,
|
||||||
|
relativePath: path,
|
||||||
|
validTill: DateTime.now().add(maxAge),
|
||||||
|
eTag: eTag,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await store.putFile(cacheObject);
|
||||||
|
} catch (e) {
|
||||||
|
try {
|
||||||
|
await file.delete();
|
||||||
|
} catch (e) {
|
||||||
|
_log.severe('Failed to delete untracked cache file: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteImageCacheManager extends RemoteCacheManager {
|
||||||
static const key = 'remoteImageCacheKey';
|
static const key = 'remoteImageCacheKey';
|
||||||
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
|
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
|
||||||
|
static final _config = Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30));
|
||||||
|
static final _store = CacheStore(_config);
|
||||||
|
|
||||||
factory RemoteImageCacheManager() {
|
factory RemoteImageCacheManager() {
|
||||||
return _instance;
|
return _instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoteImageCacheManager._() : super(Config(key, maxNrOfCacheObjects: 500, stalePeriod: const Duration(days: 30)));
|
RemoteImageCacheManager._() : super.custom(_config, _store);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putStreamedFile(
|
||||||
|
String url,
|
||||||
|
Stream<List<int>> source, {
|
||||||
|
String? key,
|
||||||
|
String? eTag,
|
||||||
|
Duration maxAge = const Duration(days: 30),
|
||||||
|
String fileExtension = 'file',
|
||||||
|
}) {
|
||||||
|
return putStreamedFileToStore(
|
||||||
|
_store,
|
||||||
|
url,
|
||||||
|
source,
|
||||||
|
key: key,
|
||||||
|
eTag: eTag,
|
||||||
|
maxAge: maxAge,
|
||||||
|
fileExtension: fileExtension,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache manager for full size images [ImmichRemoteImageProvider]
|
||||||
|
class RemoteThumbnailCacheManager extends RemoteCacheManager {
|
||||||
|
static const key = 'remoteThumbnailCacheKey';
|
||||||
|
static final RemoteThumbnailCacheManager _instance = RemoteThumbnailCacheManager._();
|
||||||
|
static final _config = Config(key, maxNrOfCacheObjects: 5000, stalePeriod: const Duration(days: 30));
|
||||||
|
static final _store = CacheStore(_config);
|
||||||
|
|
||||||
|
factory RemoteThumbnailCacheManager() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteThumbnailCacheManager._() : super.custom(_config, _store);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> putStreamedFile(
|
||||||
|
String url,
|
||||||
|
Stream<List<int>> source, {
|
||||||
|
String? key,
|
||||||
|
String? eTag,
|
||||||
|
Duration maxAge = const Duration(days: 30),
|
||||||
|
String fileExtension = 'file',
|
||||||
|
}) {
|
||||||
|
return putStreamedFileToStore(
|
||||||
|
_store,
|
||||||
|
url,
|
||||||
|
source,
|
||||||
|
key: key,
|
||||||
|
eTag: eTag,
|
||||||
|
maxAge: maxAge,
|
||||||
|
fileExtension: fileExtension,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
import 'package:immich_mobile/platform/native_sync_api.g.dart';
|
||||||
|
import 'package:immich_mobile/platform/thumbnail_api.g.dart';
|
||||||
|
|
||||||
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
|
||||||
|
|
||||||
|
final thumbnailApi = ThumbnailApi();
|
||||||
|
|
|
||||||
19
mobile/lib/utils/cache/custom_image_cache.dart
vendored
19
mobile/lib/utils/cache/custom_image_cache.dart
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/local_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/remote_image_provider.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
|
||||||
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
||||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||||
|
|
@ -9,6 +10,7 @@ import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.d
|
||||||
/// [ImageCache] that uses two caches for small and large images
|
/// [ImageCache] that uses two caches for small and large images
|
||||||
/// so that a single large image does not evict all small images
|
/// so that a single large image does not evict all small images
|
||||||
final class CustomImageCache implements ImageCache {
|
final class CustomImageCache implements ImageCache {
|
||||||
|
final _thumbhash = ImageCache()..maximumSize = 0;
|
||||||
final _small = ImageCache();
|
final _small = ImageCache();
|
||||||
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images
|
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images
|
||||||
|
|
||||||
|
|
@ -39,13 +41,16 @@ final class CustomImageCache implements ImageCache {
|
||||||
/// Gets the cache for the given key
|
/// Gets the cache for the given key
|
||||||
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
|
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
|
||||||
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
|
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
|
||||||
ImageCache _cacheForKey(Object key) =>
|
ImageCache _cacheForKey(Object key) {
|
||||||
(key is ImmichLocalImageProvider ||
|
return switch (key) {
|
||||||
key is ImmichRemoteImageProvider ||
|
ImmichLocalImageProvider() ||
|
||||||
key is LocalFullImageProvider ||
|
ImmichRemoteImageProvider() ||
|
||||||
key is RemoteFullImageProvider)
|
LocalFullImageProvider() ||
|
||||||
? _large
|
RemoteFullImageProvider() => _large,
|
||||||
: _small;
|
ThumbHashProvider() => _thumbhash,
|
||||||
|
_ => _small,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool containsKey(Object key) {
|
bool containsKey(Object key) {
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||||
filterQuality: widget.filterQuality,
|
filterQuality: widget.filterQuality,
|
||||||
width: scaleBoundaries.childSize.width * scale,
|
width: scaleBoundaries.childSize.width * scale,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
isAntiAlias: widget.filterQuality == FilterQuality.high,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue