From 52b8944d714a37468133976e18961fc540b1d094 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:13:09 +0200 Subject: [PATCH] feat: use HTTP/2 for all network requests --- .../libraries/tabs/library_browse_tab.dart | 2 +- lib/screens/settings/logs_screen.dart | 2 +- lib/services/discord_rpc_service.dart | 5 +- lib/services/download_manager_service.dart | 2 +- lib/services/image_cache_service.dart | 90 +++++++++++++++++-- lib/services/plex_auth_service.dart | 3 +- lib/services/plex_client.dart | 2 + lib/services/update_service.dart | 3 +- lib/utils/http_client.dart | 28 +++--- pubspec.lock | 32 +++++-- pubspec.yaml | 1 + 11 files changed, 128 insertions(+), 42 deletions(-) diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 1072abcb..18f2894d 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1310,7 +1310,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { ); try { - final response = await createHttpClient().post( + final response = await httpClient.post( 'https://ice.plezy.app/logs', data: logText, options: Options(contentType: 'text/plain'), diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index b6e25597..72855a0b 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -300,8 +300,7 @@ class DiscordRPCService { if (imageUrl.isEmpty) return null; // Fetch image data - final dio = createHttpClient(); - final imageResponse = await dio.get>( + final imageResponse = await httpClient.get>( imageUrl, options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 10)), ); @@ -316,7 +315,7 @@ class DiscordRPCService { 'fileToUpload': MultipartFile.fromBytes(Uint8List.fromList(imageBytes), filename: 'thumbnail.jpg'), }); - final uploadResponse = await dio.post( + final uploadResponse = await httpClient.post( _litterboxUrl, data: formData, options: Options(receiveTimeout: const Duration(seconds: 15)), diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 3ff84fbb..dbbd6cbd 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -122,7 +122,7 @@ class DownloadManagerService { DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, Dio? dio}) : _database = database, _storageService = storageService, - _dio = dio ?? createHttpClient(); + _dio = dio ?? httpClient; /// Initialize background_downloader with callbacks, notifications, and concurrency config. Future _initializeFileDownloader() async { diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index 631146a0..c26775dd 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -1,13 +1,15 @@ import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:http/io_client.dart'; -/// Custom cache manager for Plex image transcoding with connection limiting. +import '../utils/http_client.dart'; + +/// Custom cache manager for Plex image transcoding with HTTP/2 multiplexing. /// -/// Limits concurrent HTTP connections to 6 per host (matching browser HTTP/1.1 -/// behavior) to prevent overwhelming the Plex server's transcode pipeline when -/// many posters are visible simultaneously. +/// Uses Dio with [Http2Adapter] so all platforms 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'; @@ -19,11 +21,81 @@ class PlexImageCacheManager extends CacheManager with ImageCacheManager { _key, stalePeriod: const Duration(days: 14), maxNrOfCacheObjects: 3000, - fileService: HttpFileService( - httpClient: IOClient( - HttpClient()..maxConnectionsPerHost = 6, - ), + fileService: _DioFileService( + Dio()..httpClientAdapter = createHttp2Adapter(), ), ), ); } + +class _DioFileService extends FileService { + final Dio _dio; + + _DioFileService(this._dio); + + @override + Future get( + String url, { + Map? headers, + }) async { + final response = await _dio.get( + url, + options: Options( + headers: headers, + responseType: ResponseType.stream, + ), + ); + return _DioGetResponse(response); + } +} + +class _DioGetResponse implements FileServiceResponse { + final Response _response; + final DateTime _receivedTime = DateTime.now(); + + _DioGetResponse(this._response); + + @override + Stream> get content => _response.data!.stream; + + @override + int? get contentLength { + final value = _header(HttpHeaders.contentLengthHeader); + return value != null ? int.tryParse(value) : null; + } + + @override + int get statusCode => _response.statusCode ?? 200; + + @override + DateTime get validTill { + var ageDuration = const Duration(days: 7); + final controlHeader = _header(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 => _header(HttpHeaders.etagHeader); + + @override + String get fileExtension { + final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); + if (contentTypeHeader != null) { + final ct = ContentType.parse(contentTypeHeader); + return '.${ct.subType}'; + } + return ''; + } + + String? _header(String name) => _response.headers.value(name); +} diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 1324175e..09c07ed8 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -8,6 +8,7 @@ import '../models/plex_home.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/connection_constants.dart'; +import '../utils/http_client.dart'; /// Redacts the middle of an IP address or hostname for safe logging. /// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`. @@ -53,7 +54,7 @@ class PlexAuthService { final storage = await StorageService.getInstance(); final dio = Dio( BaseOptions(connectTimeout: ConnectionTimeouts.plexTvConnect, receiveTimeout: ConnectionTimeouts.plexTvReceive), - ); + )..httpClientAdapter = createHttp2Adapter(); // Get or create client identifier String? clientIdentifier = storage.getClientIdentifier(); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 16d0ee33..37eaa81c 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:dio/dio.dart'; +import '../utils/http_client.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; @@ -198,6 +199,7 @@ class PlexClient { responseDecoder: _lenientUtf8Decoder, ), ); + _dio.httpClientAdapter = createHttp2Adapter(); _dio.transformer = BackgroundTransformer(); // Add interceptor for logging (optional, can be disabled in production) diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index c017abc9..fdbd3900 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -155,8 +155,7 @@ class UpdateService { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; - final dio = createHttpClient(); - final response = await dio.get( + final response = await httpClient.get( 'https://api.github.com/repos/$_githubRepo/releases/latest', options: Options(headers: {'Accept': 'application/vnd.github+json'}), ); diff --git a/lib/utils/http_client.dart b/lib/utils/http_client.dart index b68b0905..8a8fe8f1 100644 --- a/lib/utils/http_client.dart +++ b/lib/utils/http_client.dart @@ -1,18 +1,14 @@ import 'package:dio/dio.dart'; +import 'package:dio_http2_adapter/dio_http2_adapter.dart'; -/// Creates a plain [Dio] instance with sensible default timeouts for ad-hoc -/// HTTP requests that don't go through [PlexClient]. -/// -/// Use this instead of bare `Dio()` so every call site gets consistent timeout -/// behaviour without duplicating configuration. -Dio createHttpClient({ - Duration connectTimeout = const Duration(seconds: 10), - Duration receiveTimeout = const Duration(seconds: 30), -}) { - return Dio( - BaseOptions( - connectTimeout: connectTimeout, - receiveTimeout: receiveTimeout, - ), - ); -} +/// Shared HTTP/2 connection pool. All Dio instances that go through +/// [createHttp2Adapter] reuse the same pool, so connections to the same +/// host are multiplexed instead of duplicated. +final _connectionManager = ConnectionManager(idleTimeout: const Duration(seconds: 15)); + +/// Returns an [Http2Adapter] backed by the shared connection pool. +HttpClientAdapter createHttp2Adapter() => Http2Adapter(_connectionManager); + +/// Shared [Dio] instance for ad-hoc HTTP requests that don't go through +/// [PlexClient]. Reuses the global HTTP/2 connection pool. +final httpClient = Dio()..httpClientAdapter = createHttp2Adapter(); diff --git a/pubspec.lock b/pubspec.lock index 13cc1c21..1410e828 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,10 +177,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" charcode: dependency: transitive description: @@ -349,6 +349,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.9.2" + dio_http2_adapter: + dependency: "direct main" + description: + name: dio_http2_adapter + sha256: "79f3d69b155b92a786c8734bd11860390b986210d4e07cbb6a5c8c806a7187b2" + url: "https://pub.dev" + source: hosted + version: "2.7.0" dio_web_adapter: dependency: transitive description: @@ -529,6 +537,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http2: + dependency: transitive + description: + name: http2 + sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" + url: "https://pub.dev" + source: hosted + version: "2.3.1" http_multi_server: dependency: transitive description: @@ -653,18 +669,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" material_symbols_icons: dependency: "direct main" description: @@ -1270,10 +1286,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cab88f0c..3c7d6c62 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: url: https://github.com/edde746/sentry-dart path: packages/flutter ref: build/fetch-native-zip + dio_http2_adapter: ^2.7.0 auto_updater: git: url: https://github.com/edde746/auto_updater