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.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<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 reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true)
@ -260,6 +330,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
private var current by mutableStateOf(users.first())
val model get() = models.last()
var reduceMotion by mutableStateOf(false)
var playerReady by mutableStateOf(true)
fun showUser(user: JellyfinUser) { current = user }
@ -271,6 +342,8 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(fixtures)
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.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
every { setup.navigateTo(any()) } answers {
@ -281,7 +354,7 @@ internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivitySc
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
key(current.id) {
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),
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) }
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.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<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 {
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) { }
}
}
}