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.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"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue