fix(images): reduce artwork decode pressure
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user