From 18abcef8a2699a98ad684b6f231ae7b8fe355e92 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:42:26 +0100 Subject: [PATCH] fix: offload large UTF-8 decoding to background isolate Prevents ANR when Dio's responseDecoder synchronously decodes large API responses on the main thread. Responses >50KB use compute(). Also caches MediaCodecList query results in ExoPlayerCore companion object. --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 32 +++++++++++++++---- lib/services/plex_client.dart | 16 ++++++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index cc7155ac..edd3ebc3 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -76,6 +76,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L private const val WATCHDOG_TIMEOUT_MS = 8000L + + // Codec capability caches — codec support doesn't change at runtime + private val hwAudioDecoderCache = HashMap() + private val tunneledPlaybackCache = HashMap() } private var surfaceView: SurfaceView? = null @@ -732,8 +736,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // FLAC hardware decoders are excluded via MediaCodecSelector (Samsung c2.sec.flac.decoder // has buggy 32KB input buffer limits), so report no hardware decoder for tunneling purposes. if (mimeType == MimeTypes.AUDIO_FLAC) return false - try { + hwAudioDecoderCache[mimeType]?.let { return it } + val result = try { val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS) + var found = false for (info in codecList.codecInfos) { if (info.isEncoder) continue for (type in info.supportedTypes) { @@ -744,21 +750,28 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { !name.contains(".sw.") && !name.startsWith("c2.ffmpeg.")) { Log.d(TAG, "Found hardware audio decoder for $mimeType: $name") - return true + found = true + break } } } + if (found) break } + if (!found) Log.d(TAG, "No hardware audio decoder for $mimeType — FFmpeg will handle it") + found } catch (e: Exception) { Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}") + false } - Log.d(TAG, "No hardware audio decoder for $mimeType — FFmpeg will handle it") - return false + hwAudioDecoderCache[mimeType] = result + return result } private fun videoCodecSupportsTunneledPlayback(mimeType: String): Boolean { - try { + tunneledPlaybackCache[mimeType]?.let { return it } + val result = try { val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS) + var supported = false for (info in codecList.codecInfos) { if (info.isEncoder) continue for (type in info.supportedTypes) { @@ -773,17 +786,22 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val caps = info.getCapabilitiesForType(type) if (caps.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)) { Log.d(TAG, "Hardware video decoder $name supports tunneled playback for $mimeType") - return true + supported = true + break } else { Log.d(TAG, "Hardware video decoder $name does NOT support tunneled playback for $mimeType") } } } + if (supported) break } + supported } catch (e: Exception) { Log.w(TAG, "Failed to query video decoders for tunneling support ($mimeType): ${e.message}") + false } - return false + tunneledPlaybackCache[mimeType] = result + return result } private fun evaluateVideoCodecForTunneling() { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index fcb87ca8..859110e7 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; import 'dart:typed_data'; import 'dart:ui' show VoidCallback; +import 'package:flutter/foundation.dart'; + import 'package:dio/dio.dart'; import '../models/livetv_channel.dart'; @@ -109,6 +112,11 @@ class ConnectionTestResult { ConnectionTestResult({required this.success, required this.latencyMs, this.error}); } +// Top-level function required by compute() +String _decodeUtf8(List bytes) { + return utf8.decode(bytes, allowMalformed: true); +} + class PlexClient { PlexConfig config; late final Dio _dio; @@ -136,8 +144,12 @@ class PlexClient { /// Get current offline mode state bool get isOfflineMode => _offlineMode; - /// Custom response decoder that handles malformed UTF-8 gracefully - static String _lenientUtf8Decoder(List responseBytes, RequestOptions _, ResponseBody _) { + /// Custom response decoder that handles malformed UTF-8 gracefully. + /// Large responses are decoded in a background isolate to avoid ANR. + static FutureOr _lenientUtf8Decoder(List responseBytes, RequestOptions _, ResponseBody __) { + if (responseBytes.length > 50 * 1024) { + return compute(_decodeUtf8, responseBytes); + } return utf8.decode(responseBytes, allowMalformed: true); }