diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index ecaa0fac9..6b6fd22db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -391,7 +391,10 @@ sealed interface AppPreference { defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { ac3Supported = value } + prefs.updatePlaybackOverrides { + ac3Supported = value + if (!value) spdifArcSurroundAudio = false + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -402,7 +405,10 @@ sealed interface AppPreference { defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { downmixStereo = value } + prefs.updatePlaybackOverrides { + downmixStereo = value + if (value) spdifArcSurroundAudio = false + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -455,6 +461,28 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val SpdifArcSurroundAudio = + AppSwitchPreference( + title = R.string.spdif_arc_surround_audio, + defaultValue = false, + getter = { it.playbackPreferences.overrides.spdifArcSurroundAudio }, + setter = { prefs, value -> + if (value) { + prefs.updatePlaybackOverrides { + spdifArcSurroundAudio = true + ac3Supported = true + downmixStereo = false + } + } else { + prefs.updatePlaybackOverrides { + spdifArcSurroundAudio = false + } + } + }, + summaryOn = R.string.spdif_arc_surround_audio_summary_on, + summaryOff = R.string.spdif_arc_surround_audio_summary_off, + ) + val CinemaMode = AppSwitchPreference( title = R.string.cinema_mode, @@ -1108,6 +1136,7 @@ private val ExoPlayerSettings = AppPreference.FfmpegPreference, AppPreference.DownMixStereo, AppPreference.Ac3Supported, + AppPreference.SpdifArcSurroundAudio, AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 47dae5ec1..51b843b22 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -68,6 +68,8 @@ class AppPreferencesSerializer AppPreference.FfmpegPreference.defaultValue assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + spdifArcSurroundAudio = + AppPreference.SpdifArcSurroundAudio.defaultValue }.build() mpvOptions = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 4405092ee..9d6c315f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -50,6 +50,7 @@ class DeviceProfileService decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), + spdifArcSurroundAudio = prefs.overrides.spdifArcSurroundAudio, ) if (deviceProfile == null || this@DeviceProfileService.configuration != newConfig) { this@DeviceProfileService.configuration = newConfig @@ -64,6 +65,7 @@ class DeviceProfileService dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = newConfig.jellyfinTenEleven, + spdifArcSurroundAudio = newConfig.spdifArcSurroundAudio, ) } this@DeviceProfileService.deviceProfile!! @@ -83,4 +85,5 @@ data class DeviceProfileConfiguration( val dolbyVisionELDirectPlay: Boolean, val decodeAv1: Boolean, val jellyfinTenEleven: Boolean, + val spdifArcSurroundAudio: Boolean = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt index 6eddb51c4..020f629d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MusicService.kt @@ -24,6 +24,7 @@ import com.github.damontecres.wholphin.util.BlockingList import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.PlaybackItemState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener +import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope @@ -44,6 +45,7 @@ import org.jellyfin.sdk.api.client.extensions.universalAudioApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage @@ -72,6 +74,7 @@ class MusicService private val playerFactory: PlayerFactory, private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, + private val userPreferencesService: UserPreferencesService, ) { private val _state = MutableStateFlow(MusicServiceState.EMPTY) val state: StateFlow = _state @@ -198,7 +201,7 @@ class MusicService val mediaItems = items .filter { it.type == BaseItemKind.AUDIO } - .map(::convert) + .map { convert(it) } withContext(Dispatchers.Main) { player.setMediaItems(mediaItems) player.shuffleModeEnabled = shuffled @@ -250,7 +253,7 @@ class MusicService list .getBlocking(it) ?.takeIf { it.type == BaseItemKind.AUDIO } - ?.let(::convert) + ?.let { convert(it) } } else { Timber.v("Skipping $remaining") remaining-- @@ -266,12 +269,39 @@ class MusicService /** * Converts a [BaseItem] into a [MediaItem] setting an [AudioItem] as its tag */ - private fun convert(audio: BaseItem): MediaItem { + private suspend fun convert(audio: BaseItem): MediaItem { + val spdifMode = + userPreferencesService + .getCurrent() + .appPreferences.playbackPreferences.overrides.spdifArcSurroundAudio + + val needsAc3Transcode = + spdifMode && + audio.data.mediaSources + ?.firstOrNull() + ?.mediaStreams + ?.any { stream -> + stream.type == MediaStreamType.AUDIO && + (stream.channels ?: 0) > 2 && + stream.codec != Codec.Audio.AC3 + } ?: false + val url = - api.universalAudioApi.getUniversalAudioStreamUrl( - itemId = audio.id, - container = audioFormats, - ) + if (needsAc3Transcode) { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = listOf("mka"), + transcodingContainer = "mka", + maxAudioChannels = 6, + transcodingAudioChannels = 6, + audioCodec = Codec.Audio.AC3, + ) + } else { + api.universalAudioApi.getUniversalAudioStreamUrl( + itemId = audio.id, + container = audioFormats, + ) + } Timber.i("url=%s", url) val imageUrl = audio.data.albumId?.let { albumId -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4a73afd9e..99f38406a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.formatBitrate +import com.github.damontecres.wholphin.ui.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -653,7 +654,8 @@ class PlaybackViewModel } else { mpvDeviceProfile }, - maxAudioChannels = null, + maxAudioChannels = + if (preferences.appPreferences.playbackPreferences.overrides.spdifArcSurroundAudio) 6 else null, audioStreamIndex = audioIndex, subtitleStreamIndex = subtitleIndex, mediaSourceId = currentItemPlayback.sourceId?.toServerString(), @@ -844,12 +846,24 @@ class PlaybackViewModel userInitiated: Boolean, ): Boolean = withContext(Dispatchers.IO) { - // TODO there's probably no reason why we can't add external subtitles? Timber.v("changeStreams direct play") + // Don't attempt DirectPlay if audio transcoding is required + if (preferences.appPreferences.playbackPreferences.overrides.spdifArcSurroundAudio && audioIndex != null) { + currentPlayback.mediaSourceInfo.mediaStreams + .orEmpty() + .firstOrNull { it.index == audioIndex } + ?.let { + if (it.channels.gt(2) && it.codec != Codec.Audio.AC3) { + return@withContext false + } + } + } + val source = currentPlayback.mediaSourceInfo val externalSubtitle = source.findExternalSubtitle(subtitleIndex) + // TODO there's probably no reason why we can't add external subtitles? if (externalSubtitle == null) { val result = withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index 2de5310ea..da6bd0b70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -71,6 +71,7 @@ fun createDeviceProfile( dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, + spdifArcSurroundAudio: Boolean = false, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -142,7 +143,11 @@ fun createDeviceProfile( if (supportsHevc) videoCodec(Codec.Video.HEVC) videoCodec(Codec.Video.H264) - audioCodec(*allowedAudioCodecs) + if (spdifArcSurroundAudio) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(*allowedAudioCodecs) + } copyTimestamps = false enableSubtitlesInManifest = true @@ -156,7 +161,11 @@ fun createDeviceProfile( container = Codec.Container.TS protocol = MediaStreamProtocol.HLS - audioCodec(Codec.Audio.AAC) + if (spdifArcSurroundAudio) { + audioCodec(Codec.Audio.AC3) + } else { + audioCodec(Codec.Audio.AAC) + } } // / Direct play profiles @@ -415,6 +424,29 @@ fun createDeviceProfile( } } + // SPDIF/ARC mode: restrict non-AC3 multichannel audio to stereo + // to force server to transcode only multichannel to AC3 + if (spdifArcSurroundAudio) { + supportedAudioCodecs + .filterNot { it == Codec.Audio.AC3 } + .forEach { audioCodec -> + codecProfile { + type = CodecType.VIDEO_AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + codecProfile { + type = CodecType.AUDIO + codec = audioCodec + conditions { + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals 2 + } + } + } + } + // / HDR exclude list // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK @@ -522,7 +554,12 @@ fun createDeviceProfile( type = CodecType.VIDEO_AUDIO conditions { - ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals if (downMixAudio) 2 else 8 + ProfileConditionValue.AUDIO_CHANNELS lowerThanOrEquals + when { + spdifArcSurroundAudio -> 6 + downMixAudio -> 2 + else -> 8 + } } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index a29c59acd..2fd0e7ce4 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -57,6 +57,7 @@ message PlaybackOverrides{ bool direct_play_dolby_vision_e_l = 6; bool decode_av1 = 7; AssPlaybackMode ass_playback_mode = 8; + bool spdif_arc_surround_audio = 9; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15ab01823..ec78ee4be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -788,4 +788,7 @@ + SPDIF/ARC Surround Audio + Force all audio to AC3 5.1 for SPDIF/ARC output + Use default audio settings diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt new file mode 100644 index 000000000..e7ed6193f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestAudioSettings.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile +import com.github.damontecres.wholphin.util.profile.supportedAudioCodecs +import org.junit.Assert +import org.junit.Test + +class TestAudioSettings { + @Test + fun `spdif enabling sets ac3 and disables downmix`() { + val prefs = AppPreferences.getDefaultInstance() + + val result = AppPreference.SpdifArcSurroundAudio.setter.invoke(prefs, true) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertTrue("Spdif should be ON", spdifValue) + Assert.assertTrue("AC3 should be auto-enabled", ac3Value) + Assert.assertFalse("Downmix should be auto-disabled", downmixValue) + } + + @Test + fun `spdif disabling only affects spdif`() { + val basePrefs = + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ) + + val result = AppPreference.SpdifArcSurroundAudio.setter.invoke(basePrefs, false) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should be OFF", spdifValue) + Assert.assertTrue("AC3 should remain enabled", ac3Value) + Assert.assertFalse("Downmix should remain disabled", downmixValue) + } + + @Test + fun `ac3 disabling auto-disables spdif`() { + val basePrefs = + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ) + + val result = AppPreference.Ac3Supported.setter.invoke(basePrefs, false) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + + Assert.assertFalse("Spdif should auto-disable when AC3 is turned off", spdifValue) + Assert.assertFalse("AC3 should be OFF", ac3Value) + } + + @Test + fun `downmix enabling auto-disables spdif`() { + val basePrefs = + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ) + + val result = AppPreference.DownMixStereo.setter.invoke(basePrefs, true) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should auto-disable when downmix is turned on", spdifValue) + Assert.assertTrue("Downmix should be ON", downmixValue) + } + + @Test + fun `enabling ac3 does not alter spdif or downmix`() { + val basePrefs = + AppPreference.Ac3Supported.setter.invoke( + AppPreferences.getDefaultInstance(), + false, + ) + + val result = AppPreference.Ac3Supported.setter.invoke(basePrefs, true) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should remain OFF", spdifValue) + Assert.assertTrue("AC3 should be ON", ac3Value) + Assert.assertFalse("Downmix should remain OFF", downmixValue) + } + + @Test + fun `disabling downmix does not alter spdif or ac3`() { + val basePrefs = + AppPreference.DownMixStereo.setter.invoke( + AppPreference.SpdifArcSurroundAudio.setter.invoke( + AppPreferences.getDefaultInstance(), + true, + ), + true, + ) + + val spdifValueInitial = AppPreference.SpdifArcSurroundAudio.getter.invoke(basePrefs) + Assert.assertFalse("Spdif should turn OFF", spdifValueInitial) + + val result = AppPreference.DownMixStereo.setter.invoke(basePrefs, false) + + val spdifValue = AppPreference.SpdifArcSurroundAudio.getter.invoke(result) + val ac3Value = AppPreference.Ac3Supported.getter.invoke(result) + val downmixValue = AppPreference.DownMixStereo.getter.invoke(result) + + Assert.assertFalse("Spdif should remain OFF", spdifValue) + Assert.assertTrue("AC3 should remain ON", ac3Value) + Assert.assertFalse("Downmix should be OFF", downmixValue) + } +}