fix(android): gate TrueHD passthrough
This commit is contained in:
@@ -6,7 +6,12 @@ import android.content.Context
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.media.AudioDeviceInfo
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.AudioTrack
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -19,6 +24,7 @@ import android.view.ViewTreeObserver
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
@@ -30,14 +36,18 @@ import androidx.media3.common.VideoSize
|
|||||||
import androidx.media3.common.text.Cue
|
import androidx.media3.common.text.Cue
|
||||||
import androidx.media3.common.text.CueGroup
|
import androidx.media3.common.text.CueGroup
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.common.util.Util
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
import androidx.media3.datasource.HttpDataSource
|
import androidx.media3.datasource.HttpDataSource
|
||||||
import androidx.media3.datasource.cronet.CronetDataSource
|
import androidx.media3.datasource.cronet.CronetDataSource
|
||||||
|
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||||
import androidx.media3.exoplayer.DefaultLoadControl
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.RenderersFactory
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
|
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||||
|
import androidx.media3.exoplayer.audio.AudioSink
|
||||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||||
@@ -138,6 +148,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
private var decoderInitName: String? = null
|
private var decoderInitName: String? = null
|
||||||
private var audioDecoderInitName: String? = null
|
private var audioDecoderInitName: String? = null
|
||||||
private var loggedEwasteEac3Workaround: Boolean = false
|
private var loggedEwasteEac3Workaround: Boolean = false
|
||||||
|
private var lastTrueHdDirectOutputLogKey: String? = null
|
||||||
private var firstFrameRendered: Boolean = false
|
private var firstFrameRendered: Boolean = false
|
||||||
var delegate: ExoPlayerDelegate? = null
|
var delegate: ExoPlayerDelegate? = null
|
||||||
var debugLoggingEnabled: Boolean = false
|
var debugLoggingEnabled: Boolean = false
|
||||||
@@ -385,13 +396,13 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create ExoPlayer with FFmpeg audio decoder fallback
|
// Create ExoPlayer with FFmpeg audio decoder fallback
|
||||||
val audioAttributes = androidx.media3.common.AudioAttributes.Builder()
|
val audioAttributes = buildMovieAudioAttributes()
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
|
||||||
.setUsage(C.USAGE_MEDIA)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
// Use DefaultRenderersFactory with FFmpeg fallback for unsupported or blocked audio codecs.
|
// Use DefaultRenderersFactory with FFmpeg fallback for unsupported or blocked audio codecs.
|
||||||
val renderersFactory = PlezyRenderersFactory(activity).apply {
|
val renderersFactory = PlezyRenderersFactory(activity).apply {
|
||||||
|
audioDiagnosticsLogger = { level, prefix, message -> emitLog(level, prefix, message) }
|
||||||
|
shouldBlockDirectTrueHd = { format -> this@ExoPlayerCore.shouldBlockDirectTrueHd(format, "sink support") }
|
||||||
|
onAudioCapabilitiesChanged = { updateAudioDecoderPolicy("audio capabilities changed") }
|
||||||
setEnableDecoderFallback(true)
|
setEnableDecoderFallback(true)
|
||||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||||
setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
|
setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
|
||||||
@@ -407,6 +418,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.renderersFactory = renderersFactory
|
this.renderersFactory = renderersFactory
|
||||||
|
updateAudioDecoderPolicy("initialize")
|
||||||
|
|
||||||
// Cronet DataSource for HTTP/2 multiplexing — all range requests share one connection
|
// Cronet DataSource for HTTP/2 multiplexing — all range requests share one connection
|
||||||
httpDataSourceFactory = CronetDataSource.Factory(getCronetEngine(activity), cronetExecutor)
|
httpDataSourceFactory = CronetDataSource.Factory(getCronetEngine(activity), cronetExecutor)
|
||||||
@@ -788,7 +800,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
}
|
}
|
||||||
if (audioGroup != null) {
|
if (audioGroup != null) {
|
||||||
val af = audioGroup.mediaTrackGroup.getFormat(0)
|
val af = audioGroup.mediaTrackGroup.getFormat(0)
|
||||||
emitLog("info", "tracks", "Audio: ${af.codecs} ${af.channelCount}ch ${af.sampleRate}Hz")
|
emitLog("info", "tracks", "Audio: ${formatAudioSummary(af)}")
|
||||||
|
if (af.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||||
|
updateAudioDecoderPolicy("tracks changed", af)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Detect video track present but deselected (unsupported codec — plays audio only)
|
// Detect video track present but deselected (unsupported codec — plays audio only)
|
||||||
val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO }
|
val hasAnyVideoGroup = tracks.groups.any { it.type == C.TRACK_TYPE_VIDEO }
|
||||||
@@ -1088,6 +1103,25 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
|
|
||||||
// Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg)
|
// Tunneling control — disabled when audio codec has no hardware decoder (requires FFmpeg)
|
||||||
|
|
||||||
|
private data class TrueHdDirectOutputDecision(
|
||||||
|
val forceFfmpeg: Boolean,
|
||||||
|
val platformProbe: String,
|
||||||
|
val media3Probe: String,
|
||||||
|
val routeSummary: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun shouldBlockDirectTrueHd(format: Format, reason: String): Boolean {
|
||||||
|
val decision = evaluateTrueHdDirectOutput(format)
|
||||||
|
logTrueHdDirectOutputDecision(reason, format, decision)
|
||||||
|
return decision.forceFfmpeg
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAudioDecoderPolicy(reason: String, format: Format? = null) {
|
||||||
|
if (format != null && format.sampleMimeType != MimeTypes.AUDIO_TRUEHD) return
|
||||||
|
val decision = evaluateTrueHdDirectOutput(format)
|
||||||
|
logTrueHdDirectOutputDecision(reason, format, decision)
|
||||||
|
}
|
||||||
|
|
||||||
private fun shouldForceAppAudioDecoder(mimeType: String): Boolean {
|
private fun shouldForceAppAudioDecoder(mimeType: String): Boolean {
|
||||||
if (mimeType == MimeTypes.AUDIO_FLAC) return true
|
if (mimeType == MimeTypes.AUDIO_FLAC) return true
|
||||||
if (DeviceQuirks.isEWaste && isEac3MimeType(mimeType)) {
|
if (DeviceQuirks.isEWaste && isEac3MimeType(mimeType)) {
|
||||||
@@ -1100,6 +1134,184 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun evaluateTrueHdDirectOutput(format: Format?): TrueHdDirectOutputDecision {
|
||||||
|
val probeFormat = trueHdProbeFormat(format)
|
||||||
|
val audioAttributes = buildMovieAudioAttributes()
|
||||||
|
val platformAttributes = audioAttributes.getPlatformAudioAttributes()
|
||||||
|
|
||||||
|
var platformSupported: Boolean? = null
|
||||||
|
val platformProbe = try {
|
||||||
|
when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
|
||||||
|
val platformFormat = buildTrueHdAudioFormat(probeFormat)
|
||||||
|
val support = AudioManager.getDirectPlaybackSupport(platformFormat, platformAttributes)
|
||||||
|
val bitstream = (support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
||||||
|
platformSupported = bitstream
|
||||||
|
"api=${Build.VERSION.SDK_INT}, direct=${describeDirectPlaybackSupport(support)}, bitstream=$bitstream"
|
||||||
|
}
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
|
||||||
|
val platformFormat = buildTrueHdAudioFormat(probeFormat)
|
||||||
|
val direct = isDirectPlaybackSupportedApi29(platformFormat, platformAttributes)
|
||||||
|
platformSupported = direct
|
||||||
|
"api=${Build.VERSION.SDK_INT}, direct=$direct"
|
||||||
|
}
|
||||||
|
else -> "api=${Build.VERSION.SDK_INT}, direct=unknown"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"api=${Build.VERSION.SDK_INT}, direct=error(${e.javaClass.simpleName}: ${e.message})"
|
||||||
|
}
|
||||||
|
|
||||||
|
var media3Supported: Boolean? = null
|
||||||
|
val media3Probe = try {
|
||||||
|
media3Supported = AudioCapabilities
|
||||||
|
.getCapabilities(activity, audioAttributes, null)
|
||||||
|
.isPassthroughPlaybackSupported(probeFormat, audioAttributes)
|
||||||
|
"media3Passthrough=$media3Supported"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"media3Passthrough=error(${e.javaClass.simpleName}: ${e.message})"
|
||||||
|
}
|
||||||
|
|
||||||
|
val forceFfmpeg = when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && platformSupported != null -> !platformSupported
|
||||||
|
media3Supported != null -> !media3Supported
|
||||||
|
platformSupported != null -> !platformSupported
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrueHdDirectOutputDecision(
|
||||||
|
forceFfmpeg = forceFfmpeg,
|
||||||
|
platformProbe = platformProbe,
|
||||||
|
media3Probe = media3Probe,
|
||||||
|
routeSummary = summarizeAudioOutputRoutes(audioAttributes)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logTrueHdDirectOutputDecision(reason: String, format: Format?, decision: TrueHdDirectOutputDecision) {
|
||||||
|
val key = listOf(
|
||||||
|
decision.forceFfmpeg,
|
||||||
|
decision.platformProbe,
|
||||||
|
decision.media3Probe,
|
||||||
|
decision.routeSummary,
|
||||||
|
format?.id,
|
||||||
|
format?.channelCount,
|
||||||
|
format?.sampleRate
|
||||||
|
).joinToString("|")
|
||||||
|
if (key == lastTrueHdDirectOutputLogKey) return
|
||||||
|
lastTrueHdDirectOutputLogKey = key
|
||||||
|
|
||||||
|
emitLog(
|
||||||
|
"info",
|
||||||
|
"audio",
|
||||||
|
"TrueHD direct output ${if (decision.forceFfmpeg) "disabled (FFmpeg PCM)" else "allowed"} " +
|
||||||
|
"(reason=$reason, format=${formatAudioSummary(trueHdProbeFormat(format))}, " +
|
||||||
|
"${decision.platformProbe}, ${decision.media3Probe}, route=${decision.routeSummary})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun trueHdProbeFormat(format: Format?): Format = if (format?.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||||
|
format
|
||||||
|
} else {
|
||||||
|
Format.Builder()
|
||||||
|
.setSampleMimeType(MimeTypes.AUDIO_TRUEHD)
|
||||||
|
.setChannelCount(8)
|
||||||
|
.setSampleRate(48_000)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildMovieAudioAttributes(): androidx.media3.common.AudioAttributes = androidx.media3.common.AudioAttributes.Builder()
|
||||||
|
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||||
|
.setUsage(C.USAGE_MEDIA)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun buildTrueHdAudioFormat(format: Format): AudioFormat {
|
||||||
|
val channelCount = if (format.channelCount > 0) format.channelCount else 8
|
||||||
|
val channelMask = Util.getAudioTrackChannelConfig(channelCount).takeIf { it != 0 }
|
||||||
|
?: AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
||||||
|
val sampleRate = if (format.sampleRate > 0) format.sampleRate else 48_000
|
||||||
|
return AudioFormat.Builder()
|
||||||
|
.setEncoding(AudioFormat.ENCODING_DOLBY_TRUEHD)
|
||||||
|
.setChannelMask(channelMask)
|
||||||
|
.setSampleRate(sampleRate)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun isDirectPlaybackSupportedApi29(
|
||||||
|
audioFormat: AudioFormat,
|
||||||
|
audioAttributes: android.media.AudioAttributes
|
||||||
|
): Boolean = AudioTrack.isDirectPlaybackSupported(audioFormat, audioAttributes)
|
||||||
|
|
||||||
|
private fun describeDirectPlaybackSupport(support: Int): String {
|
||||||
|
if (support == AudioManager.DIRECT_PLAYBACK_NOT_SUPPORTED) return "none"
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if ((support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0) parts.add("bitstream")
|
||||||
|
if ((support and AudioManager.DIRECT_PLAYBACK_OFFLOAD_SUPPORTED) != 0) parts.add("offload")
|
||||||
|
if ((support and AudioManager.DIRECT_PLAYBACK_OFFLOAD_GAPLESS_SUPPORTED) != 0) parts.add("offload-gapless")
|
||||||
|
if (parts.isEmpty()) parts.add("0x${support.toString(16)}")
|
||||||
|
return parts.joinToString("+")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summarizeAudioOutputRoutes(audioAttributes: androidx.media3.common.AudioAttributes): String = try {
|
||||||
|
val audioManager = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
val routedDevices = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
audioManager.getAudioDevicesForAttributes(audioAttributes.getPlatformAudioAttributes())
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
val devices = if (routedDevices.isNotEmpty()) {
|
||||||
|
routedDevices
|
||||||
|
} else {
|
||||||
|
audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).toList()
|
||||||
|
}
|
||||||
|
val source = if (routedDevices.isNotEmpty()) "routed" else "outputs"
|
||||||
|
if (devices.isEmpty()) {
|
||||||
|
"$source=none"
|
||||||
|
} else {
|
||||||
|
val extra = if (devices.size > 3) ";+${devices.size - 3}" else ""
|
||||||
|
"$source=${devices.take(3).joinToString(";") { describeAudioDevice(it) }}$extra"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"error(${e.javaClass.simpleName}: ${e.message})"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describeAudioDevice(device: AudioDeviceInfo): String {
|
||||||
|
val encodings = device.encodings.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString("|") { audioEncodingName(it) }
|
||||||
|
?: "any/unknown"
|
||||||
|
val channels = device.channelCounts.takeIf { it.isNotEmpty() }
|
||||||
|
?.joinToString("|")
|
||||||
|
?: "unknown"
|
||||||
|
val name = device.productName?.toString()?.take(32) ?: "unknown"
|
||||||
|
return "${audioDeviceTypeName(device.type)}($name,enc=$encodings,ch=$channels)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioEncodingName(encoding: Int): String = when (encoding) {
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT -> "pcm16"
|
||||||
|
AudioFormat.ENCODING_PCM_FLOAT -> "pcm-float"
|
||||||
|
AudioFormat.ENCODING_AC3 -> "ac3"
|
||||||
|
AudioFormat.ENCODING_E_AC3 -> "eac3"
|
||||||
|
AudioFormat.ENCODING_E_AC3_JOC -> "eac3-joc"
|
||||||
|
AudioFormat.ENCODING_DTS -> "dts"
|
||||||
|
AudioFormat.ENCODING_DTS_HD -> "dts-hd"
|
||||||
|
AudioFormat.ENCODING_DOLBY_TRUEHD -> "truehd"
|
||||||
|
else -> encoding.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioDeviceTypeName(type: Int): String = when (type) {
|
||||||
|
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "speaker"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI -> "hdmi"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI_ARC -> "hdmi-arc"
|
||||||
|
AudioDeviceInfo.TYPE_HDMI_EARC -> "hdmi-earc"
|
||||||
|
AudioDeviceInfo.TYPE_LINE_DIGITAL -> "line-digital"
|
||||||
|
AudioDeviceInfo.TYPE_LINE_ANALOG -> "line-analog"
|
||||||
|
AudioDeviceInfo.TYPE_USB_DEVICE -> "usb-device"
|
||||||
|
AudioDeviceInfo.TYPE_USB_HEADSET -> "usb-headset"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "bt-a2dp"
|
||||||
|
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "bt-sco"
|
||||||
|
AudioDeviceInfo.TYPE_UNKNOWN -> "unknown"
|
||||||
|
else -> "type-$type"
|
||||||
|
}
|
||||||
|
|
||||||
private fun isEac3MimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_E_AC3 || mimeType == MimeTypes.AUDIO_E_AC3_JOC
|
private fun isEac3MimeType(mimeType: String): Boolean = mimeType == MimeTypes.AUDIO_E_AC3 || mimeType == MimeTypes.AUDIO_E_AC3_JOC
|
||||||
|
|
||||||
private fun hasHardwareAudioDecoder(mimeType: String): Boolean {
|
private fun hasHardwareAudioDecoder(mimeType: String): Boolean {
|
||||||
@@ -1231,6 +1443,18 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
return mediaItemBuilder.build()
|
return mediaItemBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatAudioSummary(format: Format): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
parts.add("mime=${format.sampleMimeType ?: "unknown"}")
|
||||||
|
parts.add("codec=${format.codecs ?: "unknown"}")
|
||||||
|
parts.add("channels=${format.channelCount}")
|
||||||
|
parts.add("sampleRate=${format.sampleRate}")
|
||||||
|
format.id?.let { parts.add("id=$it") }
|
||||||
|
format.label?.let { parts.add("label=$it") }
|
||||||
|
format.language?.let { parts.add("lang=$it") }
|
||||||
|
return parts.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
// Decoder hang detection via AnalyticsListener:
|
// Decoder hang detection via AnalyticsListener:
|
||||||
// Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame.
|
// Tracks the gap between onVideoDecoderInitialized and onRenderedFirstFrame.
|
||||||
// If the decoder is initialized and fed input but never produces output, it's hung
|
// If the decoder is initialized and fed input but never produces output, it's hung
|
||||||
@@ -1254,6 +1478,75 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
initializationDurationMs: Long
|
initializationDurationMs: Long
|
||||||
) {
|
) {
|
||||||
audioDecoderInitName = decoderName
|
audioDecoderInitName = decoderName
|
||||||
|
emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioInputFormatChanged(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
format: Format,
|
||||||
|
decoderReuseEvaluation: DecoderReuseEvaluation?
|
||||||
|
) {
|
||||||
|
emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}")
|
||||||
|
if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||||
|
updateAudioDecoderPolicy("input format", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioPositionAdvancing(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
playoutStartSystemTimeMs: Long
|
||||||
|
) {
|
||||||
|
emitLog("debug", "audio", "Position advancing: playoutStart=$playoutStartSystemTimeMs")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioUnderrun(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
bufferSize: Int,
|
||||||
|
bufferSizeMs: Long,
|
||||||
|
elapsedSinceLastFeedMs: Long
|
||||||
|
) {
|
||||||
|
emitLog(
|
||||||
|
"warn",
|
||||||
|
"audio",
|
||||||
|
"Underrun: buffer=${bufferSize}B, bufferMs=$bufferSizeMs, elapsedSinceLastFeedMs=$elapsedSinceLastFeedMs"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
|
||||||
|
emitLog("warn", "audio", "Sink error: ${audioSinkError.javaClass.name}: ${audioSinkError.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioCodecError(eventTime: AnalyticsListener.EventTime, audioCodecError: Exception) {
|
||||||
|
emitLog("warn", "audio", "Codec error: ${audioCodecError.javaClass.name}: ${audioCodecError.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioTrackInitialized(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||||
|
) {
|
||||||
|
emitLog(
|
||||||
|
"info",
|
||||||
|
"audio",
|
||||||
|
"AudioTrack initialized: encoding=${audioTrackConfig.encoding}, sampleRate=${audioTrackConfig.sampleRate}, " +
|
||||||
|
"channelConfig=0x${audioTrackConfig.channelConfig.toString(16)}, buffer=${audioTrackConfig.bufferSize}B, " +
|
||||||
|
"tunneling=${audioTrackConfig.tunneling}, offload=${audioTrackConfig.offload}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioTrackReleased(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
audioTrackConfig: AudioSink.AudioTrackConfig
|
||||||
|
) {
|
||||||
|
emitLog(
|
||||||
|
"debug",
|
||||||
|
"audio",
|
||||||
|
"AudioTrack released: encoding=${audioTrackConfig.encoding}, sampleRate=${audioTrackConfig.sampleRate}, " +
|
||||||
|
"channelConfig=0x${audioTrackConfig.channelConfig.toString(16)}, buffer=${audioTrackConfig.bufferSize}B"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onVolumeChanged(eventTime: AnalyticsListener.EventTime, volume: Float) {
|
||||||
|
emitLog("debug", "audio", "Volume changed: $volume")
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRenderedFirstFrame(
|
override fun onRenderedFirstFrame(
|
||||||
@@ -1404,6 +1697,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
|
|
||||||
decoderInitName = null
|
decoderInitName = null
|
||||||
audioDecoderInitName = null
|
audioDecoderInitName = null
|
||||||
|
updateAudioDecoderPolicy("open")
|
||||||
currentMediaUri = uri
|
currentMediaUri = uri
|
||||||
currentHeaders = headers
|
currentHeaders = headers
|
||||||
currentMediaIsLive = isLive
|
currentMediaIsLive = isLive
|
||||||
@@ -1502,7 +1796,6 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
dv7RetryAttempted = override != null
|
dv7RetryAttempted = override != null
|
||||||
activeDoviMkvWrapper = null
|
activeDoviMkvWrapper = null
|
||||||
activeDoviMp4Wrapper = null
|
activeDoviMp4Wrapper = null
|
||||||
|
|
||||||
val debugMode = override?.name ?: "AUTO"
|
val debugMode = override?.name ?: "AUTO"
|
||||||
emitLog("info", "dv-debug", "Debug DV conversion mode set to $debugMode (active=$dvMode)")
|
emitLog("info", "dv-debug", "Debug DV conversion mode set to $debugMode (active=$dvMode)")
|
||||||
reloadCurrentMediaForDvMode()
|
reloadCurrentMediaForDvMode()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
import androidx.media3.common.PlaybackParameters
|
import androidx.media3.common.PlaybackParameters
|
||||||
import androidx.media3.common.util.Clock
|
import androidx.media3.common.util.Clock
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -27,6 +29,14 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
|||||||
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
|
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
|
||||||
val audioDelayUs = AtomicLong(0L)
|
val audioDelayUs = AtomicLong(0L)
|
||||||
|
|
||||||
|
/** Returns whether direct TrueHD output should be hidden so FFmpeg PCM decode can be selected. */
|
||||||
|
var shouldBlockDirectTrueHd: ((Format) -> Boolean)? = null
|
||||||
|
|
||||||
|
/** Called before Media3 reacts to an audio route/capability change. */
|
||||||
|
var onAudioCapabilitiesChanged: (() -> Unit)? = null
|
||||||
|
|
||||||
|
var audioDiagnosticsLogger: ((String, String, String) -> Unit)? = null
|
||||||
|
|
||||||
override fun buildAudioSink(
|
override fun buildAudioSink(
|
||||||
context: Context,
|
context: Context,
|
||||||
enableFloatOutput: Boolean,
|
enableFloatOutput: Boolean,
|
||||||
@@ -51,10 +61,17 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
|||||||
val defaultSink = DefaultAudioSink.Builder(context)
|
val defaultSink = DefaultAudioSink.Builder(context)
|
||||||
.setEnableFloatOutput(enableFloatOutput)
|
.setEnableFloatOutput(enableFloatOutput)
|
||||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||||
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs))
|
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return PositionFixAudioSink(defaultSink, rawPositionUs, audioDelayUs)
|
return PositionFixAudioSink(
|
||||||
|
defaultSink,
|
||||||
|
rawPositionUs,
|
||||||
|
audioDelayUs,
|
||||||
|
shouldBlockDirectTrueHd,
|
||||||
|
onAudioCapabilitiesChanged,
|
||||||
|
audioDiagnosticsLogger
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +100,10 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
|||||||
private class PositionFixAudioSink(
|
private class PositionFixAudioSink(
|
||||||
sink: AudioSink,
|
sink: AudioSink,
|
||||||
private val rawPositionUs: AtomicLong,
|
private val rawPositionUs: AtomicLong,
|
||||||
private val audioDelayUs: AtomicLong
|
private val audioDelayUs: AtomicLong,
|
||||||
|
private val shouldBlockDirectTrueHd: ((Format) -> Boolean)?,
|
||||||
|
private val onAudioCapabilitiesChanged: (() -> Unit)?,
|
||||||
|
private val log: ((String, String, String) -> Unit)?
|
||||||
) : ForwardingAudioSink(sink) {
|
) : ForwardingAudioSink(sink) {
|
||||||
|
|
||||||
private var startMediaTimeUs = Long.MIN_VALUE
|
private var startMediaTimeUs = Long.MIN_VALUE
|
||||||
@@ -97,12 +117,45 @@ private class PositionFixAudioSink(
|
|||||||
// Transient counter-offset: after seek/flush, ramp offset from 0 to full
|
// Transient counter-offset: after seek/flush, ramp offset from 0 to full
|
||||||
// over recoveryDurationUs to prevent video frame drops.
|
// over recoveryDurationUs to prevent video frame drops.
|
||||||
private var recoveryDurationUs = 0L
|
private var recoveryDurationUs = 0L
|
||||||
|
private var loggedTrueHdDirectBlock = false
|
||||||
|
private var loggedFirstBuffer = false
|
||||||
|
|
||||||
|
override fun supportsFormat(format: Format): Boolean {
|
||||||
|
if (blocksDirectTrueHd(format)) return false
|
||||||
|
return super.supportsFormat(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFormatSupport(format: Format): Int {
|
||||||
|
if (blocksDirectTrueHd(format)) return AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||||
|
return super.getFormatSupport(format)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blocksDirectTrueHd(format: Format): Boolean {
|
||||||
|
if (format.sampleMimeType != MimeTypes.AUDIO_TRUEHD || shouldBlockDirectTrueHd?.invoke(format) != true) return false
|
||||||
|
if (!loggedTrueHdDirectBlock) {
|
||||||
|
loggedTrueHdDirectBlock = true
|
||||||
|
log?.invoke(
|
||||||
|
"info",
|
||||||
|
"audio",
|
||||||
|
"Blocking direct TrueHD output; route does not report TrueHD bitstream support, FFmpeg PCM decode will be preferred"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
override fun handleBuffer(
|
override fun handleBuffer(
|
||||||
buffer: ByteBuffer,
|
buffer: ByteBuffer,
|
||||||
presentationTimeUs: Long,
|
presentationTimeUs: Long,
|
||||||
encodedAccessUnitCount: Int
|
encodedAccessUnitCount: Int
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
if (!loggedFirstBuffer && buffer.hasRemaining()) {
|
||||||
|
loggedFirstBuffer = true
|
||||||
|
log?.invoke(
|
||||||
|
"debug",
|
||||||
|
"audio-sink",
|
||||||
|
"First buffer: size=${buffer.remaining()}B, pts=${presentationTimeUs}us, accessUnits=$encodedAccessUnitCount"
|
||||||
|
)
|
||||||
|
}
|
||||||
if (startMediaTimeUs == Long.MIN_VALUE) {
|
if (startMediaTimeUs == Long.MIN_VALUE) {
|
||||||
startMediaTimeUs = presentationTimeUs
|
startMediaTimeUs = presentationTimeUs
|
||||||
refMediaTimeUs = presentationTimeUs
|
refMediaTimeUs = presentationTimeUs
|
||||||
@@ -163,7 +216,10 @@ private class PositionFixAudioSink(
|
|||||||
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
|
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
|
||||||
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
|
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
|
||||||
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
|
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
|
||||||
override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged()
|
override fun onAudioCapabilitiesChanged() {
|
||||||
|
onAudioCapabilitiesChanged?.invoke()
|
||||||
|
listener.onAudioCapabilitiesChanged()
|
||||||
|
}
|
||||||
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
|
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
|
||||||
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
|
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
|
||||||
override fun onSilenceSkipped() = listener.onSilenceSkipped()
|
override fun onSilenceSkipped() = listener.onSilenceSkipped()
|
||||||
@@ -235,7 +291,8 @@ internal class SubtitleDelayRenderer(
|
|||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
private class RawPositionOutputProvider(
|
private class RawPositionOutputProvider(
|
||||||
private val delegate: AudioOutputProvider,
|
private val delegate: AudioOutputProvider,
|
||||||
private val rawPositionUs: AtomicLong
|
private val rawPositionUs: AtomicLong,
|
||||||
|
private val log: ((String, String, String) -> Unit)?
|
||||||
) : AudioOutputProvider {
|
) : AudioOutputProvider {
|
||||||
|
|
||||||
private var cachedOutput: RawPositionAudioOutput? = null
|
private var cachedOutput: RawPositionAudioOutput? = null
|
||||||
@@ -256,7 +313,7 @@ private class RawPositionOutputProvider(
|
|||||||
|
|
||||||
val realOutput = delegate.getAudioOutput(config)
|
val realOutput = delegate.getAudioOutput(config)
|
||||||
cachedConfig = config
|
cachedConfig = config
|
||||||
return RawPositionAudioOutput(realOutput, rawPositionUs, this)
|
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun returnToCache(output: RawPositionAudioOutput) {
|
fun returnToCache(output: RawPositionAudioOutput) {
|
||||||
@@ -285,9 +342,14 @@ private class RawPositionOutputProvider(
|
|||||||
private class RawPositionAudioOutput(
|
private class RawPositionAudioOutput(
|
||||||
private val delegate: AudioOutput,
|
private val delegate: AudioOutput,
|
||||||
private val rawPositionUs: AtomicLong,
|
private val rawPositionUs: AtomicLong,
|
||||||
private val provider: RawPositionOutputProvider
|
private val provider: RawPositionOutputProvider,
|
||||||
|
private val log: ((String, String, String) -> Unit)?
|
||||||
) : AudioOutput {
|
) : AudioOutput {
|
||||||
|
|
||||||
|
private var loggedFirstWrite = false
|
||||||
|
private var writeCount = 0L
|
||||||
|
private var writtenBytes = 0L
|
||||||
|
|
||||||
override fun getPositionUs(): Long {
|
override fun getPositionUs(): Long {
|
||||||
val pos = delegate.getPositionUs()
|
val pos = delegate.getPositionUs()
|
||||||
rawPositionUs.set(pos)
|
rawPositionUs.set(pos)
|
||||||
@@ -298,10 +360,30 @@ private class RawPositionAudioOutput(
|
|||||||
override fun pause() = delegate.pause()
|
override fun pause() = delegate.pause()
|
||||||
|
|
||||||
@Throws(AudioOutput.WriteException::class)
|
@Throws(AudioOutput.WriteException::class)
|
||||||
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) = delegate.write(buffer, size, presentationTimeUs)
|
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long): Boolean {
|
||||||
|
val before = buffer.position()
|
||||||
|
val handled = delegate.write(buffer, size, presentationTimeUs)
|
||||||
|
val written = buffer.position() - before
|
||||||
|
if (written > 0) {
|
||||||
|
writeCount++
|
||||||
|
writtenBytes += written.toLong()
|
||||||
|
if (!loggedFirstWrite) {
|
||||||
|
loggedFirstWrite = true
|
||||||
|
log?.invoke(
|
||||||
|
"debug",
|
||||||
|
"audio-output",
|
||||||
|
"First write: wrote=${written}B, handled=$handled, pts=${presentationTimeUs}us, writes=$writeCount, total=${writtenBytes}B"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
override fun flush() {
|
override fun flush() {
|
||||||
rawPositionUs.set(Long.MIN_VALUE)
|
rawPositionUs.set(Long.MIN_VALUE)
|
||||||
|
loggedFirstWrite = false
|
||||||
|
writeCount = 0
|
||||||
|
writtenBytes = 0
|
||||||
delegate.flush()
|
delegate.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user