mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
e669f3ad59
commit
20bef8fb6a
8 changed files with 176 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue