mangayomi-mirror/lib/utils/cached_network.dart
Mehakdeep Singh 861ee65113 perf(library): decode covers at thumbnail resolution to cut image-cache RAM
Refs #609 (high RAM with stutters).

Manga / anime covers from sources are typically 720x1080 or larger
(~3 MB decoded RGBA per cover). The library grid, library list and
generic browse / search card widgets render those covers at roughly
150x220 logical pixels, but every cover decoded to its full source
resolution and that decoded bitmap landed in Flutter's `imageCache`.
With 30-50 covers in flight during a normal scroll, the default
100 MB cache filled and the engine started evicting + re-decoding
aggressively — exactly the symptom in #609 (stutters + high RAM).

Mangayomi already had `ExtendedResizeImage` available via the
`extended_image_library` package and used it in one place
(`cachedCompressedNetworkImage`, called only from the History
screen). This commit generalises that pattern.

Add a `coverProvider()` helper in `lib/utils/cached_network.dart`
that wraps `CustomExtendedNetworkImageProvider` in
`ExtendedResizeImage` with a 200 KB encoded budget — sharp at
typical thumbnail size on high-DPR screens, ~3.6x smaller decoded
than a full-resolution cover. Pass through the same `cache` /
`cacheMaxAge` knobs the underlying provider exposes so existing
disk-cache behaviour is preserved.

Swap the three high-traffic thumbnail call sites to use it:

* `lib/modules/library/widgets/library_gridview_widget.dart`
* `lib/modules/library/widgets/library_listview_widget.dart`
* `lib/modules/widgets/manga_image_card_widget.dart`
  (both `MangaImageCardWidget` and `MangaImageCardListTileWidget`,
  used by browse and search results)

Deliberately not changed:

* The manga / anime detail page hero cover — large display, full
  resolution is appropriate.
* Reader pages — already memory-managed by `ChapterPreloadManager`
  and need full resolution for actual reading.
* `cachedNetworkImage()` and other lower-traffic thumbnail surfaces
  (tracker results, calendar, recommendation grid). Easy to extend
  in a follow-up if anyone asks; kept narrow here so review is
  manageable.

Verified

* `flutter analyze` clean on every touched file
* `flutter build macos --release` succeeds
* Smoke-tested on macOS with the local-all-fixes build: library
  grid, library list and browse card all render identical-looking
  covers at typical thumbnail sizes; no visible quality regression
  at the displayed scale
2026-05-09 23:37:44 -07:00

107 lines
3.5 KiB
Dart

import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart';
/// Default upper bound on the encoded byte size of a thumbnail-sized cover
/// after `ExtendedResizeImage` resamples. 200 KB roughly maps to a 400x600
/// JPEG / WebP — sharp enough at typical grid / list display sizes on
/// high-DPR screens, while keeping the *decoded* RAM footprint small (the
/// resampled image goes into Flutter's `imageCache` decoded, where every
/// saved KB matters).
const int _coverMaxBytes = 200 << 10;
/// Returns an `ImageProvider` for a manga / anime cover URL that decodes at
/// thumbnail resolution rather than the source resolution.
///
/// Source covers are commonly 720x1080 or larger (~3 MB decoded RGBA per
/// cover). When used directly in a library grid or list, every visible
/// thumbnail fills `imageCache` with a 3 MB blob even though it renders at
/// ~150x220 logical pixels. Wrapping the provider in `ExtendedResizeImage`
/// instructs the decoder to resample to a much smaller bitmap before it
/// hits the cache, so the same in-memory budget holds far more thumbnails
/// and scrolling stops thrashing.
///
/// Use this for thumbnail call sites (library grid / list, browse search
/// cards, tracker results, calendar, etc.). Do *not* use it for large hero
/// covers (manga detail page) or for reader pages, which need full
/// resolution.
ImageProvider coverProvider(
String url, {
Map<String, String>? headers,
int maxBytes = _coverMaxBytes,
bool cache = true,
Duration? cacheMaxAge,
}) {
return ExtendedResizeImage(
CustomExtendedNetworkImageProvider(
url,
headers: headers,
cache: cache,
cacheMaxAge: cacheMaxAge,
),
maxBytes: maxBytes,
);
}
Widget cachedNetworkImage({
Map<String, String>? headers,
required String imageUrl,
required double? width,
required double? height,
required BoxFit? fit,
AlignmentGeometry? alignment,
bool useCustomNetworkImage = true,
Widget errorWidget = const Icon(Icons.error, size: 50),
}) {
return ExtendedImage(
image: useCustomNetworkImage
? CustomExtendedNetworkImageProvider(imageUrl, headers: headers)
: ExtendedNetworkImageProvider(imageUrl, headers: headers),
width: width,
height: height,
fit: fit,
filterQuality: FilterQuality.medium,
mode: ExtendedImageMode.none,
handleLoadingProgress: true,
loadStateChanged: (state) {
if (state.extendedImageLoadState == LoadState.failed) {
return errorWidget;
}
return null;
},
);
}
Widget cachedCompressedNetworkImage({
Map<String, String>? headers,
required String imageUrl,
required double? width,
required double? height,
required BoxFit? fit,
AlignmentGeometry? alignment,
bool useCustomNetworkImage = true,
Widget errorWidget = const Icon(Icons.error, size: 50),
int maxBytes = 5 << 10,
}) {
return ExtendedImage(
image: ExtendedResizeImage(
useCustomNetworkImage
? CustomExtendedNetworkImageProvider(imageUrl, headers: headers)
: ExtendedNetworkImageProvider(imageUrl, headers: headers),
maxBytes: maxBytes,
),
width: width,
height: height,
fit: fit,
filterQuality: FilterQuality.medium,
mode: ExtendedImageMode.none,
handleLoadingProgress: true,
clearMemoryCacheWhenDispose: true,
loadStateChanged: (state) {
if (state.extendedImageLoadState == LoadState.failed) {
return errorWidget;
}
return null;
},
);
}