feat(flutter): disk-persistent cover cache via cached_network_image

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.
This commit is contained in:
2026-05-13 17:59:42 -04:00
parent ae5de91006
commit f732c49645
5 changed files with 81 additions and 18 deletions
@@ -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),
+7 -1
View File
@@ -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
@@ -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/<id>/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 <img> 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 <img> 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,
: <String, String>{};
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,
);
},
);
+32
View File
@@ -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:
+7
View File
@@ -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: