From 20bef8fb6a9a75be6177b872c66a9c1701e8f648 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Sat, 6 Dec 2025 16:35:43 -0500 Subject: [PATCH] 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 --- DEVELOPMENT.md | 15 +++ .../damontecres/wholphin/MainActivity.kt | 12 ++ .../wholphin/preferences/AppPreference.kt | 25 +++- .../preferences/AppPreferencesSerializer.kt | 2 + .../wholphin/services/RefreshRateService.kt | 111 ++++++++++++++++++ .../wholphin/ui/playback/PlaybackViewModel.kt | 10 ++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 3 +- 8 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6d075fd8..1a723a9e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 341a120a..077966a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -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 -> 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 4513dd4b..4e182e0c 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 @@ -677,6 +677,18 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), ) + val RefreshRateSwitching = + AppSwitchPreference( + 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( 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, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 247a5e49..e3223663 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt new file mode 100644 index 00000000..190bf21b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -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(originalMode) + val refreshRateMode: LiveData = _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) { + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 0ae3b2b2..7023b2bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -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 { diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f5abed11..fe601b0b 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -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{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c54c7fb..678c8d5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -350,10 +350,11 @@ Guest stars Edge size + Refresh rate switching + Automatic Use username/password Login - Disabled Lowest