Keep playback black through native scene handoff

This commit is contained in:
justinzeus 2026-11-08 05:33:54 +01:00
parent 156c140731
commit bfa9fbc941
2 changed files with 109 additions and 9 deletions

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.* import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule 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.Config
import org.robolectric.annotation.GraphicsMode 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) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE) @GraphicsMode(GraphicsMode.Mode.NATIVE)
@ -44,7 +47,7 @@ class NavigationCutTest {
private lateinit var host: PolishAppHost private lateinit var host: PolishAppHost
@After fun dispose() { if (::host.isInitialized) host.close() } @After fun dispose() { if (::host.isInitialized) host.close() }
@Test fun titleBackAndTabPaintOpaqueCutsWithinTwoFrames() = cuts(false) @Test fun titleAndTabPaintWithinTwoFramesBackWithinFour() = cuts(false)
@Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true) @Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true)
private fun cuts(reduced: Boolean) { private fun cuts(reduced: Boolean) {
@ -109,7 +112,8 @@ class NavigationCutTest {
val incoming = composed && !paint.contentEquals(outgoingPaint) val incoming = composed && !paint.contentEquals(outgoingPaint)
if (incoming && firstIncoming == null) { if (incoming && firstIncoming == null) {
firstIncoming = index 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 (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming)
if (index >= 4) { if (index >= 4) {
@ -171,6 +175,72 @@ class NavigationCutTest {
assertFalse(rule.activity.isFinishing) 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<String>()
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 setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false)
@Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true) @Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true)
@ -260,6 +330,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
private var current by mutableStateOf(users.first()) private var current by mutableStateOf(users.first())
val model get() = models.last() val model get() = models.last()
var reduceMotion by mutableStateOf(false) var reduceMotion by mutableStateOf(false)
var playerReady by mutableStateOf(true)
fun showUser(user: JellyfinUser) { current = user } fun showUser(user: JellyfinUser) { current = user }
@ -271,6 +342,8 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
oldScreens = WolfskinScreenPreference.enabled oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(fixtures) ConductarrPreference.updateFixtures(fixtures)
WolfskinScreenPreference.enabled = true WolfskinScreenPreference.enabled = true
val backdrop = mockk<BackdropService>(relaxed = true)
every { backdrop.backdropFlow } returns kotlinx.coroutines.flow.MutableStateFlow(BackdropResult.NONE)
coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) } coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) }
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
every { setup.navigateTo(any()) } answers { every { setup.navigateTo(any()) } answers {
@ -281,7 +354,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) { CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
key(current.id) { key(current.id) {
val model = remember { val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, backdrop,
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
} }
@ -304,6 +377,11 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
} }
androidx.activity.compose.BackHandler { title.back(shell) } androidx.activity.compose.BackHandler { title.back(shell) }
TitleSurface(title, shell) TitleSurface(title, shell)
},
fullScreenContent = { destination, modifier ->
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"))
}) })
} }
} }

View file

@ -5,17 +5,21 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
@ -293,6 +297,8 @@ fun WolfskinApp(
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
titleContent: @Composable (String, ShellState, UserPreferences) -> Unit = titleContent: @Composable (String, ShellState, UserPreferences) -> Unit =
{ route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) }, { 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 identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
@ -330,12 +336,21 @@ fun WolfskinApp(
commit.fallback?.invoke() commit.fallback?.invoke()
} }
} }
var composedDestination by remember(server.id, user.id, navigationManager) { mutableStateOf<Destination?>(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 { WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback, 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) { org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
Box(modifier.onPreviewKeyEvent { event -> 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() if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail()
false false
} }
@ -352,7 +367,7 @@ fun WolfskinApp(
} }
NavDisplay( NavDisplay(
backStack = navigationManager.backStack, 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) }, transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
predictivePopTransitionSpec = { _ -> (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 discover = LocalDiscoverState.current
val route = destination.toWolfskinRoute() val route = destination.toWolfskinRoute()
val isCurrent = navigationManager.backStack.lastOrNull() == destination val isCurrent = navigationManager.backStack.lastOrNull() == destination
if (isCurrent) SideEffect { composedDestination = destination }
LaunchedEffect(destination, state, isCurrent) { LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route) if (isCurrent && !destination.fullScreen) state.enterDestination(route)
} }
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) { org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
if (destination.fullScreen) { 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 { } else {
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
@ -410,12 +428,16 @@ fun WolfskinApp(
Modifier.pointerInput(session.playback.launch) { Modifier.pointerInput(session.playback.launch) {
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } 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) 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() session.playback.fail()
onBack() onBack()
} }
BackHandler(enabled = commit != null || session.playback.launch != null) { } BackHandler(enabled = commit != null || session.playback.launch != null || playerCoverVisible) { }
} }
} }
} }