diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a97bc36..cb130157 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -38,7 +38,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" - android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> 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 c21aa161..4e6e22b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -114,12 +115,12 @@ class MainActivity : AppCompatActivity() { if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) } - refreshRateService.refreshRateMode.observe(this) { mode -> + refreshRateService.refreshRateMode.observe(this) { modeId -> // 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 } + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } } } viewModel.appStart() @@ -318,6 +319,11 @@ class MainActivity : AppCompatActivity() { Timber.d("onDestroy") } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Timber.d("onConfigurationChanged") + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) Timber.v("onNewIntent") 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 6bca9d38..4b08b226 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 @@ -696,7 +696,25 @@ sealed interface AppPreference { defaultValue = false, getter = { it.playbackPreferences.refreshRateSwitching }, setter = { prefs, value -> - prefs.updatePlaybackPreferences { refreshRateSwitching = value } + prefs.updatePlaybackPreferences { + if (!value) resolutionSwitching = false + refreshRateSwitching = value + } + }, + summaryOn = R.string.automatic, + summaryOff = R.string.disabled, + ) + + val ResolutionSwitching = + AppSwitchPreference( + title = R.string.resolution_switching, + defaultValue = false, + getter = { it.playbackPreferences.resolutionSwitching }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { + if (value) refreshRateSwitching = true + resolutionSwitching = value + } }, summaryOn = R.string.automatic, summaryOff = R.string.disabled, @@ -934,6 +952,7 @@ val advancedPreferences = AppPreference.GlobalContentScale, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, + AppPreference.ResolutionSwitching, 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 c39b9012..f84744c7 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 @@ -52,6 +52,7 @@ class AppPreferencesSerializer playerBackend = AppPreference.PlayerBackendPref.defaultValue refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue + resolutionSwitching = AppPreference.ResolutionSwitching.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 index e34d2fa1..67b541fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -32,41 +32,61 @@ class RefreshRateService private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val originalMode = display.mode - val displayModes get() = display.supportedModes + val supportedDisplayModes get() = display.supportedModes.orEmpty() - private val _refreshRateMode = EqualityMutableLiveData(originalMode) - val refreshRateMode: LiveData = _refreshRateMode + private val displayModes: List by lazy { + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + } + + private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId) + val refreshRateMode: LiveData = _refreshRateMode /** * Find the best display mode for the given stream and signal to change to it */ - suspend fun changeRefreshRate(stream: MediaStream) { + suspend fun changeRefreshRate( + stream: MediaStream, + switchRefreshRate: Boolean, + switchResolution: Boolean, + ) { + if (!switchRefreshRate && !switchResolution) { + Timber.v("Not switching either refresh rate nor resolution") + return + } + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width val height = stream.height - val frameRate = stream.realFrameRate?.times(100)?.roundToInt() + val frameRate = + if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate 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) { + findDisplayMode( + displayModes = displayModes, + streamWidth = width, + streamHeight = height, + targetFrameRate = frameRate, + refreshRateSwitch = switchRefreshRate, + resolutionSwitch = switchResolution, + ) + Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode) + if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) { val listener = Listener(display.displayId) displayManager.registerDisplayListener( listener, Handler(Looper.myLooper() ?: Looper.getMainLooper()), ) - _refreshRateMode.setValueOnMain(targetMode) + _refreshRateMode.setValueOnMain(targetMode.modeId) try { if (!listener.latch.await(5, TimeUnit.SECONDS)) { Timber.w("Timed out waiting for display change") @@ -98,7 +118,7 @@ class RefreshRateService * Reset the display mode to the original */ fun resetRefreshRate() { - _refreshRateMode.value = originalMode + _refreshRateMode.value = originalMode.modeId } private class Listener( @@ -119,4 +139,69 @@ class RefreshRateService override fun onDisplayRemoved(displayId: Int) { } } + + companion object { + /** + * Find the best display mode for the given stream & preferences + * + * @param displayModes candidates that are sorted by resolution and frame rate descending + */ + fun findDisplayMode( + displayModes: List, + streamWidth: Int, + streamHeight: Int, + targetFrameRate: Float, + refreshRateSwitch: Boolean, + resolutionSwitch: Boolean, + ): DisplayMode? { + val streamRate = targetFrameRate.times(1000).roundToInt() +// Timber.v("display modes: %s", displayModes.joinToString("\n")) + val candidates = + if (refreshRateSwitch) { + displayModes + .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } + .filter { + it.refreshRateRounded % streamRate == 0 || // Exact multiple + it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } + } else { + displayModes + .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } + } +// Timber.v("display modes candidates: %s", candidates.joinToString("\n")) + return if (!resolutionSwitch) { + candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + } else { + candidates.firstOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + it.refreshRateRounded == streamRate + } + ?: candidates.firstOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight >= streamHeight && + it.refreshRateRounded == streamRate + } + ?: candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + } + } + } } + +data class DisplayMode( + val modeId: Int, + val physicalWidth: Int, + val physicalHeight: Int, + val refreshRate: Float, +) { + val refreshRateRounded: Int = (refreshRate * 1000).roundToInt() + + constructor(mode: Display.Mode) : this( + mode.modeId, + mode.physicalWidth, + mode.physicalHeight, + mode.refreshRate, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 60ad839e..f0ad2c0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -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(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index 496166c3..ce31d865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -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(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>, 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, ) } } 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 1d380e08..39cee993 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 @@ -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(), ) } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f10b49c5..e4cd52d7 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -72,6 +72,7 @@ message PlaybackPreferences { PlayerBackend player_backend = 20; MpvOptions mpv_options = 21; bool refresh_rate_switching = 22; + bool resolution_switching = 23; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47122cca..d4e4a859 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -398,6 +398,7 @@ Color-code programs Subtitle delay Backdrop style + Resolution switching Disabled diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt new file mode 100644 index 00000000..7af1bfce --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -0,0 +1,124 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.services.DisplayMode +import com.github.damontecres.wholphin.services.RefreshRateService +import org.junit.Assert +import org.junit.Test + +class TestDisplayModeChoice { + companion object { + val HD_60 = DisplayMode(0, 1920, 1080, 60f) + val HD_30 = DisplayMode(1, 1920, 1080, 30f) + val HD_24 = DisplayMode(2, 1920, 1080, 24f) + + val UHD_60 = DisplayMode(3, 3840, 2160, 60f) + val UHD_30 = DisplayMode(4, 3840, 2160, 30f) + val UHD_24 = DisplayMode(5, 3840, 2160, 24f) + + val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + } + + @Test + fun test1() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 60f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(3, result?.modeId) + } + + @Test + fun test2() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 60f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(0, result?.modeId) + } + + @Test + fun test3() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 30f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(4, result?.modeId) + } + + @Test + fun test4() { + val streamWidth = 1920 + val streamHeight = 804 + val streamRealFrameRate = 30f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals(1, result?.modeId) + } + + @Test + fun testFraction() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 30f + + val displayModes = + listOf( + DisplayMode(0, 1920, 1080, 59.940f), + DisplayMode(1, 1920, 1080, 60f), +// DisplayMode(2, 1920, 1080, 29.970f), + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = 29.970f, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(0, result?.modeId) + + val result2 = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = 24f, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(1, result2?.modeId) + } +}