Automatic refresh rate switching setting (#382)

Adds an advanced playback setting to enable automatic refresh rate
switching during playback

When enabled, the app looks at the available display modes, chooses the
best candidate, and attempts to switch to it. After playback ends, the
display is switched back to the original mode.

Closes #62
Closes #379
This commit is contained in:
damontecres 2025-12-06 16:35:43 -05:00 committed by GitHub
parent e669f3ad59
commit 20bef8fb6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 176 additions and 3 deletions

View file

@ -49,3 +49,18 @@ You can build the module on MacOS or Linux with the [`build_ffmpeg_decoder.sh`](
Wholphin has a playback engine that uses [`libmpv`](https://github.com/mpv-player/mpv). The app uses JNI code from [`mpv-android`](https://github.com/mpv-android/mpv-android) and has an implementation of `androidx.media3.common.Player` to swap out for `ExoPlayer`.
See the [build scripts](scripts/mpv/) for details on building this component.
### App settings
App settings are available with the `AppPreferences` object and defined by different `AppPreference` objects (note the `s` differences).
The `AppPreference` objects are used to create the UI for configuring settings using the composable functions in `com.github.damontecres.wholphin.ui.preferences`.
#### How to add a new app setting
1. Add entry in `WholphinDataStore.proto` & build to generate classes
2. Add new `AppPreference` object in `AppPreference.kt`
3. Add new object to a `PreferenceGroup` (listed in `AppPreference.kt`)
4. Update `AppPreferencesSerializer` to set the default value for new installs
5. If needed, update `AppUpgradeHandler` to set the default value for app upgrades
- Since preferences use proto3, the [default values](https://protobuf.dev/programming-guides/proto3/#default) are zero, false, or the first enum, so only need this step if the default value is different

View file

@ -39,6 +39,7 @@ import com.github.damontecres.wholphin.services.DeviceProfileService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.UpdateChecker
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
@ -93,6 +94,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var imageUrlService: ImageUrlService
@Inject
lateinit var refreshRateService: RefreshRateService
@OptIn(ExperimentalTvMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -101,6 +105,14 @@ class MainActivity : AppCompatActivity() {
if (savedInstanceState == null) {
appUpgradeHandler.copySubfont(false)
}
refreshRateService.refreshRateMode.observe(this) { mode ->
// Listen for refresh rate changes
val attrs = window.attributes
if (attrs.preferredDisplayModeId != mode.modeId) {
Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate)
window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate }
}
}
setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
appPreferences?.let { appPreferences ->

View file

@ -677,6 +677,18 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.SUBTITLES),
)
val RefreshRateSwitching =
AppSwitchPreference<AppPreferences>(
title = R.string.refresh_rate_switching,
defaultValue = false,
getter = { it.playbackPreferences.refreshRateSwitching },
setter = { prefs, value ->
prefs.updatePlaybackPreferences { refreshRateSwitching = value }
},
summaryOn = R.string.automatic,
summaryOff = R.string.disabled,
)
val PlayerBackendPref =
AppChoicePreference<AppPreferences, PlayerBackend>(
title = R.string.player_backend,
@ -860,13 +872,22 @@ val advancedPreferences =
listOf(
AppPreference.OneClickPause,
AppPreference.GlobalContentScale,
AppPreference.MaxBitrate,
AppPreference.RefreshRateSwitching,
AppPreference.PlaybackDebugInfo,
),
),
)
add(
PreferenceGroup(
title = R.string.skip,
preferences =
listOf(
AppPreference.SkipIntros,
AppPreference.SkipOutros,
AppPreference.SkipCommercials,
AppPreference.SkipPreviews,
AppPreference.SkipRecaps,
AppPreference.MaxBitrate,
AppPreference.PlaybackDebugInfo,
),
),
)

View file

@ -50,6 +50,8 @@ class AppPreferencesSerializer
AppPreference.PassOutProtection.defaultValue.hours.inWholeMilliseconds
showNextUpWhen = AppPreference.ShowNextUpTiming.defaultValue
playerBackend = AppPreference.PlayerBackendPref.defaultValue
refreshRateSwitching =
AppPreference.RefreshRateSwitching.defaultValue
overrides =
PlaybackOverrides

View file

@ -0,0 +1,111 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import android.hardware.display.DisplayManager
import android.os.Build
import android.view.Display
import androidx.lifecycle.LiveData
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import timber.log.Timber
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.seconds
@Singleton
class RefreshRateService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
) {
private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY)
private val originalMode = display.mode
private val _refreshRateMode = EqualityMutableLiveData<Display.Mode>(originalMode)
val refreshRateMode: LiveData<Display.Mode> = _refreshRateMode
/**
* Find the best display mode for the given stream and signal to change to it
*/
suspend fun changeRefreshRate(stream: MediaStream) {
require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" }
val width = stream.width
val height = stream.height
val frameRate = stream.realFrameRate?.times(100)?.roundToInt()
if (width == null || height == null || frameRate == null) {
Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate)
return
}
Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate)
val targetMode =
display.supportedModes
.filterNot { it.physicalHeight < height || it.physicalWidth < width }
.filter {
(it.refreshRate * 100).roundToInt().let { modeRate ->
frameRate % modeRate == 0 || // Exact multiple
modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps
}
}.maxByOrNull { it.physicalWidth * it.physicalHeight }
Timber.i("Found display mode: %s, current=${display.mode}", targetMode)
if (targetMode != null && targetMode != display.mode) {
val listener = Listener(display.displayId)
displayManager.registerDisplayListener(listener, null)
_refreshRateMode.setValueOnMain(targetMode)
try {
listener.latch.await(5, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
Timber.w("Timed out waiting for display change")
}
val targetRate = (targetMode.refreshRate * 100).roundToInt()
val isSeamless =
targetRate == (display.mode.refreshRate * 100).roundToInt() ||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
display.mode.alternativeRefreshRates
.map { (it * 100).roundToInt() }
.any { targetRate % it == 0 }
} else {
false
}
if (!isSeamless) {
Timber.v("Waiting for non-seamless switch")
// Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate)
delay(2.seconds)
}
displayManager.unregisterDisplayListener(listener)
}
}
/**
* Reset the display mode to the original
*/
fun resetRefreshRate() {
_refreshRateMode.value = originalMode
}
private class Listener(
val displayId: Int,
) : DisplayManager.DisplayListener {
val latch = CountDownLatch(1)
override fun onDisplayAdded(displayId: Int) {
}
override fun onDisplayChanged(displayId: Int) {
if (displayId == this.displayId) {
Timber.v("Got display change for $displayId")
latch.countDown()
}
}
override fun onDisplayRemoved(displayId: Int) {
}
}
}

