feat: improve performance overlay layout and add decoder details
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user