From bfa9fbc941c43336b7eaa8959a7b9ea88af23980 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 05:33:54 +0100 Subject: [PATCH] Keep playback black through native scene handoff --- .../wolfskin/tv/NavigationCutTest.kt | 86 ++++++++++++++++++- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 32 +++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt index 443a7258..a8885d7f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -35,7 +36,9 @@ import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode -/** Native cuts paint by 32 ms; outgoing semantics retire by 64 ms without any whole-screen alpha. */ +private const val PLAYER_PAINT = 0xff204060 + +/** Native forward cuts paint by 32 ms, Back by 64 ms; all outgoing semantics retire by 64 ms. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -44,7 +47,7 @@ class NavigationCutTest { private lateinit var host: PolishAppHost @After fun dispose() { if (::host.isInitialized) host.close() } - @Test fun titleBackAndTabPaintOpaqueCutsWithinTwoFrames() = cuts(false) + @Test fun titleAndTabPaintWithinTwoFramesBackWithinFour() = cuts(false) @Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true) private fun cuts(reduced: Boolean) { @@ -109,7 +112,8 @@ class NavigationCutTest { val incoming = composed && !paint.contentEquals(outgoingPaint) if (incoming && firstIncoming == null) { firstIncoming = index - assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), not within two frames", index <= 2) + val limit = if (phase == "back") 4 else 2 + assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), exceeding $limit frames", index <= limit) } if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming) if (index >= 4) { @@ -171,6 +175,72 @@ class NavigationCutTest { assertFalse(rule.activity.isFinishing) } + @Test fun fullRootPlaybackHandoffNeverRepaintsHome() = rootHandoff(false) + @Test fun reducedMotionFullRootHandoffNeverRepaintsHome() = rootHandoff(true) + + private fun rootHandoff(reduced: Boolean) { + host = PolishAppHost(rule) + host.playerReady = false + host.mount(picker = false, reduced = reduced) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/root-" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath) + rule.mainClock.autoAdvance = false + val destination = Destination.Playback(UUID.randomUUID(), 0L) + rule.runOnIdle { + host.session.playback.begin("hero-play") + host.session.playback.ready { host.navigation.navigateTo(destination) } + } + val origin = rule.mainClock.currentTime + val samples = mutableListOf() + var navigationTime: Long? = null + var playerPainted = false + try { + repeat(21) { index -> + if (index > 0) rule.mainClock.advanceTimeByFrame() + val file = File(directory, "handoff-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + val logicalPlayer = host.navigation.backStack.last() == destination + val time = rule.mainClock.currentTime - origin + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + val pixel = image.getPixel(600, 500) + val avatar = image.getPixel(1774, 90) + samples.add("$time,$logicalPlayer,$pixel,$avatar") + if (logicalPlayer) { + if (navigationTime == null) navigationTime = time + assertTrue("After handoff only opaque black or the incoming player may paint; never old Home", + pixel == android.graphics.Color.BLACK || pixel == PLAYER_PAINT.toInt()) + assertEquals("The old Home nav cannot flash through the completed veil", pixel, avatar) + assertEquals(pixel, image.getPixel(100, 900)) + assertEquals(pixel, image.getPixel(1800, 900)) + if (pixel == PLAYER_PAINT.toInt()) playerPainted = true + if (pixel == android.graphics.Color.BLACK && time == navigationTime + 16) { + val revision = host.session.state!!.inputRevision + rule.onRoot().performKeyInput { + pressKey(Key.DirectionRight, 0L); pressKey(Key.DirectionCenter, 0L); pressKey(Key.Escape, 0L) + } + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertEquals("Keys cannot reach old Home during the pending native player scene", revision, host.session.state!!.inputRevision) + assertEquals(destination, host.navigation.backStack.last()) + } + if (time == navigationTime + 48) rule.runOnIdle { + host.playerReady = true + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + } + } + } finally { image.recycle() } + assertEquals("The full-root probe cannot warm the manual clock", origin + index * 16L, rule.mainClock.currentTime) + } + assertTrue("The incoming player must eventually be uncovered", playerPainted) + assertEquals(2, host.navigation.backStack.size) + rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0) + assertNull(host.session.playback.starting) + assertNull(host.session.playback.launch) + } finally { + File(directory, "samples.csv").writeText("timeMs,logicalPlayer,bodyPixel,avatarPixel\n" + samples.joinToString("\n")) + } + } + @Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false) @Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true) @@ -260,6 +330,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule(relaxed = true) + every { backdrop.backdropFlow } returns kotlinx.coroutines.flow.MutableStateFlow(BackdropResult.NONE) coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) } coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } every { setup.navigateTo(any()) } answers { @@ -281,7 +354,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule + assertTrue(destination is Destination.Playback) + androidx.compose.foundation.layout.Box(modifier.then(if (playerReady) Modifier.background(androidx.compose.ui.graphics.Color(PLAYER_PAINT)) else Modifier) + .testTag("polish-player")) }) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 9ba1c4ee..20990590 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -5,17 +5,21 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -293,6 +297,8 @@ fun WolfskinApp( { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, titleContent: @Composable (String, ShellState, UserPreferences) -> Unit = { route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) }, + fullScreenContent: @Composable (Destination, Modifier) -> Unit = + { destination, contentModifier -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() @@ -330,12 +336,21 @@ fun WolfskinApp( commit.fallback?.invoke() } } + var composedDestination by remember(server.id, user.id, navigationManager) { mutableStateOf(null) } + val playerEntryPending by remember(server.id, user.id, navigationManager) { + derivedStateOf { + val target = navigationManager.backStack.lastOrNull() + target is Destination.Playback && composedDestination != target + } + } + // Keep input blocked until the composition that removes the black cover has actually replaced it. + val playerCoverVisible = playerEntryPending WolfskinTheme { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback, org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { Box(modifier.onPreviewKeyEvent { event -> - if (session.personCommit != null || session.playback.launch != null) true else { + if (session.personCommit != null || session.playback.launch != null || playerEntryPending || playerCoverVisible) true else { if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail() false } @@ -352,7 +367,7 @@ fun WolfskinApp( } NavDisplay( backStack = navigationManager.backStack, - onBack = { if (session.personCommit == null && session.playback.launch == null) { session.playback.fail(); onBack() } }, + onBack = { if (session.personCommit == null && session.playback.launch == null && !playerEntryPending && !playerCoverVisible) { session.playback.fail(); onBack() } }, transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) }, @@ -367,12 +382,15 @@ fun WolfskinApp( val discover = LocalDiscoverState.current val route = destination.toWolfskinRoute() val isCurrent = navigationManager.backStack.lastOrNull() == destination + if (isCurrent) SideEffect { composedDestination = destination } LaunchedEffect(destination, state, isCurrent) { if (isCurrent && !destination.fullScreen) state.enterDestination(route) } org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) { if (destination.fullScreen) { - DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) + // Loading, error and external-player content may not paint their own background. + fullScreenContent(destination, Modifier.fillMaxSize() + .then(if (destination is Destination.Playback) Modifier.background(Color.Black) else Modifier)) } else { val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { @@ -410,12 +428,16 @@ fun WolfskinApp( Modifier.pointerInput(session.playback.launch) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } }) + if (playerCoverVisible) Box(Modifier.fillMaxSize().background(Color.Black) + .pointerInput(Unit) { + awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } + }) if (commit != null) org.justinvisser.wolfskin.design.PersonCommitVeil(commit) - BackHandler(enabled = session.playback.starting != null && session.playback.launch == null) { + BackHandler(enabled = session.playback.starting != null && session.playback.launch == null && !playerCoverVisible) { session.playback.fail() onBack() } - BackHandler(enabled = commit != null || session.playback.launch != null) { } + BackHandler(enabled = commit != null || session.playback.launch != null || playerCoverVisible) { } } } }