From 3f55cc461e2b404ca5038d64ebfd6eb82897bcf2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 23 Aug 2026 01:49:34 +0200 Subject: [PATCH] fix(player): show audio codec and bitrate in the performance overlay on ExoPlayer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android performance overlay showed only sample rate and channels for EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide (hence AAC working), and the bitrate from Format.bitrate, which Matroska carries only when the muxer wrote BPS statistics tags. Fall back to the already-transmitted sample MIME type for the codec name (audio and video), and measure the audio bitrate in the FFmpeg demuxer from packet sizes over their pts span — the same source mpv uses for its audio-bitrate property — when the container declares none. close #2063 --- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 15 ++++- .../plezy/exoplayer/FfmpegExtractor.kt | 22 +++++++ .../plezy/exoplayer/StreamBitrateMeter.kt | 65 +++++++++++++++++++ .../plezy/exoplayer/StreamBitrateMeterTest.kt | 65 +++++++++++++++++++ lib/utils/codec_utils.dart | 24 ++++++- .../performance_stats_service.dart | 17 +++-- test/utils/codec_utils_test.dart | 19 ++++++ .../performance_stats_service_test.dart | 63 ++++++++++++++++++ 8 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt create mode 100644 android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt 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 5d2d443a4..0811e126e 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 @@ -563,6 +563,10 @@ class ExoPlayerCore(private val activity: Activity) : @Volatile private var activeDoviMp4Wrapper: DoviExtractorWrapper? = null + // The FFmpeg extractor from the current session; getStats reads its + // measured audio bitrate for the playing item. + @Volatile private var activeFfmpegExtractor: FfmpegExtractor? = null + // Container-demuxer placement; read per media item when extractors are built. @Volatile private var demuxerPreference: FfmpegDemuxerPolicy.Preference = FfmpegDemuxerPolicy.Preference.FFMPEG @@ -785,7 +789,7 @@ class ExoPlayerCore(private val activity: Activity) : currentDvMode, subtitleParserFactory, handler - ) + )?.also { activeFfmpegExtractor = it } ) ( ffmpegFirst + extractorsFactory.createExtractors().map { extractor -> @@ -3772,6 +3776,7 @@ class ExoPlayerCore(private val activity: Activity) : dv7RetryAttempted = override != null activeDoviMkvWrapper = null activeDoviMp4Wrapper = null + activeFfmpegExtractor = null val debugMode = override?.name ?: "AUTO" emitLog("info", "dv-debug", "P7 DV conversion mode set to $debugMode (active=$dvMode)") reloadCurrentMediaForDvMode() @@ -3808,6 +3813,7 @@ class ExoPlayerCore(private val activity: Activity) : lastDvPlaybackInfo = null activeDoviMkvWrapper = null activeDoviMp4Wrapper = null + activeFfmpegExtractor = null stopFrameWatchdog() cancelDecoderHangCheck() cancelResumeStallWatchdog() @@ -4273,7 +4279,12 @@ class ExoPlayerCore(private val activity: Activity) : "audioMimeType" to audioFormat?.sampleMimeType, "audioSampleRate" to audioFormat?.sampleRate, "audioChannels" to audioFormat?.channelCount, - "audioBitrate" to audioFormat?.bitrate, + // Container-declared bitrate when present; otherwise the FFmpeg + // demuxer's measured value (Matroska rarely carries BPS tags — #2063). + "audioBitrate" to ( + audioFormat?.bitrate?.takeIf { it > 0 } + ?: audioFormat?.id?.let { activeFfmpegExtractor?.measuredAudioBitrateBps(it) } + ), "audioDecoderName" to audioDecoderInitName, "audioOutputEncoding" to audioTrackConfig?.encoding, "audioOutputChannels" to audioTrackConfig?.channelConfig?.let { Integer.bitCount(it) }, diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt index c12ee955c..bf97518c2 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/FfmpegExtractor.kt @@ -118,6 +118,10 @@ internal class FfmpegExtractor private constructor( private var subtitleKinds: Array = emptyArray() private val latmOutputs = ArrayList() + // Per-audio-stream measured bitrate, parallel to trackOutputs. Written by + // the loader thread, read by ExoPlayerCore.getStats on another thread. + @Volatile private var bitrateMeters: Array = emptyArray() + // Seek index for the primary stream: (presentation time, input position) of // video keyframes or periodic audio packets. The SeekMap reads it on the // playback thread while the loader thread appends, hence the lock. @@ -231,6 +235,21 @@ internal class FfmpegExtractor private constructor( opened = false FfmpegDemuxerJni.nativeClose() } + // media3's extractors may demux a later item while this instance keeps + // the previous one's stream layout; a stale measurement must not leak + // into stats. + bitrateMeters = emptyArray() + } + + /** + * Measured average bitrate (bps) of the audio stream whose index matches + * [formatId] (this extractor sets `Format.id` to the stream index), or null + * when this instance is not demuxing that stream. Fallback for containers + * that declare no per-track bitrate (#2063). + */ + fun measuredAudioBitrateBps(formatId: String?): Int? { + val index = formatId?.toIntOrNull() ?: return null + return bitrateMeters.getOrNull(index)?.bitrateBps() } // The in-call `reconciles` counter resets on every read() invocation, so it @@ -286,6 +305,7 @@ internal class FfmpegExtractor private constructor( durationUs = FfmpegDemuxerJni.nativeDurationUs().takeIf { it >= 0 } ?: C.TIME_UNSET trackOutputs = arrayOfNulls(count) subtitleKinds = Array(count) { SubtitleKind.NONE } + bitrateMeters = arrayOfNulls(count) latmOutputs.clear() // Fonts go in before any text track exists so AssHandler's store flushes // them into libass when the first ASS track is created. @@ -360,6 +380,7 @@ internal class FfmpegExtractor private constructor( val pcmEncoding = numbers[FfmpegDemuxerJni.INFO_PCM_ENCODING].toInt() if (pcmEncoding >= 0) builder.setPcmEncoding(pcmEncoding) if (primaryAudio < 0) primaryAudio = index + bitrateMeters[index] = StreamBitrateMeter() } C.TRACK_TYPE_TEXT -> { val kind = when (mime) { @@ -485,6 +506,7 @@ internal class FfmpegExtractor private constructor( null ) recordSeekPoint(streamIndex, ptsUs, packetOut[FfmpegDemuxerJni.OUT_POSITION], isKeyframe) + bitrateMeters.getOrNull(streamIndex)?.onPacket(ptsUs, size) return Extractor.RESULT_CONTINUE } FfmpegDemuxerJni.CODE_EOF -> return Extractor.RESULT_END_OF_INPUT diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt new file mode 100644 index 000000000..b296e4f03 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeter.kt @@ -0,0 +1,65 @@ +package com.edde746.plezy.exoplayer + +/** + * Rolling average bitrate of one demuxed stream, measured from packet sizes + * over their presentation-time span — the same source of truth mpv uses for + * its `audio-bitrate` property. It exists because containers frequently + * declare no per-track bitrate (Matroska without BPS statistics tags), which + * left the performance overlay with nothing to show (#2063). + * + * Fed from the loader thread ([onPacket]) and read from the stats path + * ([bitrateBps]); both are synchronized. The measurement follows the demux + * position, which runs ahead of the playhead by the buffered duration — + * acceptable for a diagnostics overlay. + */ +internal class StreamBitrateMeter(private val windowUs: Long = DEFAULT_WINDOW_US) { + + private val ptsUsQueue = ArrayDeque() + private val sizeQueue = ArrayDeque() + private var windowBytes = 0L + + @Synchronized + fun onPacket(ptsUs: Long, sizeBytes: Int) { + if (sizeBytes <= 0) return + // A backwards pts jump is a seek; packets on either side of it do not + // form a contiguous span, so the window restarts. + val last = ptsUsQueue.lastOrNull() + if (last != null && ptsUs < last) reset() + ptsUsQueue.addLast(ptsUs) + sizeQueue.addLast(sizeBytes) + windowBytes += sizeBytes + while (ptsUsQueue.size > 1 && ptsUsQueue.last() - ptsUsQueue.first() > windowUs) { + ptsUsQueue.removeFirst() + windowBytes -= sizeQueue.removeFirst() + } + } + + /** + * Average bitrate in bits per second over the current window, or null until + * enough packets span a measurable interval. + */ + @Synchronized + fun bitrateBps(): Int? { + if (ptsUsQueue.size < MIN_PACKETS) return null + val spanUs = ptsUsQueue.last() - ptsUsQueue.first() + if (spanUs <= 0) return null + // The pts span covers the durations of every packet except the last one, + // whose bytes are therefore excluded — a constant-rate stream measures + // exactly its rate. + val bytes = windowBytes - sizeQueue.last() + if (bytes <= 0) return null + return (bytes * 8_000_000L / spanUs).toInt() + } + + @Synchronized + fun reset() { + ptsUsQueue.clear() + sizeQueue.clear() + windowBytes = 0L + } + + companion object { + private const val DEFAULT_WINDOW_US = 10_000_000L + private const val MIN_PACKETS = 16 + } +} diff --git a/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt new file mode 100644 index 000000000..58ee52753 --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/exoplayer/StreamBitrateMeterTest.kt @@ -0,0 +1,65 @@ +package com.edde746.plezy.exoplayer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StreamBitrateMeterTest { + + /** Feeds [count] packets of [sizeBytes] every [intervalUs], starting at [startUs]. */ + private fun StreamBitrateMeter.feed(count: Int, sizeBytes: Int, intervalUs: Long, startUs: Long = 0L) { + for (i in 0 until count) onPacket(startUs + i * intervalUs, sizeBytes) + } + + @Test + fun `constant packet stream measures its bitrate`() { + val meter = StreamBitrateMeter() + // 1200-byte packets every 32 ms: 1200 * 8 / 0.032 = 300 kbps. + meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000) + assertEquals(300_000, meter.bitrateBps()) + } + + @Test + fun `too few packets report nothing`() { + val meter = StreamBitrateMeter() + meter.feed(count = 15, sizeBytes = 1200, intervalUs = 32_000) + assertNull(meter.bitrateBps()) + } + + @Test + fun `zero pts span reports nothing`() { + val meter = StreamBitrateMeter() + repeat(32) { meter.onPacket(0L, 1200) } + assertNull(meter.bitrateBps()) + } + + @Test + fun `window drops old packets so a bitrate change is followed`() { + val meter = StreamBitrateMeter(windowUs = 1_000_000) + meter.feed(count = 100, sizeBytes = 1200, intervalUs = 32_000) + // Continue at four times the packet size; after well over a window of + // new packets, only the new rate remains. + meter.feed(count = 100, sizeBytes = 4800, intervalUs = 32_000, startUs = 100 * 32_000L) + assertEquals(1_200_000, meter.bitrateBps()) + } + + @Test + fun `backwards pts jump resets the window`() { + val meter = StreamBitrateMeter() + meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000) + // Seek back: the single post-seek packet must not combine with the + // pre-seek span. + meter.onPacket(0L, 1200) + assertNull(meter.bitrateBps()) + meter.feed(count = 31, sizeBytes = 1200, intervalUs = 32_000, startUs = 32_000) + assertEquals(300_000, meter.bitrateBps()) + } + + @Test + fun `empty packets are ignored`() { + val meter = StreamBitrateMeter() + meter.feed(count = 32, sizeBytes = 1200, intervalUs = 32_000) + meter.onPacket(32 * 32_000L, 0) + assertEquals(300_000, meter.bitrateBps()) + } +} diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index 8121af33c..ca2586979 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -123,11 +123,29 @@ class CodecUtils { /// Formats an audio codec name to a user-friendly display format. /// - /// Accepts both ffmpeg-style names as reported by mpv and the media - /// servers ('aac', 'eac3') and RFC 6381 codec IDs as reported by - /// ExoPlayer's `Format.codecs` ('mp4a.40.2', 'ec-3', 'dtsc'). + /// Accepts ffmpeg-style names as reported by mpv and the media + /// servers ('aac', 'eac3'), RFC 6381 codec IDs as reported by + /// ExoPlayer's `Format.codecs` ('mp4a.40.2', 'ec-3', 'dtsc'), and + /// `audio/...` MIME types as reported by ExoPlayer's + /// `Format.sampleMimeType` ('audio/eac3', 'audio/vnd.dts'). static String formatAudioCodec(String codec) { final lower = codec.toLowerCase(); + if (lower.startsWith('audio/')) { + return switch (lower.substring('audio/'.length)) { + 'mp4a-latm' => 'AAC', + 'mpeg' || 'mpeg-l2' => 'MP3', + 'true-hd' => 'TrueHD', + 'vnd.dts' => 'DTS', + 'vnd.dts.hd' || 'vnd.dts.hd;profile=lbr' => 'DTS-HD', + 'vnd.dts.uhd;audio=p2' => 'DTS:X', + 'ac3' => 'AC3', + 'eac3' || 'eac3-joc' => 'E-AC3', + 'ac4' => 'AC4', + 'raw' || 'wav' => 'PCM', + 'alac' => 'ALAC', + final rest => formatAudioCodec(rest), + }; + } // MP4 object types 0x69/0x6B under the mp4a prefix are MPEG layer // audio; every other mp4a object type in the wild is an AAC variant. if (lower == 'mp4a.69' || lower == 'mp4a.6b') return 'MP3'; 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 e414ba2d2..b6889173b 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 @@ -200,14 +200,14 @@ class PerformanceStatsService { final stats = PerformanceStats( playerType: 'exoplayer', // Video metrics - videoCodec: _formatVideoCodecName(statsMap['videoCodec'] as String?), + videoCodec: _formatVideoCodecName((statsMap['videoCodec'] ?? statsMap['videoMimeType']) 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: _formatAudioCodecName(statsMap['audioCodec'] as String?), + audioCodec: _formatAudioCodecName((statsMap['audioCodec'] ?? statsMap['audioMimeType']) as String?), audioSamplerate: statsMap['audioSampleRate'] as int?, audioChannels: CodecUtils.formatAudioChannels(statsMap['audioChannels'] as int?), audioBitrate: statsMap['audioBitrate'] as int?, @@ -387,17 +387,26 @@ class PerformanceStatsService { } /// Format a video codec name for display. Handles mpv's descriptive - /// strings ('hevc (Main 10)') and ExoPlayer's RFC 6381 codec IDs - /// ('hvc1.2.4.L153.B0', 'av01.0.08M.10'). + /// strings ('hevc (Main 10)'), ExoPlayer's RFC 6381 codec IDs + /// ('hvc1.2.4.L153.B0', 'av01.0.08M.10'), and `video/...` MIME types + /// ('video/hevc') used as a fallback when the container carries no + /// codecs string. String? _formatVideoCodecName(String? codec) { if (codec == null || codec.isEmpty) return null; final upper = codec.toUpperCase(); + if (upper.contains('DVHE') || upper.contains('DVH1') || upper.contains('DOLBY-VISION')) { + return 'Dolby Vision'; + } if (upper.contains('HEVC') || upper.contains('H265') || upper.contains('HVC1') || upper.contains('HEV1')) { return 'HEVC'; } if (upper.contains('H264') || upper.contains('AVC')) return 'H.264'; if (upper.contains('AV1') || upper.contains('AV01')) return 'AV1'; if (upper.contains('VP9') || upper.contains('VP09')) return 'VP9'; + if (upper.contains('VP8') || upper.contains('VP08')) return 'VP8'; + if (upper.contains('MP4V')) return 'MPEG-4'; + if (upper.contains('MPEG2')) return 'MPEG-2'; + if (upper.startsWith('VIDEO/')) return codec.substring('video/'.length).toUpperCase(); return codec; } diff --git a/test/utils/codec_utils_test.dart b/test/utils/codec_utils_test.dart index 5ba27da69..29241ba5a 100644 --- a/test/utils/codec_utils_test.dart +++ b/test/utils/codec_utils_test.dart @@ -200,6 +200,25 @@ void main() { expect(CodecUtils.formatAudioCodec('alac'), 'ALAC'); expect(CodecUtils.formatAudioCodec('weird'), 'WEIRD'); }); + + test('audio MIME types from ExoPlayer Format.sampleMimeType map to friendly names (#2063)', () { + expect(CodecUtils.formatAudioCodec('audio/mp4a-latm'), 'AAC'); + expect(CodecUtils.formatAudioCodec('audio/mpeg'), 'MP3'); + expect(CodecUtils.formatAudioCodec('audio/ac3'), 'AC3'); + expect(CodecUtils.formatAudioCodec('audio/eac3'), 'E-AC3'); + expect(CodecUtils.formatAudioCodec('audio/eac3-joc'), 'E-AC3'); + expect(CodecUtils.formatAudioCodec('audio/true-hd'), 'TrueHD'); + expect(CodecUtils.formatAudioCodec('audio/vnd.dts'), 'DTS'); + expect(CodecUtils.formatAudioCodec('audio/vnd.dts.hd'), 'DTS-HD'); + expect(CodecUtils.formatAudioCodec('audio/vnd.dts.hd;profile=lbr'), 'DTS-HD'); + expect(CodecUtils.formatAudioCodec('audio/vnd.dts.uhd;audio=p2'), 'DTS:X'); + expect(CodecUtils.formatAudioCodec('audio/flac'), 'FLAC'); + expect(CodecUtils.formatAudioCodec('audio/opus'), 'Opus'); + expect(CodecUtils.formatAudioCodec('audio/vorbis'), 'Vorbis'); + expect(CodecUtils.formatAudioCodec('audio/ac4'), 'AC4'); + expect(CodecUtils.formatAudioCodec('audio/raw'), 'PCM'); + expect(CodecUtils.formatAudioCodec('audio/alac'), 'ALAC'); + }); }); group('CodecUtils.formatAudioChannels', () { diff --git a/test/widgets/performance_stats_service_test.dart b/test/widgets/performance_stats_service_test.dart index 4b08e4723..84784096e 100644 --- a/test/widgets/performance_stats_service_test.dart +++ b/test/widgets/performance_stats_service_test.dart @@ -50,6 +50,22 @@ class _PropertyPlayer implements Player { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +/// Player fake that reports native (Android ExoPlayer) stats. +class _NativeStatsPlayer extends _PropertyPlayer { + _NativeStatsPlayer(this.stats) : super(const {}); + + final Map stats; + + @override + bool get providesNativeStats => true; + + @override + Future> getStats() async => stats; + + @override + Future runtimePlayerType() async => 'exoplayer'; +} + Future _firstStats(_PropertyPlayer player) async { final service = PerformanceStatsService(player); try { @@ -117,4 +133,51 @@ void main() { expect(stats.audioPassthroughFormatted, 'DTS-HD'); }); }); + + group('PerformanceStatsService ExoPlayer codec display (#2063)', () { + test('falls back to the sample MIME type when the container has no codecs string', () async { + // Matroska via the FFmpeg demuxer: Format.codecs is null; only the + // MIME types identify the streams. + final stats = await _firstStats( + _NativeStatsPlayer({ + 'playerType': 'exoplayer', + 'videoCodec': null, + 'videoMimeType': 'video/hevc', + 'audioCodec': null, + 'audioMimeType': 'audio/eac3', + 'audioSampleRate': 48000, + 'audioChannels': 6, + }), + ); + + expect(stats.videoCodec, 'HEVC'); + expect(stats.audioCodec, 'E-AC3'); + expect(stats.audioChannels, '5.1'); + }); + + test('an explicit codecs string wins over the MIME type', () async { + final stats = await _firstStats( + _NativeStatsPlayer({ + 'playerType': 'exoplayer', + 'videoCodec': 'hvc1.2.4.L153.B0', + 'videoMimeType': 'video/hevc', + 'audioCodec': 'mp4a.40.2', + 'audioMimeType': 'audio/mp4a-latm', + }), + ); + + expect(stats.videoCodec, 'HEVC'); + expect(stats.audioCodec, 'AAC'); + }); + + test('measured audio bitrate from the native side is surfaced', () async { + final stats = await _firstStats( + _NativeStatsPlayer({'playerType': 'exoplayer', 'audioMimeType': 'audio/vnd.dts', 'audioBitrate': 1509000}), + ); + + expect(stats.audioCodec, 'DTS'); + expect(stats.hasValidAudioBitrate, isTrue); + expect(stats.audioBitrateFormatted, '1509 kbps'); + }); + }); }