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:
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user