refactor(images): replace flutter cache manager

This commit is contained in:
edde746
2026-05-03 20:09:23 +02:00
parent c7b023222c
commit c4b9da0950
14 changed files with 75 additions and 187 deletions
+1 -1
View File
@@ -332,7 +332,7 @@ abstract class MediaServerClient {
// ── Images ───────────────────────────────────────────────────────
/// Resolve a backend-relative thumbnail path to a fully-qualified URL ready
/// for `cached_network_image`. Returns an empty string for null/empty
/// for cached image providers. Returns an empty string for null/empty
/// inputs.
///
/// When [width]/[height] are provided, the implementation should request
+4 -2
View File
@@ -1,7 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../services/image_cache_service.dart';
import '../utils/initials_palette.dart';
import '../widgets/app_icon.dart';
import 'profile.dart';
@@ -67,9 +68,10 @@ class ProfileAvatar extends StatelessWidget {
if (thumb != null && thumb.isNotEmpty) {
return CachedNetworkImage(
imageUrl: thumb,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.cover,
placeholder: (_, _) => _initialFallback(theme, p),
errorWidget: (_, _, _) => _initialFallback(theme, p),
errorBuilder: (_, _, _) => _initialFallback(theme, p),
);
}
return _initialFallback(theme, p);
+3 -3
View File
@@ -10,7 +10,7 @@ import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../utils/global_key_utils.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import '../services/image_cache_service.dart';
import '../media/media_item.dart';
@@ -1498,7 +1498,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
memCacheHeight: memHeight,
placeholder: (context, url) =>
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
errorWidget: (context, url, error) =>
errorBuilder: (context, error, stackTrace) =>
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
),
);
@@ -1572,7 +1572,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
placeholder: (context, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) {
errorBuilder: (context, error, stackTrace) {
// Fallback to text if logo fails to load
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -3,7 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import '../../../media/library_first_character.dart';
import '../../../media/library_query.dart';
import '../../../media/media_item.dart';
@@ -1,4 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -168,7 +168,7 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent
height: 120,
fit: BoxFit.cover,
memCacheHeight: (120 * MediaQuery.devicePixelRatioOf(context)).round(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
),
+5 -4
View File
@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../services/image_cache_service.dart';
@@ -822,7 +822,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
fit: BoxFit.cover,
memCacheHeight: memCacheHeight,
placeholder: (context, url) => const PlaceholderContainer(),
errorWidget: (context, url, error) => _buildHeroNetworkArtwork(
errorBuilder: (context, error, stackTrace) => _buildHeroNetworkArtwork(
context,
client: client,
artworkPaths: artworkPaths,
@@ -1571,7 +1571,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
fit: BoxFit.cover,
memCacheWidth: memWidth,
placeholder: (context, url) => const PlaceholderContainer(),
errorWidget: (context, url, error) => const PlaceholderContainer(),
errorBuilder: (context, error, stackTrace) => const PlaceholderContainer(),
),
);
}
@@ -2528,7 +2528,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
alignment: Alignment.centerLeft,
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
placeholder: (context, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) => _buildTitleText(context, metadata.displayTitle),
errorBuilder: (context, error, stackTrace) =>
_buildTitleText(context, metadata.displayTitle),
),
sigma: 10,
clip: false,
+19 -80
View File
@@ -1,102 +1,41 @@
import 'dart:io';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
// CE's public conditional export hides the IO-only httpClientFactory parameter
// behind a narrower unsupported-platform stub.
// ignore: implementation_imports
import 'package:cached_network_image_ce/src/cache/default_cache_manager.dart' as ce_cache;
import 'package:http/http.dart' as http;
import '../utils/platform_detector.dart';
import '../utils/media_server_http_client.dart';
/// Custom cache manager for media-server image transcoding with HTTP/2
/// multiplexing. Used for both Plex and Jellyfin artwork (the class name
/// predates Jellyfin support — it's backend-neutral).
/// Shared cache manager for media-server image artwork. Used for both Plex and
/// Jellyfin artwork (the class name predates Jellyfin support — it's
/// backend-neutral).
///
/// Uses the platform-native HTTP client so iOS/macOS (CupertinoClient) and
/// Android (CronetClient) benefit from HTTP/2 connection multiplexing —
/// many concurrent image downloads over a single connection instead of
/// being limited to a handful of HTTP/1.1 connections.
class PlexImageCacheManager extends CacheManager with ImageCacheManager {
static const _key = 'plexImageCache';
class PlexImageCacheManager extends ce_cache.DefaultCacheManager {
static final PlexImageCacheManager instance = PlexImageCacheManager._();
PlexImageCacheManager._() : super(_buildConfig());
static Config _buildConfig() {
final fileService = _HttpFileService(httpClient.inner);
// tvOS has no sqflite plugin, so force the JSON cache-info repo there.
// On other platforms we let flutter_cache_manager pick its default repo.
if (PlatformDetector.isAppleTV()) {
return Config(
_key,
PlexImageCacheManager._()
: super(
stalePeriod: const Duration(days: 14),
maxNrOfCacheObjects: 3000,
fileService: fileService,
repo: JsonCacheInfoRepository(databaseName: _key),
httpClientFactory: () => _SharedHttpClient(httpClient.inner),
);
}
return Config(_key, stalePeriod: const Duration(days: 14), maxNrOfCacheObjects: 3000, fileService: fileService);
}
}
class _HttpFileService extends FileService {
final http.Client _client;
/// CE closes each factory-created client after a download. Wrap the app-wide
/// shared client so image requests reuse its platform transport without
/// transferring ownership of its lifecycle.
class _SharedHttpClient extends http.BaseClient {
final http.Client _inner;
_HttpFileService(this._client);
_SharedHttpClient(this._inner);
@override
Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) async {
final request = http.Request('GET', Uri.parse(url));
if (headers != null) request.headers.addAll(headers);
final response = await _client.send(request);
return _HttpGetResponse(response);
}
}
Future<http.StreamedResponse> send(http.BaseRequest request) => _inner.send(request);
class _HttpGetResponse implements FileServiceResponse {
final http.StreamedResponse _response;
final DateTime _receivedTime = DateTime.now();
_HttpGetResponse(this._response);
@override
Stream<List<int>> get content => _response.stream;
@override
int? get contentLength {
final value = _response.headers[HttpHeaders.contentLengthHeader];
return value != null ? int.tryParse(value) : null;
}
@override
int get statusCode => _response.statusCode;
@override
DateTime get validTill {
var ageDuration = const Duration(days: 7);
final controlHeader = _response.headers[HttpHeaders.cacheControlHeader];
if (controlHeader != null) {
for (final setting in controlHeader.split(',')) {
final s = setting.trim().toLowerCase();
if (s == 'no-cache') ageDuration = Duration.zero;
if (s.startsWith('max-age=')) {
final secs = int.tryParse(s.split('=')[1]) ?? 0;
if (secs > 0) ageDuration = Duration(seconds: secs);
}
}
}
return _receivedTime.add(ageDuration);
}
@override
String? get eTag => _response.headers[HttpHeaders.etagHeader];
@override
String get fileExtension {
final contentTypeHeader = _response.headers[HttpHeaders.contentTypeHeader];
if (contentTypeHeader != null) {
final ct = ContentType.parse(contentTypeHeader);
return '.${ct.subType}';
}
return '';
}
void close() {}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/painting.dart' show ImageConfiguration, ImageProvider, ImageStreamListener;
import '../media/media_source_info.dart';
@@ -36,7 +36,7 @@ class JellyfinTrickplayService implements ScrubPreviewSource {
/// Provider cache by sheet index: scrubbing typically dwells on one sheet
/// for many hover events, so we reuse the same wrapper rather than
/// reallocating each frame. cached_network_image's own cache is keyed by
/// reallocating each frame. The image cache itself is keyed by
/// URL, but the wrapper object itself isn't free.
final Map<int, ImageProvider> _providerCache = {};
+8 -4
View File
@@ -1,11 +1,15 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../services/image_cache_service.dart';
import 'package:plezy/widgets/app_icon.dart';
import '../media/media_server_client.dart';
import '../services/image_cache_service.dart';
import '../utils/app_logger.dart';
import '../utils/media_image_helper.dart';
import '../utils/obfuscation_utils.dart';
@@ -356,6 +360,6 @@ class OptimizedMediaImage extends StatelessWidget {
// so the URL hash alone uniquely identifies the bytes on disk. Including
// mem-cache dimensions here would re-introduce churn on every pixel of
// window resize and defeat getMemCacheDimensions' bucketing.
return 'plex_optimized_${imageUrl.hashCode}';
return 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}';
}
}
@@ -15,7 +15,6 @@ import package_info_plus
import screen_retriever_macos
import sentry_flutter
import shared_preferences_foundation
import sqflite_darwin
import sqlite3_flutter_libs
import universal_gamepad
import url_launcher_macos
@@ -33,7 +32,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SentryFlutterPlugin.register(with: registry.registrar(forPlugin: "SentryFlutterPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
GamepadPlugin.register(with: registry.registrar(forPlugin: "GamepadPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
-7
View File
@@ -29,9 +29,6 @@ PODS:
- Flutter
- FlutterMacOS
- Sparkle (2.9.0)
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- sqlite3 (3.52.0):
- sqlite3/common (= 3.52.0)
- sqlite3/common (3.52.0)
@@ -79,7 +76,6 @@ DEPENDENCIES:
- screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`)
- sentry_flutter (from `Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- universal_gamepad (from `Flutter/ephemeral/.symlinks/plugins/universal_gamepad/macos`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
@@ -117,8 +113,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/sentry_flutter/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
sqlite3_flutter_libs:
:path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin
universal_gamepad:
@@ -145,7 +139,6 @@ SPEC CHECKSUMS:
sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
Sparkle: f4355f9ebbe9b7d932df4980d70f13922ac97b2a
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab
universal_gamepad: 8922f1f238f62d6847de887228976d5b572b57da
+28 -76
View File
@@ -150,30 +150,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.12.4"
cached_network_image:
cached_network_image_ce:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
name: cached_network_image_ce
sha256: d4701fbbdf508de831e847fd1d49c644ffa6191c6018c416ab6f66bbe1ad5fff
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
version: "4.6.4"
cached_network_image_platform_interface_ce:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
name: cached_network_image_platform_interface_ce
sha256: "95343645becaeac01dd6eeb9f3a85448b6076d211010ff1973e8bbfd840b571d"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
version: "5.2.0"
cached_network_image_web_ce:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
name: cached_network_image_web_ce
sha256: "6bcd8608d4ace7c35625f09d971ce68c7a6581a4631b0fb6ceb2909b2c2e17a8"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "2.1.1"
characters:
dependency: transitive
description:
@@ -443,14 +443,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_cache_manager:
dependency: "direct main"
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_lints:
dependency: "direct dev"
description:
@@ -506,6 +498,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hive_ce:
dependency: transitive
description:
name: hive_ce
sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418"
url: "https://pub.dev"
source: hosted
version: "2.19.3"
hooks:
dependency: transitive
description:
@@ -586,6 +586,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
isolate_channel:
dependency: transitive
description:
name: isolate_channel
sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094
url: "https://pub.dev"
source: hosted
version: "0.6.1"
jni:
dependency: transitive
description:
@@ -940,14 +948,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
saf_stream:
dependency: "direct main"
description:
@@ -1155,46 +1155,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev"
source: hosted
version: "2.4.2+3"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
@@ -1251,14 +1211,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
+1 -2
View File
@@ -13,8 +13,7 @@ dependencies:
intl: ^0.20.2
json_annotation: ^4.9.0
shared_preferences: ^2.5.4
cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.1
cached_network_image_ce: 4.6.4
http: ^1.2.0
url_launcher: ^6.3.0
uuid: ^4.4.0
@@ -40,7 +40,7 @@ TrickplayInfo _info({
);
/// Stub that returns a constant 1×1 transparent image for any URL — keeps
/// tests off path_provider / cached_network_image's disk cache.
/// tests off path_provider / the image disk cache.
ImageProvider _fakeSheet(String _) => MemoryImage(Uint8List.fromList(const [0]));
void main() {