From 861ee65113db439fa66bdebcc6bfcdaeecfa1ad4 Mon Sep 17 00:00:00 2001 From: Mehakdeep Singh <118588258+mrandhawa14@users.noreply.github.com> Date: Sat, 9 May 2026 23:37:44 -0700 Subject: [PATCH] perf(library): decode covers at thumbnail resolution to cut image-cache RAM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../widgets/library_gridview_widget.dart | 4 +- .../widgets/library_listview_widget.dart | 4 +- .../widgets/manga_image_card_widget.dart | 7 ++-- lib/utils/cached_network.dart | 41 +++++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/lib/modules/library/widgets/library_gridview_widget.dart b/lib/modules/library/widgets/library_gridview_widget.dart index ad12b35d..666c26d7 100644 --- a/lib/modules/library/widgets/library_gridview_widget.dart +++ b/lib/modules/library/widgets/library_gridview_widget.dart @@ -7,7 +7,7 @@ import 'package:mangayomi/modules/library/providers/library_state_provider.dart' import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/modules/library/widgets/continue_reader_button.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; -import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart'; +import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/utils/headers.dart'; @@ -77,7 +77,7 @@ class _LibraryGridViewWidgetState extends State { image: entry.customCoverImage != null ? MemoryImage(entry.customCoverImage as Uint8List) as ImageProvider - : CustomExtendedNetworkImageProvider( + : coverProvider( toImgUrl( entry.customCoverFromTracker ?? entry.imageUrl ?? diff --git a/lib/modules/library/widgets/library_listview_widget.dart b/lib/modules/library/widgets/library_listview_widget.dart index 006df9b9..eb34bc7d 100644 --- a/lib/modules/library/widgets/library_listview_widget.dart +++ b/lib/modules/library/widgets/library_listview_widget.dart @@ -7,7 +7,7 @@ import 'package:mangayomi/modules/library/providers/library_state_provider.dart' import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/modules/library/widgets/continue_reader_button.dart'; import 'package:mangayomi/modules/manga/detail/providers/state_providers.dart'; -import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart'; +import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/constant.dart'; import 'package:mangayomi/utils/headers.dart'; @@ -127,7 +127,7 @@ class LibraryListViewWidget extends StatelessWidget { as Uint8List, ) as ImageProvider - : CustomExtendedNetworkImageProvider( + : coverProvider( toImgUrl( entry.customCoverFromTracker ?? entry.imageUrl!, diff --git a/lib/modules/widgets/manga_image_card_widget.dart b/lib/modules/widgets/manga_image_card_widget.dart index 166c253c..feb817b0 100644 --- a/lib/modules/widgets/manga_image_card_widget.dart +++ b/lib/modules/widgets/manga_image_card_widget.dart @@ -9,7 +9,7 @@ import 'package:mangayomi/models/manga.dart'; import 'package:mangayomi/models/settings.dart'; import 'package:mangayomi/models/source.dart'; import 'package:mangayomi/modules/manga/detail/manga_detail_main.dart'; -import 'package:mangayomi/modules/widgets/custom_extended_image_provider.dart'; +import 'package:mangayomi/utils/cached_network.dart'; import 'package:mangayomi/router/router.dart'; import 'package:mangayomi/utils/extensions/build_context_extensions.dart'; import 'package:mangayomi/utils/constant.dart'; @@ -46,7 +46,7 @@ class MangaImageCardWidget extends ConsumerWidget { image: hasData && libraryManga!.customCoverImage != null ? MemoryImage(libraryManga!.customCoverImage as Uint8List) as ImageProvider - : CustomExtendedNetworkImageProvider( + : coverProvider( toImgUrl( hasData ? libraryManga!.customCoverFromTracker ?? @@ -61,7 +61,6 @@ class MangaImageCardWidget extends ConsumerWidget { sourceId: source.id, ), ), - cache: true, cacheMaxAge: const Duration(days: 7), ), onTap: () => pushToMangaReaderDetail( @@ -151,7 +150,7 @@ class MangaImageCardListTileWidget extends ConsumerWidget { final image = hasData && libraryManga!.customCoverImage != null ? MemoryImage(libraryManga!.customCoverImage as Uint8List) as ImageProvider - : CustomExtendedNetworkImageProvider( + : coverProvider( toImgUrl( hasData ? libraryManga!.customCoverFromTracker ?? diff --git a/lib/utils/cached_network.dart b/lib/utils/cached_network.dart index c518fffb..43229435 100644 --- a/lib/utils/cached_network.dart +++ b/lib/utils/cached_network.dart @@ -2,6 +2,47 @@ 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? headers, + int maxBytes = _coverMaxBytes, + bool cache = true, + Duration? cacheMaxAge, +}) { + return ExtendedResizeImage( + CustomExtendedNetworkImageProvider( + url, + headers: headers, + cache: cache, + cacheMaxAge: cacheMaxAge, + ), + maxBytes: maxBytes, + ); +} + Widget cachedNetworkImage({ Map? headers, required String imageUrl,