mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
better scrolling
This commit is contained in:
parent
c988342de1
commit
3100702e93
8 changed files with 233 additions and 88 deletions
|
|
@ -32,3 +32,31 @@ class FastClampingScrollPhysics extends ClampingScrollPhysics {
|
||||||
damping: 80,
|
damping: 80,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ScrollUnawareScrollPhysics extends ScrollPhysics {
|
||||||
|
const ScrollUnawareScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollUnawareScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return ScrollUnawareScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScrollUnawareClampingScrollPhysics extends ClampingScrollPhysics {
|
||||||
|
const ScrollUnawareClampingScrollPhysics({super.parent});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollUnawareClampingScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||||
|
return ScrollUnawareClampingScrollPhysics(parent: buildParent(ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ abstract class ImageRequest {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_isCancelled = true;
|
_isCancelled = true;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
debugPrint('Cancelling image request $requestId');
|
||||||
|
}
|
||||||
return _onCancelled();
|
return _onCancelled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +40,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,7 +90,15 @@ class ThumbhashImageRequest extends ImageRequest {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stopwatch? stopwatch;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch = Stopwatch()..start();
|
||||||
|
}
|
||||||
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
|
final Map<String, int> info = await thumbnailApi.getThumbhash(thumbhash);
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch!.stop();
|
||||||
|
debugPrint('Thumbhash request $requestId took ${stopwatch.elapsedMilliseconds} ms');
|
||||||
|
}
|
||||||
final frame = await _fromPlatformImage(info);
|
final frame = await _fromPlatformImage(info);
|
||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
@ -108,12 +122,20 @@ class LocalImageRequest extends ImageRequest {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Stopwatch? stopwatch;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch = Stopwatch()..start();
|
||||||
|
}
|
||||||
final Map<String, int> info = await thumbnailApi.requestImage(
|
final Map<String, int> info = await thumbnailApi.requestImage(
|
||||||
localId,
|
localId,
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
);
|
);
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch!.stop();
|
||||||
|
debugPrint('Local image request $requestId took ${stopwatch.elapsedMilliseconds} ms');
|
||||||
|
}
|
||||||
final frame = await _fromPlatformImage(info);
|
final frame = await _fromPlatformImage(info);
|
||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +149,7 @@ class LocalImageRequest extends ImageRequest {
|
||||||
class RemoteImageRequest extends ImageRequest {
|
class RemoteImageRequest extends ImageRequest {
|
||||||
static final log = Logger('RemoteImageRequest');
|
static final log = Logger('RemoteImageRequest');
|
||||||
static final cacheManager = RemoteImageCacheManager();
|
static final cacheManager = RemoteImageCacheManager();
|
||||||
static final client = HttpClient();
|
static final client = HttpClient()..maxConnectionsPerHost = 32;
|
||||||
String uri;
|
String uri;
|
||||||
Map<String, String> headers;
|
Map<String, String> headers;
|
||||||
HttpClientRequest? _request;
|
HttpClientRequest? _request;
|
||||||
|
|
@ -140,29 +162,42 @@ class RemoteImageRequest extends ImageRequest {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// TODO: the cache manager makes everything sequential with its DB calls and its operations cannot be cancelled,
|
||||||
// The DB calls made by the cache manager are a *massive* bottleneck (6+ seconds) with high concurrency.
|
// so it just makes things slower and more memory hungry. Even just saving files to disk
|
||||||
// Since it isn't possible to cancel these operations, we only prefer the cache when they can be avoided.
|
// for offline use adds too much overhead as these calls add up. We only prefer fetching from it when
|
||||||
// The DB hit is left as a fallback for offline use.
|
// it can skip the DB call.
|
||||||
final cachedFileBuffer = await _loadCachedFile(uri, inMemoryOnly: true);
|
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: true);
|
||||||
if (cachedFileBuffer != null) {
|
if (cachedFileImage != null) {
|
||||||
return _decodeBuffer(cachedFileBuffer, decode, scale);
|
return cachedFileImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Stopwatch? stopwatch;
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch = Stopwatch()..start();
|
||||||
|
}
|
||||||
final buffer = await _downloadImage(uri);
|
final buffer = await _downloadImage(uri);
|
||||||
if (buffer == null || _isCancelled) {
|
if (buffer == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
stopwatch!.stop();
|
||||||
|
debugPrint('Remote image download request $requestId took ${stopwatch.elapsedMilliseconds} ms');
|
||||||
|
}
|
||||||
return await _decodeBuffer(buffer, decode, scale);
|
return await _decodeBuffer(buffer, decode, scale);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is HttpException && (e.message.endsWith('aborted') || e.message.startsWith('Connection closed'))) {
|
if (_isCancelled) {
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
debugPrint('Remote image download request for $requestId was cancelled');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
log.severe('Failed to load remote image', e);
|
|
||||||
final buffer = await _loadCachedFile(uri, inMemoryOnly: false);
|
final cachedFileImage = await _loadCachedFile(uri, decode, scale, inMemoryOnly: false);
|
||||||
if (buffer != null) {
|
if (cachedFileImage != null) {
|
||||||
return _decodeBuffer(buffer, decode, scale);
|
return cachedFileImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
rethrow;
|
rethrow;
|
||||||
} finally {
|
} finally {
|
||||||
_request = null;
|
_request = null;
|
||||||
|
|
@ -170,45 +205,59 @@ class RemoteImageRequest extends ImageRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
Future<ImmutableBuffer?> _downloadImage(String url) async {
|
||||||
final request = _request = await client.getUrl(Uri.parse(url));
|
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final request = _request = await client.getUrl(Uri.parse(url));
|
||||||
|
if (_isCancelled) {
|
||||||
|
request.abort();
|
||||||
|
return _request = null;
|
||||||
|
}
|
||||||
|
|
||||||
final headers = ApiService.getRequestHeaders();
|
final headers = ApiService.getRequestHeaders();
|
||||||
for (final entry in headers.entries) {
|
for (final entry in headers.entries) {
|
||||||
request.headers.set(entry.key, entry.value);
|
request.headers.set(entry.key, entry.value);
|
||||||
}
|
}
|
||||||
final response = await request.close();
|
final response = await request.close();
|
||||||
if (_isCancelled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final bytes = await consolidateHttpClientResponseBytes(response);
|
final bytes = await consolidateHttpClientResponseBytes(response);
|
||||||
_cacheFile(url, bytes);
|
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await ImmutableBuffer.fromUint8List(bytes);
|
return await ImmutableBuffer.fromUint8List(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cacheFile(String url, Uint8List bytes) async {
|
Future<ImageInfo?> _loadCachedFile(
|
||||||
try {
|
String url,
|
||||||
await cacheManager.putFile(url, bytes);
|
ImageDecoderCallback decode,
|
||||||
} catch (e) {
|
double scale, {
|
||||||
log.severe('Failed to cache image', e);
|
required bool inMemoryOnly,
|
||||||
}
|
}) async {
|
||||||
}
|
|
||||||
|
|
||||||
Future<ImmutableBuffer?> _loadCachedFile(String url, {required bool inMemoryOnly}) async {
|
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
final file = await (inMemoryOnly ? cacheManager.getFileFromMemory(url) : cacheManager.getFileFromCache(url));
|
||||||
if (_isCancelled || file == null) {
|
if (_isCancelled || file == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await ImmutableBuffer.fromFilePath(file.file.path);
|
|
||||||
|
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 {
|
Future<ImageInfo?> _decodeBuffer(ImmutableBuffer buffer, ImageDecoderCallback decode, scale) async {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ 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/presentation/widgets/images/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/presentation/widgets/timeline/constants.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||||
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.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';
|
import 'package:octo_image/octo_image.dart';
|
||||||
|
|
||||||
class Thumbnail extends StatelessWidget {
|
class Thumbnail extends StatefulWidget {
|
||||||
const Thumbnail({this.asset, this.remoteId, this.size = const Size.square(256), this.fit = BoxFit.cover, super.key})
|
const Thumbnail({this.asset, this.remoteId, this.size = kTimelineFixedTileExtent, this.fit = BoxFit.cover, super.key})
|
||||||
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
: assert(asset != null || remoteId != null, 'Either asset or remoteId must be provided');
|
||||||
|
|
||||||
final BaseAsset? asset;
|
final BaseAsset? asset;
|
||||||
|
|
@ -16,46 +17,79 @@ class Thumbnail extends StatelessWidget {
|
||||||
final Size size;
|
final Size size;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
createState() => _ThumbnailState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ThumbnailState extends State<Thumbnail> {
|
||||||
|
ImageProvider? provider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
provider = getThumbnailImageProvider(asset: widget.asset, remoteId: widget.remoteId);
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant Thumbnail oldWidget) {
|
||||||
|
if (oldWidget.asset == widget.asset && oldWidget.remoteId == widget.remoteId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (provider is CancellableImageProvider) {
|
||||||
|
(provider as CancellableImageProvider).cancel();
|
||||||
|
}
|
||||||
|
provider = getThumbnailImageProvider(asset: widget.asset, remoteId: widget.remoteId);
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final thumbHash = asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null;
|
final thumbHash = widget.asset is RemoteAsset ? (widget.asset as RemoteAsset).thumbHash : null;
|
||||||
final provider = getThumbnailImageProvider(asset: asset, remoteId: remoteId);
|
|
||||||
|
|
||||||
return OctoImage.fromSet(
|
return OctoImage.fromSet(
|
||||||
image: provider,
|
image: provider!,
|
||||||
octoSet: OctoSet(
|
octoSet: OctoSet(
|
||||||
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit),
|
placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash),
|
||||||
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, fit: fit, asset: asset),
|
errorBuilder: _blurHashErrorBuilder(thumbHash, provider: provider, asset: widget.asset),
|
||||||
),
|
),
|
||||||
fadeOutDuration: const Duration(milliseconds: 100),
|
fadeOutDuration: const Duration(milliseconds: 100),
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
width: size.width,
|
width: widget.size.width,
|
||||||
height: size.height,
|
height: widget.size.height,
|
||||||
fit: fit,
|
fit: widget.fit,
|
||||||
placeholderFadeInDuration: Duration.zero,
|
placeholderFadeInDuration: Duration.zero,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash, {BoxFit? fit}) {
|
@override
|
||||||
return (context) => thumbHash == null
|
void dispose() {
|
||||||
? const ThumbnailPlaceholder()
|
if (provider is CancellableImageProvider) {
|
||||||
: FadeInPlaceholderImage(
|
(provider as CancellableImageProvider).cancel();
|
||||||
placeholder: const ThumbnailPlaceholder(),
|
}
|
||||||
image: ThumbHashProvider(thumbHash: thumbHash),
|
super.dispose();
|
||||||
fit: fit ?? BoxFit.cover,
|
}
|
||||||
|
|
||||||
|
OctoPlaceholderBuilder _blurHashPlaceholderBuilder(String? thumbHash) {
|
||||||
|
return (context) => thumbHash == null
|
||||||
|
? const ThumbnailPlaceholder()
|
||||||
|
: FadeInPlaceholderImage(
|
||||||
|
placeholder: const ThumbnailPlaceholder(),
|
||||||
|
image: ThumbHashProvider(thumbHash: thumbHash),
|
||||||
|
fit: widget.fit,
|
||||||
|
width: widget.size.width,
|
||||||
|
height: widget.size.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider}) =>
|
||||||
|
(context, e, s) {
|
||||||
|
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
_blurHashPlaceholderBuilder(blurhash)(context),
|
||||||
|
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
OctoErrorBuilder _blurHashErrorBuilder(String? blurhash, {BaseAsset? asset, ImageProvider? provider, BoxFit? fit}) =>
|
|
||||||
(context, e, s) {
|
|
||||||
Logger("ImThumbnail").warning("Error loading thumbnail for ${asset?.name}", e, s);
|
|
||||||
provider?.evict();
|
|
||||||
return Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
_blurHashPlaceholderBuilder(blurhash, fit: fit)(context),
|
|
||||||
const Opacity(opacity: 0.75, child: Icon(Icons.error_outline_rounded)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
|
||||||
|
|
@ -106,7 +107,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
bool _dragging = false;
|
bool _dragging = false;
|
||||||
TimelineAssetIndex? _dragAnchorIndex;
|
TimelineAssetIndex? _dragAnchorIndex;
|
||||||
final Set<BaseAsset> _draggedAssets = HashSet();
|
final Set<BaseAsset> _draggedAssets = HashSet();
|
||||||
ScrollPhysics? _scrollPhysics;
|
ScrollPhysics _scrollPhysics = const ScrollUnawareScrollPhysics();
|
||||||
|
|
||||||
int _perRow = 4;
|
int _perRow = 4;
|
||||||
double _scaleFactor = 3.0;
|
double _scaleFactor = 3.0;
|
||||||
|
|
@ -188,7 +189,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
// Drag selection methods
|
// Drag selection methods
|
||||||
void _setDragStartIndex(TimelineAssetIndex index) {
|
void _setDragStartIndex(TimelineAssetIndex index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_scrollPhysics = const ClampingScrollPhysics();
|
_scrollPhysics = const ScrollUnawareClampingScrollPhysics();
|
||||||
_dragAnchorIndex = index;
|
_dragAnchorIndex = index;
|
||||||
_dragging = true;
|
_dragging = true;
|
||||||
});
|
});
|
||||||
|
|
@ -198,7 +199,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Update the physics post frame to prevent sudden change in physics on iOS.
|
// Update the physics post frame to prevent sudden change in physics on iOS.
|
||||||
setState(() {
|
setState(() {
|
||||||
_scrollPhysics = null;
|
_scrollPhysics = const ScrollUnawareScrollPhysics();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,43 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/widgets/common/transparent_image.dart';
|
|
||||||
|
|
||||||
class FadeInPlaceholderImage extends StatelessWidget {
|
class FadeInPlaceholderImage extends StatelessWidget {
|
||||||
final Widget placeholder;
|
final Widget placeholder;
|
||||||
final ImageProvider image;
|
final ImageProvider image;
|
||||||
final Duration duration;
|
final Duration duration;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
|
final double width;
|
||||||
|
final double height;
|
||||||
|
|
||||||
const FadeInPlaceholderImage({
|
const FadeInPlaceholderImage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.placeholder,
|
required this.placeholder,
|
||||||
required this.image,
|
required this.image,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
this.duration = const Duration(milliseconds: 100),
|
this.duration = const Duration(milliseconds: 100),
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox.expand(
|
final stopwatch = Stopwatch()..start();
|
||||||
child: Stack(
|
return Image(
|
||||||
fit: StackFit.expand,
|
image: image,
|
||||||
children: [
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||||
placeholder,
|
if (frame == null) {
|
||||||
FadeInImage(fadeInDuration: duration, image: image, fit: fit, placeholder: MemoryImage(kTransparentImage)),
|
return AnimatedSwitcher(duration: duration, child: placeholder);
|
||||||
],
|
}
|
||||||
),
|
|
||||||
|
stopwatch.stop();
|
||||||
|
if (stopwatch.elapsedMilliseconds < 32) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
return AnimatedSwitcher(duration: duration, child: child);
|
||||||
|
},
|
||||||
|
filterQuality: FilterQuality.low,
|
||||||
|
fit: fit,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class ImmichThumbnail extends HookConsumerWidget {
|
||||||
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
|
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
|
||||||
thumbnailProviderInstance.evict();
|
thumbnailProviderInstance.evict();
|
||||||
|
|
||||||
final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, fit: fit);
|
final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit);
|
||||||
return originalErrorWidgetBuilder(ctx, error, stackTrace);
|
return originalErrorWidgetBuilder(ctx, error, stackTrace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ class ImmichThumbnail extends HookConsumerWidget {
|
||||||
fadeInDuration: Duration.zero,
|
fadeInDuration: Duration.zero,
|
||||||
fadeOutDuration: const Duration(milliseconds: 100),
|
fadeOutDuration: const Duration(milliseconds: 100),
|
||||||
octoSet: OctoSet(
|
octoSet: OctoSet(
|
||||||
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
|
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit),
|
||||||
errorBuilder: customErrorBuilder,
|
errorBuilder: customErrorBuilder,
|
||||||
),
|
),
|
||||||
image: thumbnailProviderInstance,
|
image: thumbnailProviderInstance,
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,40 @@ import 'package:octo_image/octo_image.dart';
|
||||||
|
|
||||||
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
|
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
|
||||||
/// placeholder and [OctoError.icon] as error.
|
/// placeholder and [OctoError.icon] as error.
|
||||||
OctoSet blurHashOrPlaceholder(Uint8List? blurhash, {BoxFit? fit, Text? errorMessage}) {
|
OctoSet blurHashOrPlaceholder(
|
||||||
|
Uint8List? blurhash, {
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
BoxFit? fit,
|
||||||
|
Text? errorMessage,
|
||||||
|
}) {
|
||||||
return OctoSet(
|
return OctoSet(
|
||||||
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
|
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, width: width, height: height, fit: fit),
|
||||||
errorBuilder: blurHashErrorBuilder(blurhash, fit: fit, message: errorMessage),
|
errorBuilder: blurHashErrorBuilder(blurhash, width: width, height: height, fit: fit, message: errorMessage),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
OctoPlaceholderBuilder blurHashPlaceholderBuilder(Uint8List? blurhash, {BoxFit? fit}) {
|
OctoPlaceholderBuilder blurHashPlaceholderBuilder(
|
||||||
|
Uint8List? blurhash, {
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
|
BoxFit? fit,
|
||||||
|
}) {
|
||||||
return (context) => blurhash == null
|
return (context) => blurhash == null
|
||||||
? const ThumbnailPlaceholder()
|
? const ThumbnailPlaceholder()
|
||||||
: FadeInPlaceholderImage(
|
: FadeInPlaceholderImage(
|
||||||
placeholder: const ThumbnailPlaceholder(),
|
placeholder: const ThumbnailPlaceholder(),
|
||||||
image: MemoryImage(blurhash),
|
image: MemoryImage(blurhash),
|
||||||
fit: fit ?? BoxFit.cover,
|
fit: fit ?? BoxFit.cover,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
OctoErrorBuilder blurHashErrorBuilder(
|
OctoErrorBuilder blurHashErrorBuilder(
|
||||||
Uint8List? blurhash, {
|
Uint8List? blurhash, {
|
||||||
|
required double width,
|
||||||
|
required double height,
|
||||||
BoxFit? fit,
|
BoxFit? fit,
|
||||||
Text? message,
|
Text? message,
|
||||||
IconData? icon,
|
IconData? icon,
|
||||||
|
|
@ -32,7 +47,7 @@ OctoErrorBuilder blurHashErrorBuilder(
|
||||||
double? iconSize,
|
double? iconSize,
|
||||||
}) {
|
}) {
|
||||||
return OctoError.placeholderWithErrorIcon(
|
return OctoError.placeholderWithErrorIcon(
|
||||||
blurHashPlaceholderBuilder(blurhash, fit: fit),
|
blurHashPlaceholderBuilder(blurhash, width: width, height: width, fit: fit),
|
||||||
message: message,
|
message: message,
|
||||||
icon: icon,
|
icon: icon,
|
||||||
iconColor: iconColor,
|
iconColor: iconColor,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue