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