From 559abe2458eb6c5e2b1ca732f943f0a6807a42bf Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sat, 3 Oct 2026 18:41:18 -0400 Subject: [PATCH] Add setting to force transcoding above a max resolution (#1955) ## Description Adds a new setting to pick a maximum resolution (by height). Any playback that has a larger resolution will be transcoded down to the maximum resolution. Playback equal or less than the maximum resolution will not be affected and will direct play if otherwise able. This setting is also independent of the maximum bitrate (ie if max resolution is 4K and max bitrate is 2mbps, playing 4K will transcode). This setting is pretty much equivalent to the Web UI setting "Maximum Allowed Video Transcoding Resolution" with "Limit maximum supported video resolution" enabled. Note: this setting does not affect which version is chosen by default ### Related issues Closes #756 Closes #1594 ### Testing Manual emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 14 +++ .../wholphin/preferences/MaxResolution.kt | 19 ++++ .../wholphin/services/DeviceProfileService.kt | 1 + .../util/profile/DeviceProfileUtils.kt | 96 ++++++++++--------- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 19 ++++ 6 files changed, 105 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.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 43d2d53a..4213fe94 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 @@ -389,6 +389,19 @@ sealed interface AppPreference { }, ) + val MaxResolutionPref = + AppChoicePreference( + title = R.string.max_resolution, + defaultValue = MaxResolution.NO_LIMIT, + getter = { MaxResolution.fromValue(it.playbackPreferences.overrides.maxResolution) }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { maxResolution = value.value } + }, + displayValues = R.array.max_resolution_options, + indexToValue = { MaxResolution.entries.getOrNull(it) ?: MaxResolution.NO_LIMIT }, + valueToIndex = { it.ordinal }, + ) + val Ac3Supported = AppSwitchPreference( title = R.string.ac3_supported, @@ -1242,6 +1255,7 @@ val advancedPreferences = AppPreference.GlobalContentScale, AppPreference.SkipSegments, AppPreference.DpadSeekModePref, + AppPreference.MaxResolutionPref, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, AppPreference.ResolutionSwitching, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt new file mode 100644 index 00000000..8b389927 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/MaxResolution.kt @@ -0,0 +1,19 @@ +package com.github.damontecres.wholphin.preferences + +enum class MaxResolution( + val value: Int, +) { + NO_LIMIT(0), + ULTRA_HD_8K(4320), + ULTRA_HD_4K(2160), + FULL_HD(1080), + HD(720), + PAL_SD(576), + NTSC_SD(480), + LOW_DEF(360), + ; + + companion object { + fun fromValue(value: Int): MaxResolution = entries.firstOrNull { it.value == value } ?: NO_LIMIT + } +} 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 29df802c..b9b15f97 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 @@ -65,6 +65,7 @@ class DeviceProfileService decodeAv1 = prefs.overrides.decodeAv1, preferAc3ForSurround = appPrefs.experimentalPreferences.enabled { preferAc3Surround }, jellyfinTenEleven = newConfig.jellyfinTenEleven, + maxResolution = newConfig.overrides.maxResolution, ) } this@DeviceProfileService.deviceProfile!! 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 d2efeb30..5c5004ac 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 @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util.profile // Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt import android.media.MediaCodecInfo +import android.util.Size import androidx.media3.common.MimeTypes import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52 import org.jellyfin.sdk.model.api.CodecType @@ -61,6 +62,8 @@ val supportedAudioCodecs = // ) // } +private fun Size.min(min: Int) = if (min > 0 && height > min) Size(width, min) else this + fun createDeviceProfile( mediaTest: MediaCodecCapabilitiesTest, maxBitrate: Int, @@ -72,6 +75,7 @@ fun createDeviceProfile( decodeAv1: Boolean, jellyfinTenEleven: Boolean, preferAc3ForSurround: Boolean, + maxResolution: Int, ) = buildDeviceProfile { val allowedAudioCodecs = when { @@ -106,10 +110,10 @@ fun createDeviceProfile( val supportsAV1 = mediaTest.supportsAV1() val supportsAV1Main10 = mediaTest.supportsAV1Main10() val supportsVC1 = mediaTest.supportsVc1() - val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264) - val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265) - val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1) - val maxResolutionVC1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_VC1) + val maxResolutionAVC = mediaTest.getMaxResolution(MimeTypes.VIDEO_H264).min(maxResolution) + val maxResolutionHevc = mediaTest.getMaxResolution(MimeTypes.VIDEO_H265).min(maxResolution) + val maxResolutionAV1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_AV1).min(maxResolution) + val maxResolutionVC1 = mediaTest.getMaxResolution(MimeTypes.VIDEO_VC1).min(maxResolution) // / HDR capabilities @@ -133,59 +137,43 @@ fun createDeviceProfile( // / Transcoding profiles // Video - if (preferAc3ForSurround) { - transcodingProfile { - type = DlnaProfileType.VIDEO - context = EncodingContext.STREAMING + transcodingProfile { + type = DlnaProfileType.VIDEO + context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + 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 - } - } else { - transcodingProfile { - type = DlnaProfileType.VIDEO - context = EncodingContext.STREAMING - - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS - - if (supportsHevc) videoCodec(Codec.Video.HEVC) - videoCodec(Codec.Video.H264) + if (supportsHevc) videoCodec(Codec.Video.HEVC) + videoCodec(Codec.Video.H264) + if (preferAc3ForSurround) { audioCodec(*allowedAudioCodecs) + } else { + audioCodec(Codec.Audio.AC3) + } - copyTimestamps = false - enableSubtitlesInManifest = true + copyTimestamps = false + enableSubtitlesInManifest = true + + if (maxResolution > 0) { + conditions { + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolution + } } } // Audio - if (preferAc3ForSurround) { - transcodingProfile { - type = DlnaProfileType.AUDIO - context = EncodingContext.STREAMING + transcodingProfile { + type = DlnaProfileType.AUDIO + context = EncodingContext.STREAMING - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS + container = Codec.Container.TS + protocol = MediaStreamProtocol.HLS + if (preferAc3ForSurround) { audioCodec(Codec.Audio.AC3) - } - } else { - transcodingProfile { - type = DlnaProfileType.AUDIO - context = EncodingContext.STREAMING - - container = Codec.Container.TS - protocol = MediaStreamProtocol.HLS - + } else { audioCodec(Codec.Audio.AAC) } } @@ -459,6 +447,24 @@ fun createDeviceProfile( } } + if (maxResolution > 0) { + listOf( + Codec.Video.MPEG, + Codec.Video.MPEG2VIDEO, + Codec.Video.VP8, + Codec.Video.VP9, + ).forEach { codecName -> + codecProfile { + type = CodecType.VIDEO + codec = codecName + + conditions { + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolution + } + } + } + } + // / HDR exclude list // TODO Use VideoRangeType enum with Jellyfin 10.11 based SDK diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 3fba78f6..38f6099c 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; + int32 max_resolution = 9; } message PlaybackPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 423530a3..dd87f6da 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -927,6 +927,25 @@ Remove from playlist Credentials expired, please login in again You are not authorized to access the server at this time + Max resolution before transcoding + + 8K + 4K + 1080 + 720 + 576 + 480 + 360 + + @string/no_limit + @string/resolution_8k + @string/resolution_4k + @string/resolution_1080 + @string/resolution_720 + @string/resolution_576 + @string/resolution_480 + @string/resolution_360 + %1$d Episodes %1$d Episode