feat: improve performance overlay layout and add decoder details

This commit is contained in:
edde746
2026-03-06 19:25:41 +01:00
parent b05d69b10f
commit 1318c54c0c
5 changed files with 115 additions and 85 deletions
@@ -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
@@ -2043,8 +2043,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> 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)),
),
@@ -47,95 +47,73 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
Widget build(BuildContext context) {
final isMpv = _stats.playerType == 'mpv';
final sections = <Widget>[
_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,
),
);
}
@@ -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';
@@ -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