diff --git a/app/src/main/java/com/limelight/DisplayModeManager.kt b/app/src/main/java/com/limelight/DisplayModeManager.kt index 0274441dc7..5514224efe 100644 --- a/app/src/main/java/com/limelight/DisplayModeManager.kt +++ b/app/src/main/java/com/limelight/DisplayModeManager.kt @@ -2,10 +2,10 @@ package com.limelight import android.os.Build import android.view.Display +import androidx.annotation.RequiresApi import com.limelight.preferences.PreferenceConfiguration import com.limelight.utils.UiHelper import kotlin.math.abs -import kotlin.math.roundToInt /** * 显示模式管理器 @@ -25,11 +25,11 @@ object DisplayModeManager { ) fun isRefreshRateEqualMatch(refreshRate: Float, targetFps: Int): Boolean { - return refreshRate >= targetFps && refreshRate <= targetFps + 3 + return DisplayModePolicy.isRefreshRateEqualMatch(refreshRate, targetFps) } fun isRefreshRateGoodMatch(refreshRate: Float, targetFps: Int): Boolean { - return refreshRate >= targetFps && refreshRate.roundToInt() % targetFps <= 3 + return DisplayModePolicy.isRefreshRateGoodMatch(refreshRate, targetFps) } fun mayReduceRefreshRate(prefConfig: PreferenceConfiguration): Boolean { @@ -74,84 +74,36 @@ object DisplayModeManager { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val supportedModes = display.supportedModes - val eligibleModes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && - acceptableHdrTypes.isNotEmpty() - ) { - supportedModes.filter { mode -> - mode.supportedHdrTypes.any { supportedType -> - acceptableHdrTypes.any { it == supportedType } - } - }.ifEmpty { - LimeLog.warning("No display mode supports the requested HDR type; using normal mode selection") - supportedModes.asList() - } + val isNativeResolutionStream = prefConfig.usesNativeDisplayMode + val effectiveHdrTypes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + acceptableHdrTypes.toList() } else { - supportedModes.asList() + emptyList() } - - // Start from the mode Android is actually using. A candidate may be - // rejected by the resolution/refresh-rate constraints below; in that - // case we must keep the current mode instead of retaining an arbitrary - // first HDR-eligible mode that was never validated by those constraints. - var bestMode = display.mode - val isNativeResolutionStream = prefConfig.usesNativeDisplayMode - var refreshRateIsGood = isRefreshRateGoodMatch(bestMode.refreshRate, prefConfig.fps) - var refreshRateIsEqual = isRefreshRateEqualMatch(bestMode.refreshRate, prefConfig.fps) - - LimeLog.info("Current display mode: ${bestMode.physicalWidth}x${bestMode.physicalHeight}x${bestMode.refreshRate}") - - for (candidate in eligibleModes) { - val refreshRateReduced = candidate.refreshRate < bestMode.refreshRate - val resolutionReduced = candidate.physicalWidth < bestMode.physicalWidth || - candidate.physicalHeight < bestMode.physicalHeight - val resolutionFitsStream = candidate.physicalWidth >= prefConfig.width && - candidate.physicalHeight >= prefConfig.height - - LimeLog.info("Examining display mode: ${candidate.physicalWidth}x${candidate.physicalHeight}x${candidate.refreshRate}") - - if (candidate.physicalWidth > 4096 && prefConfig.width <= 4096) { - continue - } - - if (prefConfig.width < 3840 && prefConfig.fps <= 60 && !isNativeResolutionStream) { - if (display.mode.physicalWidth != candidate.physicalWidth || - display.mode.physicalHeight != candidate.physicalHeight - ) { - continue - } - } - - if (resolutionReduced && !(prefConfig.fps > 60 && resolutionFitsStream)) { - continue - } - - if (mayReduceRefreshRate(prefConfig) && refreshRateIsEqual && !isRefreshRateEqualMatch(candidate.refreshRate, prefConfig.fps)) { - continue - } else if (refreshRateIsGood) { - if (!isRefreshRateGoodMatch(candidate.refreshRate, prefConfig.fps)) { - continue - } - - if (mayReduceRefreshRate(prefConfig)) { - if (candidate.refreshRate > bestMode.refreshRate) { - continue - } - } else { - if (refreshRateReduced) { - continue - } - } - } else if (!isRefreshRateGoodMatch(candidate.refreshRate, prefConfig.fps)) { - if (refreshRateReduced) { - continue - } - } - - bestMode = candidate - refreshRateIsGood = isRefreshRateGoodMatch(candidate.refreshRate, prefConfig.fps) - refreshRateIsEqual = isRefreshRateEqualMatch(candidate.refreshRate, prefConfig.fps) + val currentMode = display.mode.toPolicyMode() + val policyResult = DisplayModePolicy.selectBestMode( + currentMode = currentMode, + supportedModes = supportedModes.map { it.toPolicyMode() }, + request = DisplayModePolicy.Request( + width = prefConfig.width, + height = prefConfig.height, + fps = prefConfig.fps, + usesNativeDisplayMode = isNativeResolutionStream, + mayReduceRefreshRate = mayReduceRefreshRate(prefConfig), + acceptableHdrTypes = effectiveHdrTypes, + ), + ) + if (effectiveHdrTypes.isNotEmpty() && !policyResult.hdrFilterApplied) { + LimeLog.warning("No display mode supports the requested HDR type; using normal mode selection") + } else if (effectiveHdrTypes.isNotEmpty() && + policyResult.mode.hdrTypes.none(effectiveHdrTypes::contains) + ) { + LimeLog.warning("HDR-capable modes exist but none met the display mode constraints") } + val bestMode = supportedModes.firstOrNull { it.modeId == policyResult.mode.id } ?: display.mode + LimeLog.info("Current display mode: ${display.mode.physicalWidth}x${display.mode.physicalHeight}x${display.mode.refreshRate}") + LimeLog.info("Best display mode: ${bestMode.physicalWidth}x${bestMode.physicalHeight}x${bestMode.refreshRate}") if (display.mode.modeId != bestMode.modeId) { @@ -220,4 +172,19 @@ object DisplayModeManager { aspectRatioMatch = aspectRatioMatch ) } + + @RequiresApi(Build.VERSION_CODES.M) + private fun Display.Mode.toPolicyMode(): DisplayModePolicy.Mode { + return DisplayModePolicy.Mode( + id = modeId, + width = physicalWidth, + height = physicalHeight, + refreshRate = refreshRate, + hdrTypes = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + supportedHdrTypes.toList() + } else { + emptyList() + }, + ) + } } diff --git a/app/src/main/java/com/limelight/DisplayModePolicy.kt b/app/src/main/java/com/limelight/DisplayModePolicy.kt new file mode 100644 index 0000000000..de477781f0 --- /dev/null +++ b/app/src/main/java/com/limelight/DisplayModePolicy.kt @@ -0,0 +1,115 @@ +package com.limelight + +import kotlin.math.roundToInt + +/** Pure display-mode selection policy, independent of Android framework objects. */ +internal object DisplayModePolicy { + data class Mode( + val id: Int, + val width: Int, + val height: Int, + val refreshRate: Float, + val hdrTypes: List = emptyList(), + ) + + data class Request( + val width: Int, + val height: Int, + val fps: Int, + val usesNativeDisplayMode: Boolean, + val mayReduceRefreshRate: Boolean, + val acceptableHdrTypes: List = emptyList(), + ) + + data class Result( + val mode: Mode, + val hdrFilterApplied: Boolean, + ) + + fun selectBestMode( + currentMode: Mode, + supportedModes: List, + request: Request, + ): Result { + val hdrModes = if (request.acceptableHdrTypes.isNotEmpty()) { + supportedModes.filter { it.supportsAny(request.acceptableHdrTypes) } + } else { + emptyList() + } + val hdrFilterApplied = hdrModes.isNotEmpty() + val eligibleModes = if (hdrFilterApplied) hdrModes else supportedModes + + // An HDR request must be optimized entirely within the matching HDR set. Keep the + // current mode as the baseline only when it belongs to that set; otherwise the first + // candidate that passes the resolution constraints establishes the HDR baseline. + var bestMode: Mode? = currentMode.takeIf { !hdrFilterApplied || it.supportsAny(request.acceptableHdrTypes) } + var refreshRateIsGood = bestMode?.let { isRefreshRateGoodMatch(it.refreshRate, request.fps) } ?: false + var refreshRateIsEqual = bestMode?.let { isRefreshRateEqualMatch(it.refreshRate, request.fps) } ?: false + + for (candidate in eligibleModes) { + val comparisonMode = bestMode ?: currentMode + val resolutionReduced = candidate.width < comparisonMode.width || + candidate.height < comparisonMode.height + val resolutionFitsStream = candidate.width >= request.width && + candidate.height >= request.height + + if (candidate.width > 4096 && request.width <= 4096) { + continue + } + + if (request.width < 3840 && request.fps <= 60 && !request.usesNativeDisplayMode && + (currentMode.width != candidate.width || currentMode.height != candidate.height) + ) { + continue + } + + if (resolutionReduced && !(request.fps > 60 && resolutionFitsStream)) { + continue + } + + // There is no valid HDR baseline yet. The first resolution-compatible HDR mode + // must not be compared against an SDR mode's refresh rate. + if (bestMode != null) { + val refreshRateReduced = candidate.refreshRate < bestMode.refreshRate + + if (request.mayReduceRefreshRate && refreshRateIsEqual && + !isRefreshRateEqualMatch(candidate.refreshRate, request.fps) + ) { + continue + } else if (refreshRateIsGood) { + if (!isRefreshRateGoodMatch(candidate.refreshRate, request.fps)) { + continue + } + + if (request.mayReduceRefreshRate) { + if (candidate.refreshRate > bestMode.refreshRate) { + continue + } + } else if (refreshRateReduced) { + continue + } + } else if (!isRefreshRateGoodMatch(candidate.refreshRate, request.fps) && refreshRateReduced) { + continue + } + } + + bestMode = candidate + refreshRateIsGood = isRefreshRateGoodMatch(candidate.refreshRate, request.fps) + refreshRateIsEqual = isRefreshRateEqualMatch(candidate.refreshRate, request.fps) + } + + return Result(bestMode ?: currentMode, hdrFilterApplied) + } + + fun isRefreshRateEqualMatch(refreshRate: Float, targetFps: Int): Boolean { + return refreshRate >= targetFps && refreshRate <= targetFps + 3 + } + + fun isRefreshRateGoodMatch(refreshRate: Float, targetFps: Int): Boolean { + return refreshRate >= targetFps && refreshRate.roundToInt() % targetFps <= 3 + } + + private fun Mode.supportsAny(acceptableHdrTypes: List): Boolean { + return hdrTypes.any(acceptableHdrTypes::contains) + } +} \ No newline at end of file diff --git a/app/src/main/jni/moonlight-core/Android.mk b/app/src/main/jni/moonlight-core/Android.mk index e2d6c1f328..b8f463bbda 100644 --- a/app/src/main/jni/moonlight-core/Android.mk +++ b/app/src/main/jni/moonlight-core/Android.mk @@ -18,6 +18,8 @@ LOCAL_SRC_FILES := moonlight-common-c/src/AudioStream.c \ moonlight-common-c/src/ConnectionTester.c \ moonlight-common-c/src/ControlStream.c \ moonlight-common-c/src/CursorStream.c \ + moonlight-common-c/src/Ds5HapticsIrStream.c \ + moonlight-common-c/src/Ds5HapticsStream.c \ moonlight-common-c/src/FakeCallbacks.c \ moonlight-common-c/src/InputStream.c \ moonlight-common-c/src/LinkedBlockingQueue.c \ diff --git a/app/src/main/jni/moonlight-core/moonlight-common-c b/app/src/main/jni/moonlight-core/moonlight-common-c index f894aabf2d..72733e3a47 160000 --- a/app/src/main/jni/moonlight-core/moonlight-common-c +++ b/app/src/main/jni/moonlight-core/moonlight-common-c @@ -1 +1 @@ -Subproject commit f894aabf2d4c33e3c437e01ffca758260aa04f0e +Subproject commit 72733e3a47fc7823e7e0b1b0cef2e3d101b0399b diff --git a/app/src/test/java/com/limelight/DisplayModePolicyTest.kt b/app/src/test/java/com/limelight/DisplayModePolicyTest.kt new file mode 100644 index 0000000000..acb8171f92 --- /dev/null +++ b/app/src/test/java/com/limelight/DisplayModePolicyTest.kt @@ -0,0 +1,105 @@ +package com.limelight + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DisplayModePolicyTest { + @Test + fun selectsLowerRefreshHdrModeWhenCurrentModeDoesNotSupportHdr() { + val currentSdrMode = mode(id = 1, refreshRate = 120f) + val hdrMode = mode(id = 2, refreshRate = 60f, hdrTypes = listOf(HDR10)) + + val result = DisplayModePolicy.selectBestMode( + currentMode = currentSdrMode, + supportedModes = listOf(currentSdrMode, hdrMode), + request = request(acceptableHdrTypes = listOf(HDR10)), + ) + + assertEquals(hdrMode, result.mode) + assertTrue(result.hdrFilterApplied) + } + + @Test + fun keepsCurrentModeWhenItSupportsRequestedHdrType() { + val currentHdrMode = mode(id = 1, refreshRate = 120f, hdrTypes = listOf(HDR10)) + val lowerRefreshHdrMode = mode(id = 2, refreshRate = 60f, hdrTypes = listOf(HDR10)) + + val result = DisplayModePolicy.selectBestMode( + currentMode = currentHdrMode, + supportedModes = listOf(currentHdrMode, lowerRefreshHdrMode), + request = request(acceptableHdrTypes = listOf(HDR10)), + ) + + assertEquals(currentHdrMode, result.mode) + assertTrue(result.hdrFilterApplied) + } + + @Test + fun fallsBackToNormalSelectionWhenNoModeSupportsRequestedHdrType() { + val currentSdrMode = mode(id = 1, refreshRate = 60f) + val fasterSdrMode = mode(id = 2, refreshRate = 120f) + + val result = DisplayModePolicy.selectBestMode( + currentMode = currentSdrMode, + supportedModes = listOf(currentSdrMode, fasterSdrMode), + request = request(acceptableHdrTypes = listOf(HDR10)), + ) + + assertEquals(fasterSdrMode, result.mode) + assertFalse(result.hdrFilterApplied) + } + + @Test + fun doesNotRetainHdrCandidateThatViolatesResolutionConstraints() { + val currentSdrMode = mode(id = 1, width = 3840, height = 2160, refreshRate = 120f) + val undersizedHdrMode = mode( + id = 2, + width = 1920, + height = 1080, + refreshRate = 60f, + hdrTypes = listOf(HDR10), + ) + + val result = DisplayModePolicy.selectBestMode( + currentMode = currentSdrMode, + supportedModes = listOf(currentSdrMode, undersizedHdrMode), + request = request(acceptableHdrTypes = listOf(HDR10)), + ) + + assertEquals(currentSdrMode, result.mode) + assertTrue(result.hdrFilterApplied) + } + + @Test + fun modeEqualityUsesHdrTypeValues() { + assertEquals( + mode(id = 1, refreshRate = 60f, hdrTypes = listOf(HDR10)), + mode(id = 1, refreshRate = 60f, hdrTypes = listOf(HDR10)), + ) + } + + private fun mode( + id: Int, + width: Int = 3840, + height: Int = 2160, + refreshRate: Float, + hdrTypes: List = emptyList(), + ) = DisplayModePolicy.Mode(id, width, height, refreshRate, hdrTypes) + + private fun request( + acceptableHdrTypes: List, + ) = DisplayModePolicy.Request( + width = 3840, + height = 2160, + fps = 60, + usesNativeDisplayMode = true, + mayReduceRefreshRate = false, + acceptableHdrTypes = acceptableHdrTypes, + ) + + private companion object { + const val HDR10 = 2 + } +} \ No newline at end of file