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
This commit is contained in:
Damontecres 2026-10-03 18:41:18 -04:00 • committed by GitHub
parent b0d270380d
commit 559abe2458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 105 additions and 45 deletions

View file

@ -389,6 +389,19 @@ sealed interface AppPreference<Pref, T> {
},
)
val MaxResolutionPref =
AppChoicePreference<AppPreferences, MaxResolution>(
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<AppPreferences>(
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,

View file

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

View file

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

View file

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

View file

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

View file

@ -927,6 +927,25 @@
<string name="remove_from_playlist">Remove from playlist</string>
<string name="login_credentials_expired">Credentials expired, please login in again</string>
<string name="login_not_authorized">You are not authorized to access the server at this time</string>
<string name="max_resolution">Max resolution before transcoding</string>
<string name="resolution_8k">8K</string>
<string name="resolution_4k">4K</string>
<string name="resolution_1080">1080</string>
<string name="resolution_720">720</string>
<string name="resolution_576">576</string>
<string name="resolution_480">480</string>
<string name="resolution_360">360</string>
<string-array name="max_resolution_options">
<item>@string/no_limit</item>
<item>@string/resolution_8k</item>
<item>@string/resolution_4k</item>
<item>@string/resolution_1080</item>
<item>@string/resolution_720</item>
<item>@string/resolution_576</item>
<item>@string/resolution_480</item>
<item>@string/resolution_360</item>
</string-array>
<plurals name="episodes_count">
<item quantity="zero">%1$d Episodes</item>
<item quantity="one">%1$d Episode</item>