diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index 3b8803cf..7ea1c246 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -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 closeArtworkHttpClientGracefully({Duration drainTimeout = const Duration(seconds: 5)}) { return _artworkHttpClient.closeGracefully(drainTimeout: drainTimeout); @@ -20,10 +23,9 @@ Future 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 getImageFile( + String url, { + String? key, + Map? 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 send(http.BaseRequest request) => _inner.send(request); + Future 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> _releaseWhenDone(Stream> stream, void Function() release) async* { + try { + await for (final chunk in stream) { + yield chunk; + } + } finally { + release(); + } +} + +class _RequestLimiter { + final int maxConcurrent; + final Queue> _queue = Queue>(); + 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(); + } +} diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 9bfdf59d..8cea67d2 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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, diff --git a/test/widgets/optimized_media_image_test.dart b/test/widgets/optimized_media_image_test.dart index fb6123b5..421f860c 100644 --- a/test/widgets/optimized_media_image_test.dart +++ b/test/widgets/optimized_media_image_test.dart @@ -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(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(