From 1318c54c0c6d34820f58895579eaf9b16233c4c9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 6 Mar 2026 19:25:41 +0100 Subject: [PATCH] feat: improve performance overlay layout and add decoder details --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 28 +++- .../video_controls/video_controls.dart | 6 +- .../performance_overlay.dart | 142 ++++++++---------- .../performance_stats.dart | 20 +++ .../performance_stats_service.dart | 4 + 5 files changed, 115 insertions(+), 85 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 809b452d..833057ad 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 @@ -125,6 +125,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Decoder hang detection: tracks gap between decoder init and first rendered frame private var decoderHangRunnable: Runnable? = null private var decoderInitName: String? = null + private var audioDecoderInitName: String? = null private var firstFrameRendered: Boolean = false var delegate: ExoPlayerDelegate? = null var debugLoggingEnabled: Boolean = false @@ -1170,6 +1171,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { startDecoderHangCheck(decoderName) } + override fun onAudioDecoderInitialized( + eventTime: AnalyticsListener.EventTime, + decoderName: String, + initializationDurationMs: Long + ) { + audioDecoderInitName = decoderName + } + override fun onRenderedFirstFrame( eventTime: AnalyticsListener.EventTime, output: Any, @@ -1296,6 +1305,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { dv7RetryAttempted = false } + decoderInitName = null + audioDecoderInitName = null currentMediaUri = uri currentHeaders = headers currentMediaIsLive = isLive @@ -1639,7 +1650,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "videoHeight" to videoFormat?.height, "videoFps" to videoFormat?.frameRate, "videoBitrate" to videoFormat?.bitrate, - "videoDecoderName" to videoDecoderInfo, + "videoDecoderName" to (decoderInitName ?: videoDecoderInfo), "videoDroppedFrames" to player.videoDecoderCounters?.droppedBufferCount, "videoRenderedFrames" to player.videoDecoderCounters?.renderedOutputBufferCount, // Color info @@ -1653,6 +1664,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "audioSampleRate" to audioFormat?.sampleRate, "audioChannels" to audioFormat?.channelCount, "audioBitrate" to audioFormat?.bitrate, + "audioDecoderName" to audioDecoderInitName, + // Tunneling + "tunneledPlayback" to currentTunneledPlayback, + "tunnelingStatus" to getTunnelingStatus(player), // Buffer metrics "bufferedPositionMs" to player.bufferedPosition, "currentPositionMs" to player.currentPosition, @@ -1700,6 +1715,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } + private fun getTunnelingStatus(player: ExoPlayer): String { + if (currentTunneledPlayback) return "Active" + if (!tunnelingUserEnabled) return "Disabled by user" + if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)" + if (tunnelingDisabledForVideoCodec) return "Off (video codec unsupported)" + if (tunnelingDisabledForAudioCodec) return "Off (no HW audio decoder)" + return "Off" + } + // Cleanup fun dispose() { @@ -1716,6 +1740,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { audioFocusManager?.release() audioFocusManager = null + decoderInitName = null + audioDecoderInitName = null tunnelingDisabledForAudioCodec = false tunnelingDisabledForVideoCodec = false currentTunneledPlayback = false diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a6633085..a627f2f6 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -2043,8 +2043,10 @@ class _PlexVideoControlsState extends State with WindowListen ), // Performance overlay (top-left) if (_showPerformanceOverlay) - Positioned( - top: isMobile ? 60 : 16, + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + top: _showControls && isMobile ? 80.0 : 16.0, left: 16, child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)), ), 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 07910a1b..9e00d182 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_overlay.dart @@ -47,95 +47,73 @@ class _PlayerPerformanceOverlayState extends State { Widget build(BuildContext context) { final isMpv = _stats.playerType == 'mpv'; + final sections = [ + _buildSection(Symbols.videocam_rounded, 'Video', [ + _metric('Codec', _stats.videoCodec ?? 'N/A'), + _metric('Resolution', _stats.resolution), + if (_stats.hasValidVideoFps) _metric('FPS', _stats.videoFpsFormatted), + if (_stats.hasValidVideoBitrate) _metric('Bitrate', _stats.videoBitrateFormatted), + _metric('Decoder', _stats.hwdecFormatted), + if (!isMpv && _stats.videoDecoderName != null) _metric('Raw Decoder', _stats.videoDecoderRaw), + if (!isMpv) _metric('Tunneling', _stats.tunneledPlaybackFormatted), + if (_stats.aspectName != null && _stats.aspectName!.isNotEmpty) _metric('Aspect', _stats.aspectName!), + if (_stats.rotate != null && _stats.rotate != 0) _metric('Rotation', _stats.rotateFormatted), + if (_stats.dvConversionActive) + _metric('DV', _stats.dvConversionMode == 'DV81' ? '7→8.1' : '7→HEVC'), + ]), + _buildSection(Symbols.volume_up_rounded, 'Audio', [ + if (_stats.audioCodec != null) _metric('Codec', _stats.audioCodec!), + _metric('Sample Rate', _stats.sampleRateFormatted), + _metric('Channels', _stats.audioChannels ?? 'N/A'), + if (_stats.hasValidAudioBitrate) _metric('Bitrate', _stats.audioBitrateFormatted), + if (!isMpv && _stats.audioDecoderName != null) _metric('Decoder', _stats.audioDecoderFormatted), + ]), + if (isMpv) + _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'), + ]), + _buildSection(Symbols.speed_rounded, 'Performance', [ + 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), + ]), + if (_stats.hasHdrMetadata) + _buildSection(Symbols.hdr_on_rounded, 'HDR', [ + if (_stats.maxLuma != null) _metric('Max Luma', _stats.maxLumaFormatted), + if (_stats.minLuma != null) _metric('Min Luma', _stats.minLumaFormatted), + if (_stats.maxCll != null) _metric('MaxCLL', _stats.maxCllFormatted), + if (_stats.maxFall != null) _metric('MaxFALL', _stats.maxFallFormatted), + ]), + _buildSection(Symbols.memory_rounded, 'Buffer', [ + _metric('Duration', _stats.cacheDurationFormatted), + if (isMpv) _metric('Cache Used', _stats.cacheUsedFormatted), + if (isMpv) _metric('Speed', _stats.cacheSpeedFormatted), + ]), + _buildSection(Symbols.apps_rounded, 'App', [ + _metric('Player', _stats.playerTypeFormatted), + _metric('Memory', _stats.appMemoryFormatted), + _metric('UI FPS', _stats.uiFpsFormatted), + ]), + ]; + return Container( - constraints: const BoxConstraints(maxWidth: 380), + constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.8), borderRadius: const BorderRadius.all(Radius.circular(8)), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 4)], ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Left column - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildSection(Symbols.videocam_rounded, 'Video', [ - _metric('Codec', _stats.videoCodec ?? 'N/A'), - _metric('Resolution', _stats.resolution), - 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), - if (_stats.dvConversionActive) - _metric('DV', _stats.dvConversionMode == 'DV81' ? '7→8.1' : '7→HEVC'), - ]), - // 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', [ - if (_stats.maxLuma != null) _metric('Max Luma', _stats.maxLumaFormatted), - if (_stats.minLuma != null) _metric('Min Luma', _stats.minLumaFormatted), - if (_stats.maxCll != null) _metric('MaxCLL', _stats.maxCllFormatted), - if (_stats.maxFall != null) _metric('MaxFALL', _stats.maxFallFormatted), - ]), - ], - ], - ), - ), - const SizedBox(width: 24), - // Right column - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildSection(Symbols.volume_up_rounded, 'Audio', [ - if (_stats.audioCodec != null) _metric('Codec', _stats.audioCodec!), - _metric('Sample Rate', _stats.sampleRateFormatted), - _metric('Channels', _stats.audioChannels ?? 'N/A'), - if (_stats.hasValidAudioBitrate) _metric('Bitrate', _stats.audioBitrateFormatted), - ]), - const SizedBox(height: 12), - _buildSection(Symbols.speed_rounded, 'Performance', [ - 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), - 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), - ]), - ], - ), - ), - ], + child: Wrap( + spacing: 24, + runSpacing: 12, + children: sections, ), ); } 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 e3dd0cf5..7dd65e2d 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -35,6 +35,11 @@ class PerformanceStats { final int? audioSamplerate; final String? audioChannels; final int? audioBitrate; + final String? audioDecoderName; + + // Tunneling + final bool tunneledPlayback; + final String? tunnelingStatus; // Performance metrics final double? actualFps; @@ -80,6 +85,9 @@ class PerformanceStats { this.audioSamplerate, this.audioChannels, this.audioBitrate, + this.audioDecoderName, + this.tunneledPlayback = false, + this.tunnelingStatus, this.actualFps, this.avsyncChange, this.displayFps, @@ -119,6 +127,9 @@ class PerformanceStats { audioSamplerate = null, audioChannels = null, audioBitrate = null, + audioDecoderName = null, + tunneledPlayback = false, + tunnelingStatus = null, actualFps = null, avsyncChange = null, displayFps = null, @@ -235,6 +246,15 @@ class PerformanceStats { return hwdecCurrent!; } + /// Raw video decoder name (e.g. c2.qti.video.decoder.hevc). + String get videoDecoderRaw => videoDecoderName ?? 'N/A'; + + /// Format audio decoder name for display. + String get audioDecoderFormatted => audioDecoderName ?? 'N/A'; + + /// Format tunneled playback status with reason. + String get tunneledPlaybackFormatted => tunnelingStatus ?? (tunneledPlayback ? 'Active' : 'Off'); + /// Format app memory usage in MB. String get appMemoryFormatted { if (appMemoryBytes == null) return 'N/A'; 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 e36b267b..42151856 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 @@ -188,6 +188,10 @@ class PerformanceStatsService { audioSamplerate: statsMap['audioSampleRate'] as int?, audioChannels: _formatChannels(statsMap['audioChannels'] as int?), audioBitrate: statsMap['audioBitrate'] as int?, + audioDecoderName: statsMap['audioDecoderName'] as String?, + // Tunneling + tunneledPlayback: statsMap['tunneledPlayback'] == true, + tunnelingStatus: statsMap['tunnelingStatus'] as String?, // Performance metrics frameDropCount: statsMap['videoDroppedFrames'] as int?, // Buffer metrics - convert ms to seconds for duration