View file

@ -42,6 +42,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.onMain
@ -121,6 +122,7 @@ class PlaybackViewModel
private val datePlayedService: DatePlayedService,
private val deviceInfo: DeviceInfo,
private val deviceProfileService: DeviceProfileService,
private val refreshRateService: RefreshRateService,
) : ViewModel(),
Player.Listener,
AnalyticsListener {
@ -184,6 +186,9 @@ class PlaybackViewModel
) {
nextUp.value = null
this.preferences = preferences
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
addCloseable { refreshRateService.resetRefreshRate() }
}
controllerViewState.hideMilliseconds =
preferences.appPreferences.playbackPreferences.controllerTimeoutMs
this.forceTranscoding =
@ -611,6 +616,11 @@ class PlaybackViewModel
}
}
if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) {
source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let {
refreshRateService.changeRefreshRate(it)
}
}
withContext(Dispatchers.Main) {
// TODO, don't need to release & recreate when switching streams
this@PlaybackViewModel.activityListener?.let {

View file

@ -71,6 +71,7 @@ message PlaybackPreferences {
bool one_click_pause = 19;
PlayerBackend player_backend = 20;
MpvOptions mpv_options = 21;
bool refresh_rate_switching = 22;
}
message HomePagePreferences{

View file

@ -350,10 +350,11 @@
<string name="cast_and_crew"><![CDATA[Cast & Crew]]></string>
<string name="guest_stars">Guest stars</string>
<string name="edge_size">Edge size</string>
<string name="refresh_rate_switching">Refresh rate switching</string>
<string name="automatic">Automatic</string>
<string name="username_or_password">Use username/password</string>
<string name="login">Login</string>
<string-array name="theme_song_volume">
<item>Disabled</item>
<item>Lowest</item>