Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82
2 changed files with 109 additions and 9 deletions
|
|
@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue