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
import android.content.Context
import android.media.AudioAttributes as PlatformAudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.media.MediaCodecList
import android.util.Log
import org.jellyfin.sdk.model.api.DeviceProfile
@@ -20,7 +23,9 @@ import org.jellyfin.sdk.model.api.TranscodingProfile
object 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
private var cachedSnapshot: CapabilitySnapshot? = null
@@ -214,7 +219,7 @@ object AndroidDeviceProfile {
val audioManager = context.getSystemService(AudioManager::class.java) ?: return DEFAULT_MAX_AUDIO_CHANNELS
return runCatching {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val maxDeviceChannels = devices
val reportedMaxChannels = devices
.flatMap { device ->
buildList {
addAll(device.channelCounts.toList())
@@ -223,15 +228,67 @@ object AndroidDeviceProfile {
}
}
.maxOrNull()
maxDeviceChannels
?.takeIf { it > 0 }
?: DEFAULT_MAX_AUDIO_CHANNELS
val verifiedMaxChannels = verifyOutputChannelSupport(reportedMaxChannels)
Log.d(TAG, "Reported max audio channels: $reportedMaxChannels, verified: $verifiedMaxChannels")
verifiedMaxChannels
}.getOrElse {
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 {
return if (mask == 0) {
0

View File

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

View File

@@ -10,6 +10,10 @@ internal object PlaybackRetryPolicy {
private val retryableErrorCodes = setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_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_UNSUPPORTED
)
@@ -45,6 +49,10 @@ internal object PlaybackRetryPolicy {
}
}.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))
}
@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 {
return PlaybackReportContext(
playMethod = playMethod,