diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt index 8daeccdc..c07ee215 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/AndroidDeviceProfile.kt @@ -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 diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt index 93867014..18eb29c3 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt @@ -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) // ) diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt index 38126290..11bb23aa 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicy.kt @@ -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 } } diff --git a/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt index 15253547..ef271bd3 100644 --- a/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt +++ b/core/player/src/test/java/hu/bbara/purefin/core/player/viewmodel/PlaybackRetryPolicyTest.kt @@ -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,