From 3a2f2c9c694668d410ca11ee4936046dcca266e2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:11:46 +0100 Subject: [PATCH] fix: performance overlay for exoplayer --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 106 ++++++-- .../plezy/exoplayer/ExoPlayerPlugin.kt | 15 ++ lib/mpv/player/player.dart | 3 + lib/mpv/player/player_android.dart | 26 ++ lib/mpv/player/player_native.dart | 3 + .../performance_overlay.dart | 42 ++-- .../performance_stats.dart | 55 ++++- .../performance_stats_service.dart | 231 +++++++++++------- 8 files changed, 352 insertions(+), 129 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 4632f813..5be6d5cd 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 @@ -228,24 +228,26 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { surfaceContainer!!.addView(surfaceView) - // Create SubtitleView - will be added to surfaceContainer above video - // With OVERLAY_CANVAS mode, libass-android adds AssSubtitleView as a child - // which renders ASS subtitles with full styling + // Create SubtitleView - added to surfaceContainer above video + // With OVERLAY_OPEN_GL mode, libass-android adds AssSubtitleTextureView as a child + // which renders ASS subtitles with full styling using GPU texture composition subtitleView = SubtitleView(activity).apply { layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) } - Log.d(TAG, "SubtitleView created") + // Add SubtitleView to surfaceContainer (above video SurfaceView) + // Flutter renders on top of entire surfaceContainer, keeping subtitles below UI + surfaceContainer!!.addView(subtitleView) + Log.d(TAG, "SubtitleView created and added to surfaceContainer") val contentView = activity.findViewById(android.R.id.content) contentView.addView(surfaceContainer, 0) // Find FlutterView and configure z-order // Video SurfaceView is at the bottom (setZOrderOnTop=false, setZOrderMediaOverlay=false) - // Flutter SurfaceView uses setZOrderMediaOverlay to render above video - // SubtitleView will be added to surfaceContainer so Flutter stays on top + // Flutter SurfaceView uses setZOrderMediaOverlay to render above video and subtitles for (i in 0 until contentView.childCount) { val child = contentView.getChildAt(i) if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) { @@ -265,22 +267,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - // Add SubtitleView just below FlutterView so it stays above video but below Flutter UI - var flutterViewIndex: Int? = null - for (i in 0 until contentView.childCount) { - val child = contentView.getChildAt(i) - if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) { - flutterViewIndex = i - break - } - } - if (flutterViewIndex != null) { - contentView.addView(subtitleView, flutterViewIndex) - Log.d(TAG, "SubtitleView added below FlutterView at index $flutterViewIndex") - } else { - contentView.addView(subtitleView) - Log.d(TAG, "SubtitleView added to contentView (FlutterView not found)") - } ensureFlutterOverlayOnTop() overlayLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { ensureFlutterOverlayOnTop() @@ -317,15 +303,16 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val dataSourceFactory = DefaultDataSource.Factory(activity) val extractorsFactory = DefaultExtractorsFactory() - // Use buildWithAssSupport with OVERLAY_CANVAS mode for proper libass rendering - // This renders ASS subtitles with full styling via AssSubtitleView + // Use buildWithAssSupport with OVERLAY_OPEN_GL mode for proper libass rendering + // OVERLAY_OPEN_GL uses TextureView which follows normal View hierarchy z-ordering, + // preventing hardware overlay promotion issues on devices like Nvidia Shield Log.d(TAG, "SubtitleView childCount before buildWithAssSupport: ${subtitleView?.childCount}") exoPlayer = ExoPlayer.Builder(activity) .setTrackSelector(trackSelector!!) .setAudioAttributes(audioAttributes, false) // We handle audio focus manually .buildWithAssSupport( context = activity, - renderType = AssRenderType.OVERLAY_CANVAS, // Use OVERLAY mode for libass styling + renderType = AssRenderType.OVERLAY_OPEN_GL, // Use OVERLAY_OPEN_GL to fix z-ordering on Nvidia Shield subtitleView = subtitleView, dataSourceFactory = dataSourceFactory, extractorsFactory = extractorsFactory, @@ -1049,6 +1036,75 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } + // Stats + + fun getStats(): Map { + val player = exoPlayer ?: return emptyMap() + val videoFormat = player.videoFormat + val audioFormat = player.audioFormat + + // Get decoder info from the format's codecs field and check if hardware accelerated + val videoDecoderInfo = getVideoDecoderInfo(videoFormat) + + return mapOf( + // Video metrics + "videoCodec" to videoFormat?.codecs, + "videoMimeType" to videoFormat?.sampleMimeType, + "videoWidth" to videoFormat?.width, + "videoHeight" to videoFormat?.height, + "videoFps" to videoFormat?.frameRate, + "videoBitrate" to videoFormat?.bitrate, + "videoDecoderName" to videoDecoderInfo, + "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount, + "videoRenderedFrames" to player.videoDecoderCounters?.renderedOutputBufferCount, + // Color info + "colorSpace" to videoFormat?.colorInfo?.colorSpace, + "colorRange" to videoFormat?.colorInfo?.colorRange, + "colorTransfer" to videoFormat?.colorInfo?.colorTransfer, + "hdrStaticInfo" to (videoFormat?.colorInfo?.hdrStaticInfo != null), + // Audio metrics + "audioCodec" to audioFormat?.codecs, + "audioMimeType" to audioFormat?.sampleMimeType, + "audioSampleRate" to audioFormat?.sampleRate, + "audioChannels" to audioFormat?.channelCount, + "audioBitrate" to audioFormat?.bitrate, + // Buffer metrics + "bufferedPositionMs" to player.bufferedPosition, + "currentPositionMs" to player.currentPosition, + "totalBufferedDurationMs" to player.totalBufferedDuration, + // Playback state + "playbackSpeed" to player.playbackParameters.speed, + "isPlaying" to player.isPlaying, + "playbackState" to player.playbackState, + ) + } + + private fun getVideoDecoderInfo(videoFormat: androidx.media3.common.Format?): String? { + if (videoFormat == null) return null + val mimeType = videoFormat.sampleMimeType ?: return null + + // Check available decoders for this mime type + try { + val codecList = android.media.MediaCodecList(android.media.MediaCodecList.ALL_CODECS) + for (info in codecList.codecInfos) { + if (info.isEncoder) continue + for (type in info.supportedTypes) { + if (type.equals(mimeType, ignoreCase = true)) { + // Return the first hardware decoder found, or software if none + val name = info.name + if (!name.startsWith("OMX.google.") && !name.contains(".sw.")) { + return name // Hardware decoder + } + } + } + } + // Fallback - assume software if no HW decoder found + return "Software" + } catch (e: Exception) { + return null + } + } + // Cleanup fun dispose() { diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 4b6b03b5..a7daae54 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -115,6 +115,8 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, if (usingMpvFallback) mpvCore?.isInitialized ?: false else playerCore?.isInitialized ?: false ) + "getStats" -> handleGetStats(result) + "getPlayerType" -> result.success(if (usingMpvFallback) "mpv" else "exoplayer") else -> result.notImplemented() } } @@ -406,6 +408,19 @@ class ExoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, result.success(null) } + private fun handleGetStats(result: MethodChannel.Result) { + activity?.runOnUiThread { + val stats = if (usingMpvFallback) { + // For MPV fallback, return empty - stats are fetched via getProperty + mapOf("playerType" to "mpv") + } else { + val coreStats = playerCore?.getStats() ?: emptyMap() + coreStats + mapOf("playerType" to "exoplayer") + } + result.success(stats) + } ?: result.success(mapOf("playerType" to "unknown")) + } + // ExoPlayerDelegate override fun onPropertyChange(name: String, value: Any?) { diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index bce87747..235f23da 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -48,6 +48,9 @@ abstract class Player { /// rendering is initialized. Returns null if not ready. int? get textureId; + /// The type of player backend being used (e.g., 'mpv', 'exoplayer'). + String get playerType; + // ============================================ // Playback Control // ============================================ diff --git a/lib/mpv/player/player_android.dart b/lib/mpv/player/player_android.dart index 099d41d8..c8ee6e3f 100644 --- a/lib/mpv/player/player_android.dart +++ b/lib/mpv/player/player_android.dart @@ -28,6 +28,9 @@ class PlayerAndroid implements Player { @override int? get textureId => null; // Uses SurfaceView, not Flutter texture + @override + String get playerType => 'exoplayer'; + // Stream controllers final _playingController = StreamController.broadcast(); final _completedController = StreamController.broadcast(); @@ -495,6 +498,29 @@ class PlayerAndroid implements Player { } } + /// Get all playback stats from ExoPlayer. + /// Returns a map with video/audio codec info, buffer state, and performance metrics. + Future> getStats() async { + _checkDisposed(); + try { + final result = await _methodChannel.invokeMethod('getStats'); + return Map.from(result ?? {}); + } catch (e) { + return {}; + } + } + + /// Get the current player type ('exoplayer' or 'mpv' if fallback is active). + Future getPlayerType() async { + _checkDisposed(); + try { + final result = await _methodChannel.invokeMethod('getPlayerType'); + return result ?? 'unknown'; + } catch (e) { + return 'unknown'; + } + } + @override Future command(List args) async { _checkDisposed(); diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index 7ae7109f..e4993499 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -30,6 +30,9 @@ class PlayerNative implements Player { @override int? get textureId => null; // Uses direct Metal layer, not Flutter texture + @override + String get playerType => 'mpv'; + // Stream controllers final _playingController = StreamController.broadcast(); final _completedController = StreamController.broadcast(); diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart index 115fde74..8970956f 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -45,6 +45,8 @@ class _PlayerPerformanceOverlayState extends State { @override Widget build(BuildContext context) { + final isMpv = _stats.playerType == 'mpv'; + return Container( constraints: const BoxConstraints(maxWidth: 380), padding: const EdgeInsets.all(12), @@ -66,21 +68,24 @@ class _PlayerPerformanceOverlayState extends State { _buildSection(Symbols.videocam_rounded, 'Video', [ _metric('Codec', _stats.videoCodec ?? 'N/A'), _metric('Resolution', _stats.resolution), - _metric('FPS', _stats.videoFpsFormatted), - _metric('Bitrate', _stats.videoBitrateFormatted), + if (_stats.hasValidVideoFps) _metric('FPS', _stats.videoFpsFormatted), + if (_stats.hasValidVideoBitrate) _metric('Bitrate', _stats.videoBitrateFormatted), _metric('Decoder', _stats.hwdecFormatted), if (_stats.aspectName != null && _stats.aspectName!.isNotEmpty) _metric('Aspect', _stats.aspectName!), if (_stats.rotate != null && _stats.rotate != 0) _metric('Rotation', _stats.rotateFormatted), ]), - const SizedBox(height: 12), - _buildSection(Symbols.palette_rounded, 'Color', [ - _metric('Pixel Fmt', _stats.pixelformat ?? 'N/A'), - if (_stats.hwPixelformat != null && _stats.hwPixelformat != _stats.pixelformat) - _metric('HW Fmt', _stats.hwPixelformat!), - _metric('Matrix', _stats.colormatrix ?? 'N/A'), - _metric('Primaries', _stats.primaries ?? 'N/A'), - _metric('Transfer', _stats.gamma ?? 'N/A'), - ]), + // Color section - MPV only (ExoPlayer doesn't provide this info) + if (isMpv) ...[ + const SizedBox(height: 12), + _buildSection(Symbols.palette_rounded, 'Color', [ + _metric('Pixel Fmt', _stats.pixelformat ?? 'N/A'), + if (_stats.hwPixelformat != null && _stats.hwPixelformat != _stats.pixelformat) + _metric('HW Fmt', _stats.hwPixelformat!), + _metric('Matrix', _stats.colormatrix ?? 'N/A'), + _metric('Primaries', _stats.primaries ?? 'N/A'), + _metric('Transfer', _stats.gamma ?? 'N/A'), + ]), + ], if (_stats.hasHdrMetadata) ...[ const SizedBox(height: 12), _buildSection(Symbols.hdr_on_rounded, 'HDR', [ @@ -101,26 +106,27 @@ class _PlayerPerformanceOverlayState extends State { mainAxisSize: MainAxisSize.min, children: [ _buildSection(Symbols.volume_up_rounded, 'Audio', [ - _metric('Codec', _stats.audioCodec ?? 'N/A'), + if (_stats.audioCodec != null) _metric('Codec', _stats.audioCodec!), _metric('Sample Rate', _stats.sampleRateFormatted), _metric('Channels', _stats.audioChannels ?? 'N/A'), - _metric('Bitrate', _stats.audioBitrateFormatted), + if (_stats.hasValidAudioBitrate) _metric('Bitrate', _stats.audioBitrateFormatted), ]), const SizedBox(height: 12), _buildSection(Symbols.speed_rounded, 'Performance', [ - _metric('Render FPS', _stats.actualFpsFormatted), - _metric('Display FPS', _stats.displayFpsFormatted), - _metric('A/V Sync', _stats.avsyncFormatted), + if (isMpv) _metric('Render FPS', _stats.actualFpsFormatted), + if (isMpv) _metric('Display FPS', _stats.displayFpsFormatted), + if (isMpv) _metric('A/V Sync', _stats.avsyncFormatted), _metric('Dropped', _stats.droppedFramesFormatted), ]), const SizedBox(height: 12), _buildSection(Symbols.memory_rounded, 'Buffer', [ _metric('Duration', _stats.cacheDurationFormatted), - _metric('Cache Used', _stats.cacheUsedFormatted), - _metric('Speed', _stats.cacheSpeedFormatted), + if (isMpv) _metric('Cache Used', _stats.cacheUsedFormatted), + if (isMpv) _metric('Speed', _stats.cacheSpeedFormatted), ]), const SizedBox(height: 12), _buildSection(Symbols.apps_rounded, 'App', [ + _metric('Player', _stats.playerTypeFormatted), _metric('Memory', _stats.appMemoryFormatted), _metric('UI FPS', _stats.uiFpsFormatted), ]), diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart index febbca5d..1610836d 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -1,8 +1,11 @@ /// Data model for video player performance statistics. /// -/// Contains metrics queried from libmpv including video/audio codec info, -/// playback performance, and buffer state. +/// Contains metrics queried from the video player (MPV or ExoPlayer) +/// including video/audio codec info, playback performance, and buffer state. class PerformanceStats { + // Player info + final String playerType; // 'mpv' or 'exoplayer' + // Video metrics final String? videoCodec; final int? videoWidth; @@ -12,6 +15,7 @@ class PerformanceStats { final int? videoBitrate; final String? aspectName; final int? rotate; + final String? videoDecoderName; // Color/Format metrics final String? pixelformat; @@ -49,6 +53,7 @@ class PerformanceStats { final double? uiFps; const PerformanceStats({ + this.playerType = 'unknown', this.videoCodec, this.videoWidth, this.videoHeight, @@ -57,6 +62,7 @@ class PerformanceStats { this.videoBitrate, this.aspectName, this.rotate, + this.videoDecoderName, this.pixelformat, this.hwPixelformat, this.colormatrix, @@ -84,7 +90,8 @@ class PerformanceStats { /// Creates an empty stats object (used as initial state). const PerformanceStats.empty() - : videoCodec = null, + : playerType = 'unknown', + videoCodec = null, videoWidth = null, videoHeight = null, videoFps = null, @@ -92,6 +99,7 @@ class PerformanceStats { videoBitrate = null, aspectName = null, rotate = null, + videoDecoderName = null, pixelformat = null, hwPixelformat = null, colormatrix = null, @@ -196,6 +204,23 @@ class PerformanceStats { /// Format hardware decoding mode. String get hwdecFormatted { + // For ExoPlayer, use the decoder name + if (videoDecoderName != null && videoDecoderName!.isNotEmpty) { + // Check if it's a hardware decoder (contains OMX, c2, or MediaCodec patterns) + final decoder = videoDecoderName!; + if (decoder.contains('c2.') || decoder.contains('OMX.') || decoder.contains('.hw.')) { + // Extract a cleaner name + if (decoder.contains('c2.android.')) return 'Android HW'; + if (decoder.contains('c2.nvidia')) return 'NVIDIA HW'; + if (decoder.contains('c2.qti') || decoder.contains('c2.qcom')) return 'Qualcomm HW'; + if (decoder.contains('c2.mtk') || decoder.contains('c2.mediatek')) return 'MediaTek HW'; + if (decoder.contains('c2.exynos') || decoder.contains('c2.samsung')) return 'Exynos HW'; + if (decoder.contains('OMX.google')) return 'Software'; + return 'Hardware'; + } + return 'Software'; + } + // For MPV, use hwdec-current property if (hwdecCurrent == null || hwdecCurrent!.isEmpty || hwdecCurrent == 'no') { return 'Software'; } @@ -249,4 +274,28 @@ class PerformanceStats { bool get hasHdrMetadata { return maxLuma != null || maxCll != null; } + + /// Check if video FPS is valid (not null, not negative, not zero). + bool get hasValidVideoFps { + return videoFps != null && videoFps! > 0; + } + + /// Check if video bitrate is valid (not null, not negative, not zero). + bool get hasValidVideoBitrate { + return videoBitrate != null && videoBitrate! > 0; + } + + /// Check if audio bitrate is valid (not null, not negative, not zero). + bool get hasValidAudioBitrate { + return audioBitrate != null && audioBitrate! > 0; + } + + /// Format player type for display. + String get playerTypeFormatted { + return switch (playerType.toLowerCase()) { + 'mpv' => 'MPV', + 'exoplayer' => 'ExoPlayer', + _ => playerType, + }; + } } diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index 07e0ff35..0e13c08e 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -4,10 +4,13 @@ import 'dart:io' show ProcessInfo; import 'package:flutter/scheduler.dart'; import '../../../../mpv/mpv.dart'; +import '../../../../mpv/player/player_android.dart'; import '../../../../utils/app_logger.dart'; import 'performance_stats.dart'; -/// Service that polls MPV properties and provides performance stats via a stream. +/// Service that polls player properties and provides performance stats via a stream. +/// +/// Supports both MPV (desktop/iOS) and ExoPlayer (Android) backends. /// /// Usage: /// ```dart @@ -30,6 +33,9 @@ class PerformanceStatsService { DateTime _lastFpsUpdate = DateTime.now(); double? _currentUiFps; + /// Whether we're using ExoPlayer (Android) or MPV + bool get _isExoPlayer => player is PlayerAndroid; + PerformanceStatsService(this.player); /// Stream of performance stats updates. @@ -70,96 +76,155 @@ class PerformanceStatsService { _pollingTimer = null; } - /// Fetch all performance stats from MPV. + /// Fetch all performance stats from the player. Future _fetchStats() async { try { - // Fetch all properties in parallel for efficiency - final results = await Future.wait([ - player.getProperty('video-codec'), // 0 - player.getProperty('video-params/w'), // 1 - player.getProperty('video-params/h'), // 2 - player.getProperty('container-fps'), // 3 - player.getProperty('estimated-vf-fps'), // 4 - player.getProperty('video-bitrate'), // 5 - player.getProperty('hwdec-current'), // 6 - player.getProperty('audio-codec-name'), // 7 - player.getProperty('audio-params/samplerate'), // 8 - player.getProperty('audio-params/hr-channels'), // 9 - player.getProperty('audio-bitrate'), // 10 - player.getProperty('total-avsync-change'), // 11 - player.getProperty('cache-used'), // 12 - player.getProperty('cache-speed'), // 13 - player.getProperty('display-fps'), // 14 - player.getProperty('frame-drop-count'), // 15 - player.getProperty('decoder-frame-drop-count'), // 16 - player.getProperty('demuxer-cache-duration'), // 17 - // Color/Format properties - player.getProperty('video-params/pixelformat'), // 18 - player.getProperty('video-params/hw-pixelformat'), // 19 - player.getProperty('video-params/colormatrix'), // 20 - player.getProperty('video-params/primaries'), // 21 - player.getProperty('video-params/gamma'), // 22 - // HDR metadata - player.getProperty('video-params/max-luma'), // 23 - player.getProperty('video-params/min-luma'), // 24 - player.getProperty('video-params/max-cll'), // 25 - player.getProperty('video-params/max-fall'), // 26 - // Other - player.getProperty('video-params/aspect-name'), // 27 - player.getProperty('video-params/rotate'), // 28 - ]); - - // Get app memory usage - int? appMemory; - try { - appMemory = ProcessInfo.currentRss; - } catch (_) { - // ProcessInfo not available on all platforms + if (_isExoPlayer) { + await _fetchExoPlayerStats(); + } else { + await _fetchMpvStats(); } - - final stats = PerformanceStats( - videoCodec: _formatCodecName(results[0]), - videoWidth: _parseInt(results[1]), - videoHeight: _parseInt(results[2]), - videoFps: _parseDouble(results[3]), - actualFps: _parseDouble(results[4]), - videoBitrate: _parseInt(results[5]), - hwdecCurrent: results[6], - audioCodec: _formatCodecName(results[7]), - audioSamplerate: _parseInt(results[8]), - audioChannels: results[9], - audioBitrate: _parseInt(results[10]), - avsyncChange: _parseDouble(results[11]), - cacheUsed: _parseInt(results[12]), - cacheSpeed: _parseDouble(results[13]), - displayFps: _parseDouble(results[14]), - frameDropCount: _parseInt(results[15]), - decoderFrameDropCount: _parseInt(results[16]), - cacheDuration: _parseDouble(results[17]), - // Color/Format properties - pixelformat: results[18], - hwPixelformat: results[19], - colormatrix: results[20], - primaries: results[21], - gamma: results[22], - // HDR metadata - maxLuma: _parseDouble(results[23]), - minLuma: _parseDouble(results[24]), - maxCll: _parseDouble(results[25]), - maxFall: _parseDouble(results[26]), - // Other - aspectName: results[27], - rotate: _parseInt(results[28]), - appMemoryBytes: appMemory, - uiFps: _currentUiFps, - ); - - _statsController.add(stats); } catch (e) { appLogger.w('Failed to fetch performance stats', error: e); } } + /// Fetch stats from ExoPlayer via native method channel. + Future _fetchExoPlayerStats() async { + final exoPlayer = player as PlayerAndroid; + final statsMap = await exoPlayer.getStats(); + + // Get app memory usage + int? appMemory; + try { + appMemory = ProcessInfo.currentRss; + } catch (_) {} + + final stats = PerformanceStats( + playerType: (statsMap['playerType'] as String?) ?? 'exoplayer', + // Video metrics + videoCodec: _formatCodecName(statsMap['videoCodec'] as String?), + videoWidth: statsMap['videoWidth'] as int?, + videoHeight: statsMap['videoHeight'] as int?, + videoFps: (statsMap['videoFps'] as num?)?.toDouble(), + videoBitrate: statsMap['videoBitrate'] as int?, + videoDecoderName: statsMap['videoDecoderName'] as String?, + // Audio metrics + audioCodec: _formatCodecName(statsMap['audioCodec'] as String?), + audioSamplerate: statsMap['audioSampleRate'] as int?, + audioChannels: _formatChannels(statsMap['audioChannels'] as int?), + audioBitrate: statsMap['audioBitrate'] as int?, + // Performance metrics + frameDropCount: statsMap['videoDroppedFrames'] as int?, + // Buffer metrics - convert ms to seconds for duration + cacheDuration: ((statsMap['totalBufferedDurationMs'] as int?) ?? 0) / 1000.0, + // App metrics + appMemoryBytes: appMemory, + uiFps: _currentUiFps, + ); + + _statsController.add(stats); + } + + /// Format channel count to string (e.g., "2" -> "Stereo", "6" -> "5.1") + String? _formatChannels(int? channels) { + if (channels == null) return null; + return switch (channels) { + 1 => 'Mono', + 2 => 'Stereo', + 6 => '5.1', + 8 => '7.1', + _ => '$channels ch', + }; + } + + /// Fetch stats from MPV via property queries. + Future _fetchMpvStats() async { + // Fetch all properties in parallel for efficiency + final results = await Future.wait([ + player.getProperty('video-codec'), // 0 + player.getProperty('video-params/w'), // 1 + player.getProperty('video-params/h'), // 2 + player.getProperty('container-fps'), // 3 + player.getProperty('estimated-vf-fps'), // 4 + player.getProperty('video-bitrate'), // 5 + player.getProperty('hwdec-current'), // 6 + player.getProperty('audio-codec-name'), // 7 + player.getProperty('audio-params/samplerate'), // 8 + player.getProperty('audio-params/hr-channels'), // 9 + player.getProperty('audio-bitrate'), // 10 + player.getProperty('total-avsync-change'), // 11 + player.getProperty('cache-used'), // 12 + player.getProperty('cache-speed'), // 13 + player.getProperty('display-fps'), // 14 + player.getProperty('frame-drop-count'), // 15 + player.getProperty('decoder-frame-drop-count'), // 16 + player.getProperty('demuxer-cache-duration'), // 17 + // Color/Format properties + player.getProperty('video-params/pixelformat'), // 18 + player.getProperty('video-params/hw-pixelformat'), // 19 + player.getProperty('video-params/colormatrix'), // 20 + player.getProperty('video-params/primaries'), // 21 + player.getProperty('video-params/gamma'), // 22 + // HDR metadata + player.getProperty('video-params/max-luma'), // 23 + player.getProperty('video-params/min-luma'), // 24 + player.getProperty('video-params/max-cll'), // 25 + player.getProperty('video-params/max-fall'), // 26 + // Other + player.getProperty('video-params/aspect-name'), // 27 + player.getProperty('video-params/rotate'), // 28 + ]); + + // Get app memory usage + int? appMemory; + try { + appMemory = ProcessInfo.currentRss; + } catch (_) { + // ProcessInfo not available on all platforms + } + + final stats = PerformanceStats( + playerType: 'mpv', + videoCodec: _formatCodecName(results[0]), + videoWidth: _parseInt(results[1]), + videoHeight: _parseInt(results[2]), + videoFps: _parseDouble(results[3]), + actualFps: _parseDouble(results[4]), + videoBitrate: _parseInt(results[5]), + hwdecCurrent: results[6], + audioCodec: _formatCodecName(results[7]), + audioSamplerate: _parseInt(results[8]), + audioChannels: results[9], + audioBitrate: _parseInt(results[10]), + avsyncChange: _parseDouble(results[11]), + cacheUsed: _parseInt(results[12]), + cacheSpeed: _parseDouble(results[13]), + displayFps: _parseDouble(results[14]), + frameDropCount: _parseInt(results[15]), + decoderFrameDropCount: _parseInt(results[16]), + cacheDuration: _parseDouble(results[17]), + // Color/Format properties + pixelformat: results[18], + hwPixelformat: results[19], + colormatrix: results[20], + primaries: results[21], + gamma: results[22], + // HDR metadata + maxLuma: _parseDouble(results[23]), + minLuma: _parseDouble(results[24]), + maxCll: _parseDouble(results[25]), + maxFall: _parseDouble(results[26]), + // Other + aspectName: results[27], + rotate: _parseInt(results[28]), + appMemoryBytes: appMemory, + uiFps: _currentUiFps, + ); + + _statsController.add(stats); + } + /// Parse a string to int, returning null if parsing fails. int? _parseInt(String? value) { if (value == null || value.isEmpty) return null;