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:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -204,9 +205,14 @@ class _ResultTile extends StatelessWidget {
color: fs.slate, color: fs.slate,
child: row.imageUrl.isEmpty child: row.imageUrl.isEmpty
? Icon(Icons.album, color: fs.ash) ? Icon(Icons.album, color: fs.ash)
: Image.network(row.imageUrl, fit: BoxFit.cover, : CachedNetworkImage(
errorBuilder: (_, __, ___) => imageUrl: row.imageUrl,
Icon(Icons.album, color: fs.ash)), fit: BoxFit.cover,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
errorWidget: (_, __, ___) =>
Icon(Icons.album, color: fs.ash),
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
+7 -1
View File
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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. // so the transition works regardless of what's playing.
Widget cover; Widget cover;
if (media.artUri != null) { 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( cover = Image(
image: media.artUri!.isScheme('file') image: media.artUri!.isScheme('file')
? FileImage(File.fromUri(media.artUri!)) as ImageProvider ? FileImage(File.fromUri(media.artUri!)) as ImageProvider
: NetworkImage(media.artUri.toString()), : CachedNetworkImageProvider(media.artUri.toString()),
width: 48, width: 48,
height: 48, height: 48,
// Without a fit, Image paints the source at its intrinsic // 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/auth_provider.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 /// `/api/albums/<id>/cover`) against the configured server base URL
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through /// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
/// unchanged. Empty/null base or empty URL render the [fallback] /// 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 /// Mirrors the absolute-or-relative logic the audio handler uses for
/// stream URLs, so cover art and audio resolve URLs the same way. /// 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 { class ServerImage extends ConsumerWidget {
const ServerImage({ const ServerImage({
super.key, super.key,
@@ -31,17 +37,18 @@ class ServerImage extends ConsumerWidget {
final base = ref.watch(serverUrlProvider).value; final base = ref.watch(serverUrlProvider).value;
final resolved = _resolve(base, url); final resolved = _resolve(base, url);
if (resolved == null) return empty; if (resolved == null) return empty;
// Cover endpoints are gated by RequireUser server-side. Image.network // Cover endpoints are gated by RequireUser server-side. The image
// doesn't carry cookies/Bearer tokens automatically the way the // loader doesn't carry cookies/Bearer tokens automatically the way
// browser's <img> tag does, so we forward the session token as a // the browser's <img> tag does, so we forward the session token as
// header. Without this, the server returns 401 and the image fails. // a header. Without this, the server returns 401 and the image
// fails.
// //
// sessionTokenProvider is a FutureProvider — on first read after a // sessionTokenProvider is a FutureProvider — on first read after a
// hot restart its .value is null until the secure-storage read // hot restart its .value is null until the secure-storage read
// resolves. If we fired Image.network with headers:null at that // resolves. If we mounted the image with httpHeaders:null at that
// moment, the network image cache would lock in the 401 response // moment, the disk cache would lock in the 401 response and never
// and never retry. Instead, hold the fallback until the token is // retry. Hold the fallback until the token is available, then
// available, then mount the Image with the auth header attached. // mount the image with the auth header attached.
final tokenAsync = ref.watch(sessionTokenProvider); final tokenAsync = ref.watch(sessionTokenProvider);
return tokenAsync.when( return tokenAsync.when(
loading: () => empty, loading: () => empty,
@@ -49,14 +56,19 @@ class ServerImage extends ConsumerWidget {
data: (token) { data: (token) {
final headers = (token != null && token.isNotEmpty) final headers = (token != null && token.isNotEmpty)
? {'Authorization': 'Bearer $token'} ? {'Authorization': 'Bearer $token'}
: null; : <String, String>{};
return Image.network( return CachedNetworkImage(
resolved, imageUrl: resolved,
httpHeaders: headers,
fit: fit, 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 // Keep failures local — a single 401/timeout shouldn't dump
// a stack trace or replace the parent Container's background. // 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" url: "https://pub.dev"
source: hosted source: hosted
version: "8.12.6" 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: characters:
dependency: transitive dependency: transitive
description: description:
@@ -672,6 +696,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.3.0" 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: package_config:
dependency: transitive dependency: transitive
description: description:
+7
View File
@@ -30,6 +30,13 @@ dependencies:
connectivity_plus: ^6.0.5 connectivity_plus: ^6.0.5
flutter_timezone: ^4.1.1 flutter_timezone: ^4.1.1
palette_generator: ^0.3.3 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: dev_dependencies:
flutter_test: flutter_test: