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

<!-- Describe how this change was tested and on what device(s) -->
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 <damontecres@gmail.com>
This commit is contained in:
Jeff Whiting 2026-10-13 13:41:53 -06:00 • committed by GitHub
parent f01c5d367c
commit 9f557416ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 468 additions and 37 deletions

View file

@ -456,15 +456,69 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val DirectPlayDoviProfile7 =
AppSwitchPreference<AppPreferences>(
title = R.string.force_dovi_profile_7,
defaultValue = false,
getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL },
val Hdr10Override =
AppChoicePreference<AppPreferences, HdrOverrideMode>(
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<AppPreferences, HdrOverrideMode>(
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<AppPreferences, HdrOverrideMode>(
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<AppPreferences, HdrOverrideMode>(
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<AppPreferences, HdrOverrideMode>(
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<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.MPV),
)
val HdrFormatSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.hdr_formats,
destination = Destination.Settings(PreferenceScreenOption.HDR_FORMATS),
)
val MpvHardwareDecoding =
AppSwitchPreference<AppPreferences>(
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,

View file

@ -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<String>,
) {
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<String> =
HdrFormat.entries
.filter { it.getter(this) == mode }
.flatMapTo(mutableSetOf()) { it.rangeTypeNames(jellyfinTenEleven) }

View file

@ -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
}
}
}
}
}

View file

@ -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,
)

View file

@ -38,6 +38,7 @@ enum class PreferenceScreenOption {
SCREENSAVER,
SKIP_SEGMENTS,
EXPERIMENTAL,
HDR_FORMATS,
;
companion object {

View file

@ -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,

View file

@ -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<String>,
forceDisabledHdr: Set<String>,
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

View file

@ -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"),
}