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'); + }); + }); }