Support resolution switching (#597)

## Description
Adds a settings for automatic resolution switching. When enabled, the
app tries to output content at the video stream's resolution.

For example, if playing 1080p content on a 4K TV, the actual output by
the Android TV device will be 1080p.

This works in hand-in-hand with automatic refresh rate switching. So
enabling both allows for the output to match both refresh rate and
content resolution. Outputting the best refresh rate is preferred over
best resolution.

Also, shows the current display mode in the playback debug info.

### Related issues
Closes #428 
Fixes #531
This commit is contained in:
Ray 2025-12-31 17:11:30 -05:00 committed by GitHub
parent 38697b011d
commit 26a913b05e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 319 additions and 44 deletions

View file

@ -38,7 +38,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"> android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin package com.github.damontecres.wholphin
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
@ -114,12 +115,12 @@ class MainActivity : AppCompatActivity() {
if (savedInstanceState == null) { if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false) appUpgradeHandler.copySubfont(false)
} }
refreshRateService.refreshRateMode.observe(this) { mode -> refreshRateService.refreshRateMode.observe(this) { modeId ->
// Listen for refresh rate changes // Listen for refresh rate changes
val attrs = window.attributes val attrs = window.attributes
if (attrs.preferredDisplayModeId != mode.modeId) { if (attrs.preferredDisplayModeId != modeId) {
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate) Timber.d("Switch preferredDisplayModeId to %s", modeId)
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate } window.attributes = attrs.apply { preferredDisplayModeId = modeId }
} }
} }
viewModel.appStart() viewModel.appStart()
@ -318,6 +319,11 @@ class MainActivity : AppCompatActivity() {
Timber.d("onDestroy") Timber.d("onDestroy")
} }
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
Timber.d("onConfigurationChanged")
}
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
Timber.v("onNewIntent") Timber.v("onNewIntent")

View file

@ -696,7 +696,25 @@ sealed interface AppPreference<Pref, T> {
defaultValue = false, defaultValue = false,
getter = { it.playbackPreferences.refreshRateSwitching }, getter = { it.playbackPreferences.refreshRateSwitching },
setter = { prefs, value -> setter = { prefs, value ->
prefs.updatePlaybackPreferences { refreshRateSwitching = value } prefs.updatePlaybackPreferences {
if (!value) resolutionSwitching = false
refreshRateSwitching = value
}
},
summaryOn = R.string.automatic,
summaryOff = R.string.disabled,
)
val ResolutionSwitching =
AppSwitchPreference<AppPreferences>(
title = R.string.resolution_switching,
defaultValue = false,
getter = { it.playbackPreferences.resolutionSwitching },
setter = { prefs, value ->
prefs.updatePlaybackPreferences {
if (value) refreshRateSwitching = true
resolutionSwitching = value
}
}, },
summaryOn = R.string.automatic, summaryOn = R.string.automatic,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
@ -934,6 +952,7 @@ val advancedPreferences =
AppPreference.GlobalContentScale, AppPreference.GlobalContentScale,
AppPreference.MaxBitrate, AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching, AppPreference.RefreshRateSwitching,
AppPreference.ResolutionSwitching,
AppPreference.PlaybackDebugInfo, AppPreference.PlaybackDebugInfo,
), ),
), ),

View file

@ -52,6 +52,7 @@ class AppPreferencesSerializer
playerBackend = AppPreference.PlayerBackendPref.defaultValue playerBackend = AppPreference.PlayerBackendPref.defaultValue
refreshRateSwitching = refreshRateSwitching =
AppPreference.RefreshRateSwitching.defaultValue AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
overrides = overrides =
PlaybackOverrides PlaybackOverrides

View file

@ -32,41 +32,61 @@ class RefreshRateService
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode private val originalMode = display.mode
val displayModes get() = display.supportedModes val supportedDisplayModes get() = display.supportedModes.orEmpty()
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode) private val displayModes: List<DisplayMode> by lazy {
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode display.supportedModes
.orEmpty()
.map { DisplayMode(it) }
.sortedWith(
compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight })
.thenBy { it.refreshRateRounded },
)
}
private val _refreshRateMode = EqualityMutableLiveData<Int>(originalMode.modeId)
val refreshRateMode: LiveData<Int> = _refreshRateMode
/** /**
* Find the best display mode for the given stream and signal to change to it * Find the best display mode for the given stream and signal to change to it
*/ */
suspend fun changeRefreshRate(stream: MediaStream) { suspend fun changeRefreshRate(
stream: MediaStream,
switchRefreshRate: Boolean,
switchResolution: Boolean,
) {
if (!switchRefreshRate && !switchResolution) {
Timber.v("Not switching either refresh rate nor resolution")
return
}
val currentDisplayMode = display.mode
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width val width = stream.width
val height = stream.height val height = stream.height
val frameRate = stream.realFrameRate?.times(100)?.roundToInt() val frameRate =
if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate
if (width == null || height == null || frameRate == null) { if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
return return
} }
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode = val targetMode =
display.supportedModes findDisplayMode(
.filterNot { it.physicalHeight < height || it.physicalWidth < width } displayModes = displayModes,
.filter { streamWidth = width,
(it.refreshRate * 100).roundToInt().let { modeRate -> streamHeight = height,
frameRate % modeRate == 0 || // Exact multiple targetFrameRate = frameRate,
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps refreshRateSwitch = switchRefreshRate,
} resolutionSwitch = switchResolution,
}.maxByOrNull { it.physicalWidth * it.physicalHeight } )
Timber.i("Found display mode: %s, current=${display.mode}", targetMode) Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode)
if (targetMode != null && targetMode != display.mode) { if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) {
val listener = Listener(display.displayId) val listener = Listener(display.displayId)
displayManager.registerDisplayListener( displayManager.registerDisplayListener(
listener, listener,
Handler(Looper.myLooper() ?: Looper.getMainLooper()), Handler(Looper.myLooper() ?: Looper.getMainLooper()),
) )
_refreshRateMode.setValueOnMain(targetMode) _refreshRateMode.setValueOnMain(targetMode.modeId)
try { try {
if (!listener.latch.await(5, TimeUnit.SECONDS)) { if (!listener.latch.await(5, TimeUnit.SECONDS)) {
Timber.w("Timed out waiting for display change") Timber.w("Timed out waiting for display change")
@ -98,7 +118,7 @@ class RefreshRateService
* Reset the display mode to the original * Reset the display mode to the original
*/ */
fun resetRefreshRate() { fun resetRefreshRate() {
_refreshRateMode.value = originalMode _refreshRateMode.value = originalMode.modeId
} }
private class Listener( private class Listener(
@ -119,4 +139,69 @@ class RefreshRateService
override fun onDisplayRemoved(displayId: Int) { override fun onDisplayRemoved(displayId: Int) {
} }
} }
companion object {
/**
* Find the best display mode for the given stream & preferences
*
* @param displayModes candidates that are sorted by resolution and frame rate descending
*/
fun findDisplayMode(
displayModes: List<DisplayMode>,
streamWidth: Int,
streamHeight: Int,
targetFrameRate: Float,
refreshRateSwitch: Boolean,
resolutionSwitch: Boolean,
): DisplayMode? {
val streamRate = targetFrameRate.times(1000).roundToInt()
// Timber.v("display modes: %s", displayModes.joinToString("\n"))
val candidates =
if (refreshRateSwitch) {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
.filter {
it.refreshRateRounded % streamRate == 0 || // Exact multiple
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
} }
} else {
displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
}
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) {
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
} else {
candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate
}
?: candidates.firstOrNull {
it.physicalWidth == streamWidth &&
it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate
}
?: candidates
.filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight }
}
}
}
}
data class DisplayMode(
val modeId: Int,
val physicalWidth: Int,
val physicalHeight: Int,
val refreshRate: Float,
) {
val refreshRateRounded: Int = (refreshRate * 1000).roundToInt()
constructor(mode: Display.Mode) : this(
mode.modeId,
mode.physicalWidth,
mode.physicalHeight,
mode.refreshRate,
)
}

View file

@ -254,7 +254,7 @@ fun DebugPage(
"Manufacturer: ${Build.MANUFACTURER}", "Manufacturer: ${Build.MANUFACTURER}",
"Model: ${Build.MODEL}", "Model: ${Build.MODEL}",
"Display Modes:", "Display Modes:",
*viewModel.refreshRateService.displayModes, *viewModel.refreshRateService.supportedDisplayModes,
).forEach { ).forEach {
Text( Text(
text = it.toString(), text = it.toString(),

View file

@ -1,11 +1,19 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import android.content.Context
import android.hardware.display.DisplayManager
import android.view.Display
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
@ -14,13 +22,40 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.TranscodingInfo import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun PlaybackDebugOverlay( fun PlaybackDebugOverlay(
currentPlayback: CurrentPlayback?, currentPlayback: CurrentPlayback?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val display =
remember(context) {
try {
val displayManager =
context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager
displayManager?.getDisplay(Display.DEFAULT_DISPLAY)
} catch (ex: Exception) {
Timber.e(ex)
null
}
}
val displayMode by produceState<String?>(null) {
while (isActive) {
value =
display?.mode?.let {
val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate)
"${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}"
}
delay(10.seconds)
}
}
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
@ -38,10 +73,12 @@ fun PlaybackDebugOverlay(
add("Video Decoder:" to currentPlayback.videoDecoder) add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to currentPlayback.audioDecoder) add("Audio Decoder:" to currentPlayback.audioDecoder)
} }
add("Display Mode: " to displayMode?.toString())
}, },
modifier = Modifier.weight(1f, fill = false),
) )
currentPlayback?.transcodeInfo?.let { currentPlayback?.transcodeInfo?.let {
TranscodeInfo(it, Modifier) TranscodeInfo(it, Modifier.weight(2f))
} }
} }
} }
@ -86,27 +123,21 @@ fun SimpleTable(
rows: List<Pair<String, String?>>, rows: List<Pair<String, String?>>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Column(
horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) { ) {
rows.forEach { rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text( Text(
text = it.first, text = it.first,
modifier = Modifier.width(100.dp),
) )
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
rows.forEach {
Text( Text(
text = it.second.toString(), text = it.second.toString(),
modifier = Modifier,
) )
} }
} }

