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

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

View file

@ -1,11 +1,19 @@
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
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.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.jellyfin.sdk.model.api.TranscodingInfo
import timber.log.Timber
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@Composable
fun PlaybackDebugOverlay(
currentPlayback: CurrentPlayback?,
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(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier,
@ -38,10 +73,12 @@ fun PlaybackDebugOverlay(
add("Video Decoder:" to currentPlayback.videoDecoder)
add("Audio Decoder:" to currentPlayback.audioDecoder)
}
add("Display Mode: " to displayMode?.toString())
},
modifier = Modifier.weight(1f, fill = false),
)
currentPlayback?.transcodeInfo?.let {
TranscodeInfo(it, Modifier)
TranscodeInfo(it, Modifier.weight(2f))
}
}
}
@ -86,27 +123,21 @@ fun SimpleTable(
rows: List<Pair<String, String?>>,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
rows.forEach {
rows.forEach {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = it.first,
modifier = Modifier.width(100.dp),
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier,
) {
rows.forEach {
Text(
text = it.second.toString(),
modifier = Modifier,
)
}
}

View file

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