fix(images): reduce artwork decode pressure

This commit is contained in:
edde746
2026-06-18 18:00:23 +02:00
parent 7e1d5ad4c4
commit 0d78020fbf
3 changed files with 132 additions and 15 deletions
+98 -6
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:collection';
import 'package:cached_network_image_ce/cached_network_image.dart' show FileResponse;
// CE's public conditional export hides the IO-only httpClientFactory parameter
// behind a narrower unsupported-platform stub.
// ignore: implementation_imports
@@ -10,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import '../utils/media_server_http_client.dart';
final _artworkHttpClient = MediaServerHttpClient(usePlexApiClient: true);
final _artworkRequestLimiter = _RequestLimiter(6);
Future<void> closeArtworkHttpClientGracefully({Duration drainTimeout = const Duration(seconds: 5)}) {
return _artworkHttpClient.closeGracefully(drainTimeout: drainTimeout);
@@ -20,10 +23,9 @@ Future<void> closeArtworkHttpClientGracefully({Duration drainTimeout = const Dur
/// 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. On Linux this uses the
/// same finite-connection tuning as Plex API traffic.
/// Android (CronetClient) benefit from HTTP/2, while the wrapper below keeps
/// image fan-out bounded so weak TV devices don't decode a whole rail at once.
/// On Linux this uses the same finite-connection tuning as Plex API traffic.
class PlexImageCacheManager extends ce_cache.DefaultCacheManager {
static final PlexImageCacheManager instance = PlexImageCacheManager._();
@@ -34,19 +36,109 @@ class PlexImageCacheManager extends ce_cache.DefaultCacheManager {
httpClientFactory: () => _SharedHttpClient(_artworkHttpClient.inner),
cacheDirectoryProvider: getApplicationCacheDirectory,
);
@override
Stream<FileResponse> getImageFile(
String url, {
String? key,
Map<String, String>? headers,
bool withProgress = false,
int? maxHeight,
int? maxWidth,
}) {
// Plezy already requests server-sized artwork URLs. Avoid CE's disk-resize
// path, which decodes downloaded images before writing resized PNG copies.
return getFileStream(url, key: key, headers: headers, withProgress: withProgress);
}
}
/// 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.
/// transferring ownership of its lifecycle, and cap artwork fan-out globally.
class _SharedHttpClient extends http.BaseClient {
final http.Client _inner;
_SharedHttpClient(this._inner);
@override
Future<http.StreamedResponse> send(http.BaseRequest request) => _inner.send(request);
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final permit = await _artworkRequestLimiter.acquire();
var released = false;
void release() {
if (released) return;
released = true;
permit.release();
}
try {
final response = await _inner.send(request);
return http.StreamedResponse(
_releaseWhenDone(response.stream, release),
response.statusCode,
contentLength: response.contentLength,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
} catch (_) {
release();
rethrow;
}
}
@override
void close() {}
}
Stream<List<int>> _releaseWhenDone(Stream<List<int>> stream, void Function() release) async* {
try {
await for (final chunk in stream) {
yield chunk;
}
} finally {
release();
}
}
class _RequestLimiter {
final int maxConcurrent;
final Queue<Completer<_RequestPermit>> _queue = Queue<Completer<_RequestPermit>>();
int _active = 0;
_RequestLimiter(this.maxConcurrent);
Future<_RequestPermit> acquire() {
if (_active < maxConcurrent) {
_active++;
return Future.value(_RequestPermit(this));
}
final completer = Completer<_RequestPermit>();
_queue.add(completer);
return completer.future;
}
void _release() {
if (_queue.isNotEmpty) {
_queue.removeFirst().complete(_RequestPermit(this));
return;
}
if (_active > 0) _active--;
}
}
class _RequestPermit {
final _RequestLimiter _limiter;
bool _released = false;
_RequestPermit(this._limiter);
void release() {
if (_released) return;
_released = true;
_limiter._release();
}
}
+9 -9
View File
@@ -246,8 +246,7 @@ class OptimizedMediaImage extends StatelessWidget {
width: width,
height: height,
// Only cacheHeight: leaving cacheWidth null preserves decode aspect
// ratio, mirroring the network branch which only passes maxHeight to
// CachedNetworkImageProvider.
// ratio, mirroring the network branch's ResizeImage wrapper.
cacheHeight: memHeight > 0 ? memHeight : null,
fit: fit,
filterQuality: filterQuality,
@@ -301,14 +300,15 @@ class OptimizedMediaImage extends StatelessWidget {
final effectiveCacheKey = cacheKey ?? _generateCacheKey(imageUrl);
final provider = CachedNetworkImageProvider(
imageUrl,
cacheKey: effectiveCacheKey,
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
);
return Image(
image: CachedNetworkImageProvider(
imageUrl,
cacheKey: effectiveCacheKey,
cacheManager: PlexImageCacheManager.instance,
headers: const {'User-Agent': 'Plezy'},
maxHeight: memHeight,
),
image: ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider),
width: width,
height: height,
fit: fit,
@@ -1,3 +1,4 @@
import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -5,6 +6,30 @@ import 'package:plezy/utils/media_image_helper.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
void main() {
testWidgets('network images use decode resize without disk cache resize', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: SizedBox(
width: 160,
height: 90,
child: OptimizedMediaImage.thumb(
imagePath: 'https://example.invalid/episode-thumb.jpg',
width: 160,
height: 90,
),
),
),
);
final image = tester.widget<Image>(find.byType(Image));
final resizeProvider = image.image as ResizeImage;
final cachedProvider = resizeProvider.imageProvider as CachedNetworkImageProvider;
expect(resizeProvider.height, isNotNull);
expect(cachedProvider.maxHeight, isNull);
expect(cachedProvider.maxWidth, isNull);
});
testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async {
await tester.pumpWidget(
MaterialApp(