From f732c49645f056622cb3ee036db2ab0482c9a2b3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 17:59:42 -0400 Subject: [PATCH] feat(flutter): disk-persistent cover cache via cached_network_image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the cover-caching pass. The previous Image.network / NetworkImage path only cached covers in memory, so a scroll-off + scroll- back or an app restart re-downloaded every tile from the server. Swap to cached_network_image so bytes land on disk (path_provider temp dir, URL-keyed) and survive both. Sites migrated: - ServerImage (all /api/*/cover usage — home grid, library, playlist, artist/album detail headers) - DiscoverScreen Lidarr suggestion thumbnails - PlayerBar mini cover (HTTPS branch; file:// branch unchanged since AlbumCoverCache files are already on disk) Auth header forwarding preserved via httpHeaders. Fade-in disabled so populated grids paint instantly on cache hit. Slice 2 (pre-warm during sync) builds on this same cache manager. --- .../lib/discover/discover_screen.dart | 12 ++++-- flutter_client/lib/player/player_bar.dart | 8 +++- .../lib/shared/widgets/server_image.dart | 40 ++++++++++++------- flutter_client/pubspec.lock | 32 +++++++++++++++ flutter_client/pubspec.yaml | 7 ++++ 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/flutter_client/lib/discover/discover_screen.dart b/flutter_client/lib/discover/discover_screen.dart index 409347be..92b6151d 100644 --- a/flutter_client/lib/discover/discover_screen.dart +++ b/flutter_client/lib/discover/discover_screen.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -204,9 +205,14 @@ class _ResultTile extends StatelessWidget { color: fs.slate, child: row.imageUrl.isEmpty ? Icon(Icons.album, color: fs.ash) - : Image.network(row.imageUrl, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - Icon(Icons.album, color: fs.ash)), + : CachedNetworkImage( + imageUrl: row.imageUrl, + fit: BoxFit.cover, + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorWidget: (_, __, ___) => + Icon(Icons.album, color: fs.ash), + ), ), ), const SizedBox(width: 12), diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 13c14165..261e8d8d 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:audio_service/audio_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -96,10 +97,15 @@ class _TrackInfo extends StatelessWidget { // so the transition works regardless of what's playing. Widget cover; if (media.artUri != null) { + // CachedNetworkImageProvider for HTTPS art URIs so the mini bar + // hits the same disk cache the rest of the UI uses (ServerImage, + // discover thumbnails). FileImage stays for the file:// branch + // populated by AlbumCoverCache — bytes are already on disk and a + // second cache layer would only burn duplicate space. cover = Image( image: media.artUri!.isScheme('file') ? FileImage(File.fromUri(media.artUri!)) as ImageProvider - : NetworkImage(media.artUri.toString()), + : CachedNetworkImageProvider(media.artUri.toString()), width: 48, height: 48, // Without a fit, Image paints the source at its intrinsic diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart index be44538e..e3080588 100644 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -1,9 +1,10 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../auth/auth_provider.dart'; -/// Image.network wrapper that resolves server-relative URLs (e.g. +/// CachedNetworkImage wrapper that resolves server-relative URLs (e.g. /// `/api/albums//cover`) against the configured server base URL /// from [serverUrlProvider]. Absolute URLs (with scheme) pass through /// unchanged. Empty/null base or empty URL render the [fallback] @@ -12,6 +13,11 @@ import '../../auth/auth_provider.dart'; /// /// Mirrors the absolute-or-relative logic the audio handler uses for /// stream URLs, so cover art and audio resolve URLs the same way. +/// +/// Uses CachedNetworkImage so cover bytes land on disk (path_provider +/// temp dir, keyed by URL) and survive scroll-off + app restart. The +/// previous Image.network behavior cached only in-memory, so the cold- +/// start home grid re-downloaded every cover. class ServerImage extends ConsumerWidget { const ServerImage({ super.key, @@ -31,17 +37,18 @@ class ServerImage extends ConsumerWidget { final base = ref.watch(serverUrlProvider).value; final resolved = _resolve(base, url); if (resolved == null) return empty; - // Cover endpoints are gated by RequireUser server-side. Image.network - // doesn't carry cookies/Bearer tokens automatically the way the - // browser's tag does, so we forward the session token as a - // header. Without this, the server returns 401 and the image fails. + // Cover endpoints are gated by RequireUser server-side. The image + // loader doesn't carry cookies/Bearer tokens automatically the way + // the browser's tag does, so we forward the session token as + // a header. Without this, the server returns 401 and the image + // fails. // // sessionTokenProvider is a FutureProvider — on first read after a // hot restart its .value is null until the secure-storage read - // resolves. If we fired Image.network with headers:null at that - // moment, the network image cache would lock in the 401 response - // and never retry. Instead, hold the fallback until the token is - // available, then mount the Image with the auth header attached. + // resolves. If we mounted the image with httpHeaders:null at that + // moment, the disk cache would lock in the 401 response and never + // retry. Hold the fallback until the token is available, then + // mount the image with the auth header attached. final tokenAsync = ref.watch(sessionTokenProvider); return tokenAsync.when( loading: () => empty, @@ -49,14 +56,19 @@ class ServerImage extends ConsumerWidget { data: (token) { final headers = (token != null && token.isNotEmpty) ? {'Authorization': 'Bearer $token'} - : null; - return Image.network( - resolved, + : {}; + return CachedNetworkImage( + imageUrl: resolved, + httpHeaders: headers, fit: fit, - headers: headers, + // No fadeIn — covers paint instantly once cached, and the + // default 500ms fade looks like a regression on a populated + // grid. + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, // Keep failures local — a single 401/timeout shouldn't dump // a stack trace or replace the parent Container's background. - errorBuilder: (_, __, ___) => empty, + errorWidget: (_, __, ___) => empty, ); }, ); diff --git a/flutter_client/pubspec.lock b/flutter_client/pubspec.lock index 88f2a920..eff2b43a 100644 --- a/flutter_client/pubspec.lock +++ b/flutter_client/pubspec.lock @@ -121,6 +121,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.12.6" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -672,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 7ba0dbd0..e05cf6c4 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -30,6 +30,13 @@ dependencies: connectivity_plus: ^6.0.5 flutter_timezone: ^4.1.1 palette_generator: ^0.3.3 + # Disk-persistent image cache. Image.network only caches in memory, so + # cover art repainted after a scroll-off or app restart re-fetches from + # the server. cached_network_image stores the bytes under + # path_provider's temp dir keyed by URL, surviving both. Used directly + # by ServerImage (auth-aware path) and as CachedNetworkImageProvider + # for the mini bar (which composes its own Image widget). + cached_network_image: ^3.4.1 dev_dependencies: flutter_test: