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.
This commit is contained in:
@@ -76,6 +76,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
|
|
||||||
private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L
|
private const val WATCHDOG_CHECK_INTERVAL_MS = 1000L
|
||||||
private const val WATCHDOG_TIMEOUT_MS = 8000L
|
private const val WATCHDOG_TIMEOUT_MS = 8000L
|
||||||
|
|
||||||
|
// Codec capability caches — codec support doesn't change at runtime
|
||||||
|
private val hwAudioDecoderCache = HashMap<String, Boolean>()
|
||||||
|
private val tunneledPlaybackCache = HashMap<String, Boolean>()
|
||||||
}
|
}
|
||||||
|
|
||||||
private var surfaceView: SurfaceView? = null
|
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
|
// 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.
|
// has buggy 32KB input buffer limits), so report no hardware decoder for tunneling purposes.
|
||||||
if (mimeType == MimeTypes.AUDIO_FLAC) return false
|
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)
|
val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
|
||||||
|
var found = false
|
||||||
for (info in codecList.codecInfos) {
|
for (info in codecList.codecInfos) {
|
||||||
if (info.isEncoder) continue
|
if (info.isEncoder) continue
|
||||||
for (type in info.supportedTypes) {
|
for (type in info.supportedTypes) {
|
||||||
@@ -744,21 +750,28 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
!name.contains(".sw.") &&
|
!name.contains(".sw.") &&
|
||||||
!name.startsWith("c2.ffmpeg.")) {
|
!name.startsWith("c2.ffmpeg.")) {
|
||||||
Log.d(TAG, "Found hardware audio decoder for $mimeType: $name")
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to query audio decoders for $mimeType: ${e.message}")
|
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")
|
hwAudioDecoderCache[mimeType] = result
|
||||||
return false
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun videoCodecSupportsTunneledPlayback(mimeType: String): Boolean {
|
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)
|
val codecList = android.media.MediaCodecList(android.media.MediaCodecList.REGULAR_CODECS)
|
||||||
|
var supported = false
|
||||||
for (info in codecList.codecInfos) {
|
for (info in codecList.codecInfos) {
|
||||||
if (info.isEncoder) continue
|
if (info.isEncoder) continue
|
||||||
for (type in info.supportedTypes) {
|
for (type in info.supportedTypes) {
|
||||||
@@ -773,17 +786,22 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
val caps = info.getCapabilitiesForType(type)
|
val caps = info.getCapabilitiesForType(type)
|
||||||
if (caps.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)) {
|
if (caps.isFeatureSupported(android.media.MediaCodecInfo.CodecCapabilities.FEATURE_TunneledPlayback)) {
|
||||||
Log.d(TAG, "Hardware video decoder $name supports tunneled playback for $mimeType")
|
Log.d(TAG, "Hardware video decoder $name supports tunneled playback for $mimeType")
|
||||||
return true
|
supported = true
|
||||||
|
break
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Hardware video decoder $name does NOT support tunneled playback for $mimeType")
|
Log.d(TAG, "Hardware video decoder $name does NOT support tunneled playback for $mimeType")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (supported) break
|
||||||
}
|
}
|
||||||
|
supported
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Failed to query video decoders for tunneling support ($mimeType): ${e.message}")
|
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() {
|
private fun evaluateVideoCodecForTunneling() {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:isolate';
|
import 'dart:isolate';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui' show VoidCallback;
|
import 'dart:ui' show VoidCallback;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../models/livetv_channel.dart';
|
import '../models/livetv_channel.dart';
|
||||||
@@ -109,6 +112,11 @@ class ConnectionTestResult {
|
|||||||
ConnectionTestResult({required this.success, required this.latencyMs, this.error});
|
ConnectionTestResult({required this.success, required this.latencyMs, this.error});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top-level function required by compute()
|
||||||
|
String _decodeUtf8(List<int> bytes) {
|
||||||
|
return utf8.decode(bytes, allowMalformed: true);
|
||||||
|
}
|
||||||
|
|
||||||
class PlexClient {
|
class PlexClient {
|
||||||
PlexConfig config;
|
PlexConfig config;
|
||||||
late final Dio _dio;
|
late final Dio _dio;
|
||||||
@@ -136,8 +144,12 @@ class PlexClient {
|
|||||||
/// Get current offline mode state
|
/// Get current offline mode state
|
||||||
bool get isOfflineMode => _offlineMode;
|
bool get isOfflineMode => _offlineMode;
|
||||||
|
|
||||||
/// Custom response decoder that handles malformed UTF-8 gracefully
|
/// Custom response decoder that handles malformed UTF-8 gracefully.
|
||||||
static String _lenientUtf8Decoder(List<int> responseBytes, RequestOptions _, ResponseBody _) {
|
/// Large responses are decoded in a background isolate to avoid ANR.
|
||||||
|
static FutureOr<String> _lenientUtf8Decoder(List<int> responseBytes, RequestOptions _, ResponseBody __) {
|
||||||
|
if (responseBytes.length > 50 * 1024) {
|
||||||
|
return compute(_decodeUtf8, responseBytes);
|
||||||
|
}
|
||||||
return utf8.decode(responseBytes, allowMalformed: true);
|
return utf8.decode(responseBytes, allowMalformed: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user