Fix TV audio sink capability handling

This commit is contained in:
2026-04-07 16:52:44 +02:00
parent 356816ba0e
commit 4b0e02e017
4 changed files with 91 additions and 6 deletions

View File

@@ -1,7 +1,10 @@
package hu.bbara.purefin.core.data.client package hu.bbara.purefin.core.data.client
import android.content.Context import android.content.Context
import android.media.AudioAttributes as PlatformAudioAttributes
import android.media.AudioFormat
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodecList import android.media.MediaCodecList
import android.util.Log import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
@@ -20,7 +23,9 @@ import org.jellyfin.sdk.model.api.TranscodingProfile
object AndroidDeviceProfile { object AndroidDeviceProfile {
private const val TAG = "AndroidDeviceProfile" private const val TAG = "AndroidDeviceProfile"
private const val DEFAULT_MAX_AUDIO_CHANNELS = 8 private const val DEFAULT_MAX_AUDIO_CHANNELS = 2
private const val PROBE_SAMPLE_RATE = 48_000
private const val PROBE_ENCODING = AudioFormat.ENCODING_PCM_16BIT
@Volatile @Volatile
private var cachedSnapshot: CapabilitySnapshot? = null private var cachedSnapshot: CapabilitySnapshot? = null
@@ -214,7 +219,7 @@ object AndroidDeviceProfile {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching { return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val maxDeviceChannels = devices val reportedMaxChannels = devices
.flatMap { device -> .flatMap { device ->
buildList { buildList {
addAll(device.channelCounts.toList()) addAll(device.channelCounts.toList())
@@ -223,15 +228,67 @@ object AndroidDeviceProfile {
} }
} }
.maxOrNull() .maxOrNull()
maxDeviceChannels
?.takeIf { it > 0 } ?.takeIf { it > 0 }
?: DEFAULT_MAX_AUDIO_CHANNELS ?: DEFAULT_MAX_AUDIO_CHANNELS
val verifiedMaxChannels = verifyOutputChannelSupport(reportedMaxChannels)
Log.d(TAG, "Reported max audio channels: $reportedMaxChannels, verified: $verifiedMaxChannels")
verifiedMaxChannels
}.getOrElse { }.getOrElse {
DEFAULT_MAX_AUDIO_CHANNELS DEFAULT_MAX_AUDIO_CHANNELS
} }
} }
private fun verifyOutputChannelSupport(reportedMaxChannels: Int): Int {
val candidates = buildList {
if (reportedMaxChannels >= 8) add(8)
if (reportedMaxChannels >= 6) add(6)
add(2)
}.distinct()
return candidates.firstOrNull(::canCreateAudioTrackForChannelCount) ?: DEFAULT_MAX_AUDIO_CHANNELS
}
private fun canCreateAudioTrackForChannelCount(channelCount: Int): Boolean {
val channelMask = when (channelCount) {
8 -> AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
6 -> AudioFormat.CHANNEL_OUT_5POINT1
2 -> AudioFormat.CHANNEL_OUT_STEREO
else -> return false
}
val minBufferSize = AudioTrack.getMinBufferSize(PROBE_SAMPLE_RATE, channelMask, PROBE_ENCODING)
if (minBufferSize <= 0) {
return false
}
return runCatching {
val audioTrack = AudioTrack.Builder()
.setAudioAttributes(
PlatformAudioAttributes.Builder()
.setUsage(PlatformAudioAttributes.USAGE_MEDIA)
.setContentType(PlatformAudioAttributes.CONTENT_TYPE_MOVIE)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(PROBE_SAMPLE_RATE)
.setEncoding(PROBE_ENCODING)
.setChannelMask(channelMask)
.build()
)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(minBufferSize * 2)
.build()
try {
audioTrack.state == AudioTrack.STATE_INITIALIZED
} finally {
audioTrack.release()
}
}.getOrElse { false }
}
private fun channelMaskToCount(mask: Int): Int { private fun channelMaskToCount(mask: Int): Int {
return if (mask == 0) { return if (mask == 0) {
0 0

View File

@@ -18,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped import dagger.hilt.android.scopes.ViewModelScoped
import hu.bbara.purefin.core.data.client.AndroidDeviceProfile
@Module @Module
@InstallIn(ViewModelComponent::class) @InstallIn(ViewModelComponent::class)
@@ -27,6 +28,7 @@ object VideoPlayerModule {
@Provides @Provides
@ViewModelScoped @ViewModelScoped
fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player { fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player {
val capabilitySnapshot = AndroidDeviceProfile.getSnapshot(application)
val trackSelector = DefaultTrackSelector(application) val trackSelector = DefaultTrackSelector(application)
val audioAttributes = val audioAttributes =
AudioAttributes.Builder() AudioAttributes.Builder()
@@ -37,7 +39,9 @@ object VideoPlayerModule {
trackSelector.setParameters( trackSelector.setParameters(
trackSelector trackSelector
.buildUponParameters() .buildUponParameters()
.setTunnelingEnabled(true) .setMaxAudioChannelCount(capabilitySnapshot.maxAudioChannels)
.setExceedAudioConstraintsIfNecessary(false)
.setTunnelingEnabled(false)
// .setPreferredAudioLanguage( // .setPreferredAudioLanguage(
// appPreferences.getValue(appPreferences.preferredAudioLanguage) // appPreferences.getValue(appPreferences.preferredAudioLanguage)
// ) // )

View File

@@ -10,6 +10,10 @@ internal object PlaybackRetryPolicy {
private val retryableErrorCodes = setOf( private val retryableErrorCodes = setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED,
PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED
) )
@@ -45,6 +49,10 @@ internal object PlaybackRetryPolicy {
} }
}.lowercase() }.lowercase()
return "decoder" in detail || "codec" in detail || "unsupported" in detail return "decoder" in detail ||
"codec" in detail ||
"unsupported" in detail ||
"audiotrack" in detail ||
"audio sink" in detail
} }
} }

View File

@@ -63,6 +63,22 @@ class PlaybackRetryPolicyTest {
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext)) assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
} }
@Test
fun `audio track init failures are retried when transcoding is available`() {
val error = PlayerError(
summary = "Playback error",
technicalDetail = "AudioTrack init failed",
source = PlayerErrorSource.PLAYBACK,
errorCode = PlaybackException.ERROR_CODE_AUDIO_TRACK_INIT_FAILED,
errorCodeName = "ERROR_CODE_AUDIO_TRACK_INIT_FAILED",
retryable = true
)
val playbackReportContext = playbackReportContext(playMethod = PlayMethod.DIRECT_PLAY)
assertTrue(PlaybackRetryPolicy.shouldRetryWithTranscoding(error, playbackReportContext))
}
private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext { private fun playbackReportContext(playMethod: PlayMethod): PlaybackReportContext {
return PlaybackReportContext( return PlaybackReportContext(
playMethod = playMethod, playMethod = playMethod,