From 9f557416ea56012c3e48d007f263159dc24772a2 Mon Sep 17 00:00:00 2001 From: Jeff Whiting Date: Tue, 13 Oct 2026 13:41:53 -0600 Subject: [PATCH] New user preference to force enable or disable common HDR and Dolby Vision formats (#1644) Description Adding per-format HDR override preferences (HDR10, HDR10+, Dolby Vision Profile 5/7/8) to control device compatibility information sent to the Jellyfin server, so users can force specific formats on or off independently instead of disabling Dolby Vision as a whole. Related issues This is a common problem across the Jellyfin ecosystem, but there isn't a specific issue for Wholphin. My experience is the issue is FireTV (model and generation) + TV dependent + media dependent. I tested videos from https://kodi.wiki/view/Samples with the same DOVIWithHDR10 flag that worked while other movies I have with It is a very frustrating user experience and the current workaround of setting the bitrate really low to force a transcode is sub-optimal IMO as it lowers the video quality. This allows the user to maintain full quality while disabling only the specific format(s) causing problems. https://github.com/jellyfin/jellyfin-androidtv/issues/4021 https://github.com/jellyfin/jellyfin-androidtv/issues/4892 https://github.com/nova-video-player/aos-AVP/issues/1503 https://www.reddit.com/r/jellyfin/comments/12jqoxh/dolby_vision_video_not_working_on_fire_stick_4k/ Testing FireTV 4K (gen 1) + Vizio FireTV 4K Max (gen 1) + LG FireTV 4K Max (gen 2) + Vizio FireTV (gen 1) + Olevia AI or LLM usage Claude code assisted and wrote much of the code in this PR. I've reviewed it all and run the pre-commit linting and formatting. --------- Co-authored-by: Damontecres --- .../wholphin/preferences/AppPreference.kt | 93 +++++++++++- .../wholphin/preferences/HdrFormat.kt | 56 +++++++ .../wholphin/services/AppUpgradeHandler.kt | 15 ++ .../wholphin/services/DeviceProfileService.kt | 7 +- .../ui/preferences/PreferenceUtils.kt | 1 + .../ui/preferences/PreferencesContent.kt | 4 + .../util/profile/DeviceProfileUtils.kt | 48 +++--- .../util/profile/WholphinVideoRangeType.kt | 27 ++++ app/src/main/proto/WholphinDataStore.proto | 13 +- app/src/main/res/values/strings.xml | 15 +- .../wholphin/test/TestHdrFormat.kt | 85 +++++++++++ .../util/profile/TestDeviceProfileUtils.kt | 141 ++++++++++++++++++ 12 files changed, 468 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt 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 4213fe94..05dd6d27 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 @@ -456,15 +456,69 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) - val DirectPlayDoviProfile7 = - AppSwitchPreference( - title = R.string.force_dovi_profile_7, - defaultValue = false, - getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, + val Hdr10Override = + AppChoicePreference( + title = R.string.hdr10_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10Override }, setter = { prefs, value -> - prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } + prefs.updatePlaybackOverrides { hdr10Override = value } }, - summary = R.string.force_dovi_profile_7_summary, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val Hdr10PlusOverride = + AppChoicePreference( + title = R.string.hdr10_plus_override, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { hdr10PlusOverride = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile5Override = + AppChoicePreference( + title = R.string.dovi_profile_5, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile5Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile7Override = + AppChoicePreference( + title = R.string.dovi_profile_7, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile7Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, + ) + + val DoviProfile8Override = + AppChoicePreference( + title = R.string.dovi_profile_8, + defaultValue = HdrOverrideMode.HDR_OVERRIDE_AUTO, + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { doviProfile8Override = value } + }, + displayValues = R.array.hdr_override_modes, + indexToValue = { HdrOverrideMode.forNumber(it) ?: HdrOverrideMode.HDR_OVERRIDE_AUTO }, + valueToIndex = { if (it != HdrOverrideMode.UNRECOGNIZED) it.number else 0 }, ) val DecodeAv1 = @@ -919,6 +973,12 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.MPV), ) + val HdrFormatSettings = + AppDestinationPreference( + title = R.string.hdr_formats, + destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS), + ) + val MpvHardwareDecoding = AppSwitchPreference( title = R.string.mpv_hardware_decoding, @@ -1183,8 +1243,17 @@ private val ExoPlayerSettings = AppPreference.Ac3Supported, AppPreference.AssSubtitleMode, AppPreference.DirectPlayPgs, - AppPreference.DirectPlayDoviProfile7, AppPreference.DecodeAv1, + AppPreference.HdrFormatSettings, + ) + +private val HdrFormatSettings = + listOf( + AppPreference.Hdr10Override, + AppPreference.Hdr10PlusOverride, + AppPreference.DoviProfile5Override, + AppPreference.DoviProfile7Override, + AppPreference.DoviProfile8Override, ) val ExoPlayerPreferences = @@ -1195,6 +1264,14 @@ val ExoPlayerPreferences = ), ) +val HdrFormatPreferences = + listOf( + PreferenceGroup( + title = R.string.hdr_formats, + preferences = HdrFormatSettings, + ), + ) + private val MpvSettings = listOf( AppPreference.MpvHardwareDecoding, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt new file mode 100644 index 00000000..df0b6823 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/HdrFormat.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType + +// Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/preference/constant/HdrFormat.kt + +/** + * HDR formats that the user can override in the device profile. + */ +enum class HdrFormat( + val getter: (AppPreferences) -> HdrOverrideMode, + val rangeTypeNames: (jellyfinTenEleven: Boolean) -> Set, +) { + HDR10( + getter = { it.playbackPreferences.overrides.hdr10Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10.serialName) }, + ), + HDR10_PLUS( + getter = { it.playbackPreferences.overrides.hdr10PlusOverride }, + rangeTypeNames = { setOf(WholphinVideoRangeType.HDR10_PLUS.serialName) }, + ), + DOVI_PROFILE_5( + getter = { it.playbackPreferences.overrides.doviProfile5Override }, + rangeTypeNames = { setOf(WholphinVideoRangeType.DOVI.serialName) }, + ), + DOVI_PROFILE_7( + getter = { it.playbackPreferences.overrides.doviProfile7Override }, + rangeTypeNames = { jellyfinTenEleven -> + if (jellyfinTenEleven) { + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) + } else { + emptySet() + } + }, + ), + DOVI_PROFILE_8( + getter = { it.playbackPreferences.overrides.doviProfile8Override }, + rangeTypeNames = { jellyfinTenEleven -> + buildSet { + add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + } + }, + ), +} + +/** + * Collects the video range type names for every [HdrFormat] whose preference is currently set to [mode] + */ +fun AppPreferences.getHdrRangeTypesFor( + mode: HdrOverrideMode, + jellyfinTenEleven: Boolean, +): Set = + HdrFormat.entries + .filter { it.getter(this) == mode } + .flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 318ad9d1..6b8dbc98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.RememberedTab import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences @@ -470,5 +471,19 @@ class AppUpgradeHandler } } } + + if (previous.isEqualOrBefore(Version.fromString("1.0.10"))) { + // directPlayDolbyVisionEL was replaced by doviProfile7Override; carry the setting forward + appPreferences.updateData { + if (it.playbackPreferences.overrides.directPlayDolbyVisionEL) { + it.updatePlaybackOverrides { + doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + directPlayDolbyVisionEL = false + } + } else { + it + } + } + } } } 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 b9b15f97..939f2b72 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 @@ -4,8 +4,10 @@ import android.content.Context import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AssPlaybackMode import com.github.damontecres.wholphin.preferences.ExperimentalPreferences +import com.github.damontecres.wholphin.preferences.HdrOverrideMode import com.github.damontecres.wholphin.preferences.PlaybackOverrides import com.github.damontecres.wholphin.preferences.enabled +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.profile.MediaCodecCapabilitiesTest import com.github.damontecres.wholphin.util.profile.createDeviceProfile @@ -61,9 +63,12 @@ class DeviceProfileService downMixAudio = newConfig.overrides.downmixStereo, assDirectPlay = newConfig.overrides.assPlaybackMode != AssPlaybackMode.ASS_TRANSCODE, pgsDirectPlay = newConfig.overrides.directPlayPgs, - dolbyVisionELDirectPlay = newConfig.overrides.directPlayDolbyVisionEL, decodeAv1 = prefs.overrides.decodeAv1, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, + forceEnabledHdr = + appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, newConfig.jellyfinTenEleven), + forceDisabledHdr = + appPrefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, newConfig.jellyfinTenEleven), jellyfinTenEleven = newConfig.jellyfinTenEleven, maxResolution = newConfig.overrides.maxResolution, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index 0a843dcb..a9676132 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -38,6 +38,7 @@ enum class PreferenceScreenOption { SCREENSAVER, SKIP_SEGMENTS, EXPERIMENTAL, + HDR_FORMATS, ; companion object { 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 d95d5aba..15dd11a7 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 @@ -51,6 +51,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExperimentalPreference +import com.github.damontecres.wholphin.preferences.HdrFormatPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.ScreensaverPreference @@ -155,6 +156,7 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> screensaverPreferences PreferenceScreenOption.SKIP_SEGMENTS -> SkipSegmentPreferences PreferenceScreenOption.EXPERIMENTAL -> experimentalPreferences + PreferenceScreenOption.HDR_FORMATS -> HdrFormatPreferences } val screenTitle = when (preferenceScreenOption) { @@ -165,6 +167,7 @@ fun PreferencesContent( PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings PreferenceScreenOption.SKIP_SEGMENTS -> R.string.skip_behavior PreferenceScreenOption.EXPERIMENTAL -> R.string.experimental_settings + PreferenceScreenOption.HDR_FORMATS -> R.string.hdr_formats } var visible by remember { mutableStateOf(false) } @@ -835,6 +838,7 @@ fun PreferencesPage( PreferenceScreenOption.SCREENSAVER, PreferenceScreenOption.SKIP_SEGMENTS, PreferenceScreenOption.EXPERIMENTAL, + PreferenceScreenOption.HDR_FORMATS, -> { PreferencesContent( initialPreferences, 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 5c5004ac..988a3380 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 @@ -12,7 +12,6 @@ import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.MediaStreamProtocol import org.jellyfin.sdk.model.api.ProfileConditionValue import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod -import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.deviceprofile.DeviceProfileBuilder import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile @@ -71,10 +70,11 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, - dolbyVisionELDirectPlay: Boolean, decodeAv1: Boolean, jellyfinTenEleven: Boolean, preferAc3ForSurround: Boolean, + forceEnabledHdr: Set, + forceDisabledHdr: Set, maxResolution: Int, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -467,60 +467,56 @@ fun createDeviceProfile( // / HDR exclude list - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesAv1 = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!decodeAv1) { if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(WholphinVideoRangeType.HDR10.serialName) } } - } + } - forceEnabledHdr + forceDisabledHdr - // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK val unsupportedRangeTypesHevc = buildSet { - if (jellyfinTenEleven) add("DOVIInvalid") + if (jellyfinTenEleven) add(WholphinVideoRangeType.DOVI_INVALID.serialName) if (!supportsHevcDolbyVisionEL) { - if (!dolbyVisionELDirectPlay) { - if (jellyfinTenEleven) { - add("DOVIWithEL") - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + if (jellyfinTenEleven) { + add(WholphinVideoRangeType.DOVI_WITH_EL.serialName) + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } } if (!supportsHevcDolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + add(WholphinVideoRangeType.DOVI.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName) if (jellyfinTenEleven && !supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) { - add( - "DOVIWithHDR10Plus", - ) + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) } } } if (!supportsHevcHDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) - if (!supportsHevcHDR10) add(VideoRangeType.HDR10.serialName) + add(WholphinVideoRangeType.HDR10_PLUS.serialName) + if (!supportsHevcHDR10) add(WholphinVideoRangeType.HDR10.serialName) } if (jellyfinTenEleven && KnownDefects.hevcDoviHdr10PlusBug) { - add("DOVIWithHDR10Plus") - add("DOVIWithELHDR10Plus") + add(WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName) + add(WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName) } - } + } - forceEnabledHdr + forceDisabledHdr // Display // Note: The codec profiles use a workaround to create correct behavior diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt new file mode 100644 index 00000000..bd49a6c4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/WholphinVideoRangeType.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.util.profile + +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Complete list of video range types, including ones not yet present in the pinned Jellyfin SDK's + * [VideoRangeType] enum (added server-side in Jellyfin 10.11). + * + * TODO Remove this and use [VideoRangeType] directly once the SDK is upgraded to a version built against Jellyfin 10.11+ + */ +enum class WholphinVideoRangeType( + val serialName: String, +) { + UNKNOWN(VideoRangeType.UNKNOWN.serialName), + SDR(VideoRangeType.SDR.serialName), + HDR10(VideoRangeType.HDR10.serialName), + HLG(VideoRangeType.HLG.serialName), + DOVI(VideoRangeType.DOVI.serialName), + DOVI_WITH_HDR10(VideoRangeType.DOVI_WITH_HDR10.serialName), + DOVI_WITH_HLG(VideoRangeType.DOVI_WITH_HLG.serialName), + DOVI_WITH_SDR(VideoRangeType.DOVI_WITH_SDR.serialName), + HDR10_PLUS(VideoRangeType.HDR10_PLUS.serialName), + DOVI_INVALID("DOVIInvalid"), + DOVI_WITH_EL("DOVIWithEL"), + DOVI_WITH_EL_HDR10_PLUS("DOVIWithELHDR10Plus"), + DOVI_WITH_HDR10_PLUS("DOVIWithHDR10Plus"), +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 7de4ba67..6c184f2c 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -54,10 +54,15 @@ message PlaybackOverrides{ bool direct_play_ass = 3 [deprecated = true]; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; - bool direct_play_dolby_vision_e_l = 6; + bool direct_play_dolby_vision_e_l = 6 [deprecated = true]; bool decode_av1 = 7; AssPlaybackMode ass_playback_mode = 8; int32 max_resolution = 9; + HdrOverrideMode dovi_profile_5_override = 10; + HdrOverrideMode dovi_profile_7_override = 11; + HdrOverrideMode dovi_profile_8_override = 12; + HdrOverrideMode hdr10_override = 13; + HdrOverrideMode hdr10_plus_override = 14; } message PlaybackPreferences { @@ -224,6 +229,12 @@ message MusicPreferences { bool show_backdrop = 4; } +enum HdrOverrideMode{ + HDR_OVERRIDE_AUTO = 0; + HDR_OVERRIDE_ENABLE = 1; + HDR_OVERRIDE_DISABLE = 2; +} + message ExperimentalPreferences { bool enabled = 1; bool video_tunneling_enabled = 2; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd87f6da..81353ff3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -173,7 +173,7 @@ Video Scale Video Videos - Watch liveg + Watch live %1$d years old %1$d year old @@ -413,6 +413,19 @@ Image disk cache size (MB) Disable audio hardware offload Disabling offloading audio decoding may fix playback freezes on some devices + HDR formats + HDR10 + HDR10+ + Dolby Vision Profile 5 + Dolby Vision Profile 7 + Dolby Vision Profile 8 + Force enable + Force disable + + @string/automatic + @string/force_enable + @string/force_disable + View options Columns Spacing diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt new file mode 100644 index 00000000..e38479f0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHdrFormat.kt @@ -0,0 +1,85 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.HdrOverrideMode +import com.github.damontecres.wholphin.preferences.PlaybackOverrides +import com.github.damontecres.wholphin.preferences.getHdrRangeTypesFor +import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.util.profile.WholphinVideoRangeType +import org.junit.Assert +import org.junit.Test + +class TestHdrFormat { + private fun appPreferences(block: PlaybackOverrides.Builder.() -> Unit): AppPreferences = + AppPreferences.getDefaultInstance().updatePlaybackOverrides(block) + + @Test + fun `Test all auto returns empty sets`() { + val prefs = appPreferences { } + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true)) + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test HDR10 override`() { + val prefs = appPreferences { hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals(emptySet(), prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true)) + } + + @Test + fun `Test Dolby Vision Profile 5 override`() { + val prefs = appPreferences { doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 7 override requires Jellyfin 10_11`() { + val prefs = appPreferences { doviProfile7Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE } + Assert.assertEquals( + emptySet(), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_EL.serialName, WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test Dolby Vision Profile 8 override`() { + val prefs = appPreferences { doviProfile8Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE } + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = false), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI_WITH_HDR10.serialName, WholphinVideoRangeType.DOVI_WITH_HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } + + @Test + fun `Test multiple formats set to the same mode are unioned`() { + val prefs = + appPreferences { + hdr10Override = HdrOverrideMode.HDR_OVERRIDE_ENABLE + hdr10PlusOverride = HdrOverrideMode.HDR_OVERRIDE_ENABLE + doviProfile5Override = HdrOverrideMode.HDR_OVERRIDE_DISABLE + } + Assert.assertEquals( + setOf(WholphinVideoRangeType.HDR10.serialName, WholphinVideoRangeType.HDR10_PLUS.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_ENABLE, jellyfinTenEleven = true), + ) + Assert.assertEquals( + setOf(WholphinVideoRangeType.DOVI.serialName), + prefs.getHdrRangeTypesFor(HdrOverrideMode.HDR_OVERRIDE_DISABLE, jellyfinTenEleven = true), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt new file mode 100644 index 00000000..491280df --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/util/profile/TestDeviceProfileUtils.kt @@ -0,0 +1,141 @@ +package com.github.damontecres.wholphin.util.profile + +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.api.CodecType +import org.jellyfin.sdk.model.api.DeviceProfile +import org.jellyfin.sdk.model.api.ProfileConditionValue +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Verifies that forceEnabledHdr/forceDisabledHdr correctly override the device's own + * codec-capability detection when building the unsupported video range type list. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [30]) +class TestDeviceProfileUtils { + private fun unsupportedRangeTypes( + profile: DeviceProfile, + codec: String, + ): Set = + profile.codecProfiles + .filter { it.type == CodecType.VIDEO && it.codec == codec } + .firstNotNullOf { codecProfile -> + codecProfile.applyConditions.firstOrNull { it.property == ProfileConditionValue.VIDEO_RANGE_TYPE }?.value + }.split("|") + .toSet() + + private fun buildProfile( + mediaTest: MediaCodecCapabilitiesTest, + jellyfinTenEleven: Boolean = false, + decodeAv1: Boolean = false, + forceEnabledHdr: Set = emptySet(), + forceDisabledHdr: Set = emptySet(), + ): DeviceProfile = + createDeviceProfile( + mediaTest = mediaTest, + maxBitrate = 100_000_000, + isAC3Enabled = true, + downMixAudio = false, + assDirectPlay = true, + pgsDirectPlay = true, + decodeAv1 = decodeAv1, + jellyfinTenEleven = jellyfinTenEleven, + preferAc3ForSurround = false, + forceEnabledHdr = forceEnabledHdr, + forceDisabledHdr = forceDisabledHdr, + maxResolution = 0, + ) + + @Test + fun `Test HEVC Profile 7 unsupported when device does not support it`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = buildProfile(mediaTest, jellyfinTenEleven = true) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force enable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns false + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceEnabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test force disable overrides HEVC Profile 7 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsHevcDolbyVisionEL() } returns true + every { mediaTest.supportsHevcDolbyVision() } returns true + every { mediaTest.supportsHevcHDR10Plus() } returns true + + val profile = + buildProfile( + mediaTest, + jellyfinTenEleven = true, + forceDisabledHdr = + setOf( + WholphinVideoRangeType.DOVI_WITH_EL.serialName, + WholphinVideoRangeType.DOVI_WITH_EL_HDR10_PLUS.serialName, + ), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.HEVC).contains(WholphinVideoRangeType.DOVI_WITH_EL.serialName)) + } + + @Test + fun `Test AV1 Profile 5 unsupported when device does not support it`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = buildProfile(mediaTest, decodeAv1 = false) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force enable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns false + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceEnabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertFalse(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } + + @Test + fun `Test force disable overrides AV1 Profile 5 device support check`() { + val mediaTest = mockk(relaxed = true) + every { mediaTest.supportsAV1DolbyVision() } returns true + + val profile = + buildProfile( + mediaTest, + decodeAv1 = false, + forceDisabledHdr = setOf(WholphinVideoRangeType.DOVI.serialName), + ) + Assert.assertTrue(unsupportedRangeTypes(profile, Codec.Video.AV1).contains(WholphinVideoRangeType.DOVI.serialName)) + } +}