View file

@ -23,6 +23,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.AnalyticsListener
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.size.Size
import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackDao
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -641,9 +642,15 @@ class PlaybackViewModel
mediaSourceInfo = source, mediaSourceInfo = source,
) )
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { preferences.appPreferences.playbackPreferences.let { prefs ->
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { source.mediaStreams
refreshRateService.changeRefreshRate(it) ?.firstOrNull { it.type == MediaStreamType.VIDEO }
?.let { stream ->
refreshRateService.changeRefreshRate(
stream = stream,
switchRefreshRate = prefs.refreshRateSwitching,
switchResolution = prefs.resolutionSwitching,
)
} }
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -754,7 +761,7 @@ class PlaybackViewModel
ImageRequest ImageRequest
.Builder(context) .Builder(context)
.data(url) .data(url)
.size(coil3.size.Size.ORIGINAL) .size(Size.ORIGINAL)
.build(), .build(),
) )
} }

View file

@ -72,6 +72,7 @@ message PlaybackPreferences {
PlayerBackend player_backend = 20; PlayerBackend player_backend = 20;
MpvOptions mpv_options = 21; MpvOptions mpv_options = 21;
bool refresh_rate_switching = 22; bool refresh_rate_switching = 22;
bool resolution_switching = 23;
} }
message HomePagePreferences{ message HomePagePreferences{

View file

@ -398,6 +398,7 @@
<string name="color_code_programs">Color-code programs</string> <string name="color_code_programs">Color-code programs</string>
<string name="subtitle_delay">Subtitle delay</string> <string name="subtitle_delay">Subtitle delay</string>
<string name="backdrop_display">Backdrop style</string> <string name="backdrop_display">Backdrop style</string>
<string name="resolution_switching">Resolution switching</string>
<string-array name="theme_song_volume"> <string-array name="theme_song_volume">
<item>Disabled</item> <item>Disabled</item>

View file

@ -0,0 +1,124 @@
package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.services.DisplayMode
import com.github.damontecres.wholphin.services.RefreshRateService
import org.junit.Assert
import org.junit.Test
class TestDisplayModeChoice {
companion object {
val HD_60 = DisplayMode(0, 1920, 1080, 60f)
val HD_30 = DisplayMode(1, 1920, 1080, 30f)
val HD_24 = DisplayMode(2, 1920, 1080, 24f)
val UHD_60 = DisplayMode(3, 3840, 2160, 60f)
val UHD_30 = DisplayMode(4, 3840, 2160, 30f)
val UHD_24 = DisplayMode(5, 3840, 2160, 24f)
val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60)
}
@Test
fun test1() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 60f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(3, result?.modeId)
}
@Test
fun test2() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 60f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = true,
)
Assert.assertEquals(0, result?.modeId)
}
@Test
fun test3() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 30f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(4, result?.modeId)
}
@Test
fun test4() {
val streamWidth = 1920
val streamHeight = 804
val streamRealFrameRate = 30f
val result =
RefreshRateService.findDisplayMode(
displayModes = ALL_MODES,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = streamRealFrameRate,
refreshRateSwitch = false,
resolutionSwitch = true,
)
Assert.assertEquals(1, result?.modeId)
}
@Test
fun testFraction() {
val streamWidth = 1920
val streamHeight = 1080
val streamRealFrameRate = 30f
val displayModes =
listOf(
DisplayMode(0, 1920, 1080, 59.940f),
DisplayMode(1, 1920, 1080, 60f),
// DisplayMode(2, 1920, 1080, 29.970f),
)
val result =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = 29.970f,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(0, result?.modeId)
val result2 =
RefreshRateService.findDisplayMode(
displayModes = displayModes,
streamWidth = streamWidth,
streamHeight = streamHeight,
targetFrameRate = 24f,
refreshRateSwitch = true,
resolutionSwitch = false,
)
Assert.assertEquals(1, result2?.modeId)
}
}