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