From eecdea92e095e1ff8128c2c83f62aa298e21f685 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 21 Feb 2026 14:29:33 -0500 Subject: [PATCH 1/3] Option to transcode surround sound audio to AC3 --- .../wholphin/preferences/AppPreference.kt | 48 +++++++++++++++++-- .../wholphin/services/DeviceProfileService.kt | 3 ++ .../ui/preferences/PreferencesContent.kt | 2 +- .../subtitle/SubtitlePreferencesContent.kt | 2 +- .../util/profile/DeviceProfileUtils.kt | 45 +++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + 7 files changed, 97 insertions(+), 6 deletions(-) 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 709abd6c5..84e91c957 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 @@ -52,7 +52,10 @@ sealed interface AppPreference { value: T?, ): String? = null - fun validate(value: T): PreferenceValidation = PreferenceValidation.Valid + fun validate( + prefs: Pref, + value: T, + ): PreferenceValidation = PreferenceValidation.Valid companion object { val SkipForward = @@ -414,18 +417,49 @@ sealed interface AppPreference { defaultValue = true, getter = { it.playbackPreferences.overrides.ac3Supported }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { ac3Supported = value } + prefs.updatePlaybackOverrides { + ac3Supported = value + if (!value) preferAc3Surround = false + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + val PreferAc3ForSurround = + AppSwitchPreference( + title = R.string.prefer_ac3_for_surround, + defaultValue = true, + getter = { it.playbackPreferences.overrides.preferAc3Surround }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { + preferAc3Surround = value + } + }, + summaryOn = R.string.prefer_ac3_for_surround_summary, +// summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + validator = { prefs, value -> + prefs.playbackPreferences.overrides.let { + if (value && !it.ac3Supported) { + PreferenceValidation.Invalid("AC3 support is not enabled") + } else if (value && it.downmixStereo) { + PreferenceValidation.Invalid("Always downmixing to stereo") + } else { + PreferenceValidation.Valid + } + } + }, + ) val DownMixStereo = AppSwitchPreference( title = R.string.downmix_stereo, defaultValue = false, getter = { it.playbackPreferences.overrides.downmixStereo }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { downmixStereo = value } + prefs.updatePlaybackOverrides { + downmixStereo = value + if (value) preferAc3Surround = false + } }, summaryOn = R.string.enabled, summaryOff = R.string.disabled, @@ -1066,6 +1100,7 @@ private val ExoPlayerSettings = AppPreference.FfmpegPreference, AppPreference.DownMixStereo, AppPreference.Ac3Supported, + AppPreference.PreferAc3ForSurround, AppPreference.DirectPlayAss, AppPreference.DirectPlayPgs, AppPreference.DirectPlayDoviProfile7, @@ -1206,11 +1241,16 @@ data class AppSwitchPreference( override val defaultValue: Boolean, override val getter: (prefs: Pref) -> Boolean, override val setter: (prefs: Pref, value: Boolean) -> Pref, - val validator: (value: Boolean) -> PreferenceValidation = { PreferenceValidation.Valid }, + val validator: (prefs: Pref, value: Boolean) -> PreferenceValidation = { _, _ -> PreferenceValidation.Valid }, @param:StringRes val summary: Int? = null, @param:StringRes val summaryOn: Int? = null, @param:StringRes val summaryOff: Int? = null, ) : AppPreference { + override fun validate( + prefs: Pref, + value: Boolean, + ): PreferenceValidation = validator.invoke(prefs, value) + override fun summary( context: Context, value: Boolean?, 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 976fb2c83..dd0371596 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 @@ -44,6 +44,7 @@ class DeviceProfileService pgsDirectPlay = prefs.overrides.directPlayPgs, dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, + preferAc3ForSurround = prefs.overrides.preferAc3Surround, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -59,6 +60,7 @@ class DeviceProfileService pgsDirectPlay = newConfig.pgsDirectPlay, dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, decodeAv1 = prefs.overrides.decodeAv1, + preferAc3ForSurround = prefs.overrides.preferAc3Surround, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -78,5 +80,6 @@ data class DeviceProfileConfiguration( val pgsDirectPlay: Boolean, val dolbyVisionELDirectPlay: Boolean, val decodeAv1: Boolean, + val preferAc3ForSurround: Boolean, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index b04293f83..47d7fbe25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -441,7 +441,7 @@ fun PreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt index 8abc6dbef..4070476c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -153,7 +153,7 @@ fun SubtitlePreferencesContent( value = value, onNavigate = viewModel.navigationManager::navigateTo, onValueChange = { newValue -> - val validation = pref.validate(newValue) + val validation = pref.validate(preferences, newValue) when (validation) { is PreferenceValidation.Invalid -> { // TODO? 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 6272af5ad..0fe160cfa 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, + preferAc3ForSurround: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -132,6 +133,23 @@ fun createDeviceProfile( // / Transcoding profiles // Video + if (preferAc3ForSurround) { + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING + + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + + if (supportsHevc) videoCodec(Codec.Video.HEVC) + videoCodec(Codec.Video.H264) + + audioCodec(Codec.Audio.AC3) + + copyTimestamps = false + enableSubtitlesInManifest = true + } + } transcodingProfile { type = DlnaProfileType.VIDEO context = EncodingContext.STREAMING @@ -516,6 +534,33 @@ fun createDeviceProfile( } } + if (preferAc3ForSurround) { + codecProfile { + type = CodecType.VIDEO_AUDIO + codec = Codec.Audio.AAC + + conditions { + ProfileConditionValue.AUDIO_PROFILE equals "none" + } + + applyConditions { + ProfileConditionValue.AUDIO_CHANNELS greaterThanOrEquals 3 + } + } + codecProfile { + type = CodecType.VIDEO_AUDIO + codec = Codec.Audio.OPUS + + conditions { + ProfileConditionValue.AUDIO_PROFILE equals "none" + } + + applyConditions { + ProfileConditionValue.AUDIO_CHANNELS greaterThanOrEquals 3 + } + } + } + // Audio channel profile codecProfile { type = CodecType.VIDEO_AUDIO diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 737dbb576..134b23414 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -48,6 +48,7 @@ message PlaybackOverrides{ MediaExtensionStatus media_extensions_enabled = 5; bool direct_play_dolby_vision_e_l = 6; bool decode_av1 = 7; + bool prefer_ac3_surround = 8; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f3db4f5..d966e472b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,6 +522,8 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + + Use AC3 for surround sound audio Disabled From 7f04d47ad486e7896dca1d00b0d8867dda2fe1b9 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 21 Feb 2026 17:50:24 -0500 Subject: [PATCH 2/3] Add detected audio device info to debug page --- .../wholphin/ui/detail/DebugPage.kt | 186 +++++++++++++++++- 1 file changed, 177 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index cfae65bac..10425c964 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -2,6 +2,10 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context import android.hardware.display.DisplayManager +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioFormat +import android.media.AudioManager import android.os.Build import android.util.Log import android.view.Display @@ -15,8 +19,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -39,11 +45,15 @@ import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.acra.util.versionCodeLong @@ -60,6 +70,7 @@ import javax.inject.Inject class DebugViewModel @Inject constructor( + @param:ApplicationContext private val context: Context, val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, val clientInfo: ClientInfo, @@ -67,6 +78,7 @@ class DebugViewModel ) : ViewModel() { val itemPlaybacks = MutableLiveData>(listOf()) val logcat = MutableLiveData>(listOf()) + val audioInfo = MutableStateFlow(AudioInfo()) val supportedModes by lazy { val displayManager = @@ -88,6 +100,35 @@ class DebugViewModel this@DebugViewModel.logcat.value = logcat } } + viewModelScope.launchDefault { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val callback = + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array) { + audioInfo.update { + it.copy( + devices = + it.devices + .toMutableList() + .apply { addAll(addedDevices.filter { it.isSink }) }, + ) + } + } + + override fun onAudioDevicesRemoved(removedDevices: Array) { + audioInfo.update { + it.copy( + devices = + it.devices + .toMutableList() + .apply { removeAll(removedDevices) }, + ) + } + } + } + audioManager.registerAudioDeviceCallback(callback, null) + addCloseable { audioManager.unregisterAudioDeviceCallback(callback) } + } } companion object { @@ -164,6 +205,127 @@ data class LogcatLine( val text: String, ) +data class AudioInfo( + val devices: List = emptyList(), +) + +val AudioDeviceInfo.details: String + get() { + val typeName = + when (type) { + AudioDeviceInfo.TYPE_HDMI -> "HDMI" + AudioDeviceInfo.TYPE_HDMI_ARC -> "ARC" + AudioDeviceInfo.TYPE_HDMI_EARC -> "eARC" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker" + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE -> "Speaker Safe" + else -> "N/A" + } + val addressStr = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + address + } else { + null + } + val encodings = + encodings.map { enc -> + when (enc) { + AudioFormat.ENCODING_INVALID -> "INVALID" + + AudioFormat.ENCODING_PCM_16BIT -> "PCM_16BIT" + + AudioFormat.ENCODING_PCM_8BIT -> "PCM_8BIT" + + AudioFormat.ENCODING_PCM_FLOAT -> "PCM_FLOAT" + + AudioFormat.ENCODING_AC3 -> "AC3" + + AudioFormat.ENCODING_E_AC3 -> "E_AC3" + + AudioFormat.ENCODING_DTS -> "DTS" + + AudioFormat.ENCODING_DTS_HD -> "DTS_HD" + + AudioFormat.ENCODING_MP3 -> "MP3" + + AudioFormat.ENCODING_AAC_LC -> "AAC_LC" + + AudioFormat.ENCODING_AAC_HE_V1 -> "AAC_HE_V1" + + AudioFormat.ENCODING_AAC_HE_V2 -> "AAC_HE_V2" + + AudioFormat.ENCODING_IEC61937 -> "IEC61937" + + AudioFormat.ENCODING_DOLBY_TRUEHD -> "DOLBY_TRUEHD" + + AudioFormat.ENCODING_AAC_ELD -> "AAC_ELD" + + AudioFormat.ENCODING_AAC_XHE -> "AAC_XHE" + + AudioFormat.ENCODING_AC4 -> "AC4" + + // AudioFormat.ENCODING_AC4_L4->"AC4_L4" + + AudioFormat.ENCODING_E_AC3_JOC -> "E_AC3_JOC" + + AudioFormat.ENCODING_DOLBY_MAT -> "DOLBY_MAT" + + AudioFormat.ENCODING_OPUS -> "OPUS" + + AudioFormat.ENCODING_PCM_24BIT_PACKED -> "PCM_24BIT_PACKED" + + AudioFormat.ENCODING_PCM_32BIT -> "PCM_32BIT" + + AudioFormat.ENCODING_MPEGH_BL_L3 -> "MPEGH_BL_L3" + + AudioFormat.ENCODING_MPEGH_BL_L4 -> "MPEGH_BL_L4" + + AudioFormat.ENCODING_MPEGH_LC_L3 -> "MPEGH_LC_L3" + + AudioFormat.ENCODING_MPEGH_LC_L4 -> "MPEGH_LC_L4" + + AudioFormat.ENCODING_DTS_UHD_P1 -> "DTS_UHD_P1" + + AudioFormat.ENCODING_DRA -> "DRA" + + AudioFormat.ENCODING_DTS_HD_MA -> "DTS_HD_MA" + + AudioFormat.ENCODING_DTS_UHD_P2 -> "DTS_UHD_P2" + + AudioFormat.ENCODING_DSD -> "DSD" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_AAC -> "IAMF_BASE_ENHANCED_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_FLAC -> "IAMF_BASE_ENHANCED_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_OPUS -> "IAMF_BASE_ENHANCED_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_ENHANCED_PROFILE_PCM -> "IAMF_BASE_ENHANCED_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_AAC -> "IAMF_BASE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_FLAC -> "IAMF_BASE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_OPUS -> "IAMF_BASE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_BASE_PROFILE_PCM -> "IAMF_BASE_PROFILE_PCM" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_AAC -> "IAMF_SIMPLE_PROFILE_AAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_FLAC -> "IAMF_SIMPLE_PROFILE_FLAC" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_OPUS -> "IAMF_SIMPLE_PROFILE_OPUS" + + AudioFormat.ENCODING_IAMF_SIMPLE_PROFILE_PCM -> "IAMF_SIMPLE_PROFILE_PCM" + + else -> "invalid encoding $enc" + } + } + return "AudioDeviceInfo(id=$id, type=$type ($typeName), " + + "channelCounts=${channelCounts.contentToString()}, " + + "encodings=$encodings, " + + "productName=$productName, address=$addressStr)" + } + @Composable fun DebugPage( preferences: UserPreferences, @@ -261,15 +423,21 @@ fun DebugPage( style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurface, ) - - listOf( - "DeviceInfo: ${viewModel.deviceInfo}", - "Manufacturer: ${Build.MANUFACTURER}", - "Model: ${Build.MODEL}", - "API Level: ${Build.VERSION.SDK_INT}", - "Display Modes:", - *viewModel.supportedModes, - ).forEach { + val details = + listOf( + "DeviceInfo: ${viewModel.deviceInfo}", + "Manufacturer: ${Build.MANUFACTURER}", + "Model: ${Build.MODEL}", + "API Level: ${Build.VERSION.SDK_INT}", + "", + "Display Modes:", + *viewModel.supportedModes, + "", + "Audio Devices:", + ) + val audioInfo by viewModel.audioInfo.collectAsState() + val audio = remember(audioInfo) { audioInfo.devices.map { it.details } } + (details + audio).forEach { Text( text = it.toString(), style = MaterialTheme.typography.bodySmall, From 5d6b8ff1fe9af7477cd3cc931a27061d1840eaa1 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 17 Mar 2026 13:36:26 -0400 Subject: [PATCH 3/3] Workaround for switching direct play audio tracks --- .../wholphin/ui/playback/PlaybackViewModel.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 d3be43247..942108f89 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 @@ -50,6 +50,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService 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.gt import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -794,12 +795,26 @@ 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") + // TODO Better way to handle unsupported types in general is needed + // This is a workaround for switching to a non AC3 track when the user wants audio transcoded to AC3 + if (preferences.appPreferences.playbackPreferences.overrides.preferAc3Surround && audioIndex != null) { + currentPlayback.mediaSourceInfo.mediaStreams + .orEmpty() + .firstOrNull { it.index == audioIndex } + ?.let { + if (it.channels.gt(2) && it.codec != Codec.Audio.AC3) { + // User wants to transcode audio into 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) {