diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt index d2b7c41e..e4b86e5b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin import androidx.compose.animation.AnimatedVisibility +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 @@ -66,6 +69,9 @@ fun MainContent( NavDisplay( backStack = backStack, onBack = { backStack.removeLastOrNull() }, + transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, + popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) }, + predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) }, entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index 7842289d..b7e18bf8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest { coEvery { home.readLibrary(user.id) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) try { val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false } rule.setContent { WolfskinTheme { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt new file mode 100644 index 00000000..c545c680 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeHeroStartingTest.kt @@ -0,0 +1,166 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.BitmapFactory +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +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.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.io.IOException +import java.util.UUID +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.model.api.BaseItemDto +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.HomeReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Real Home actions keep button geometry, reject duplicate starts and launch only after the black handoff. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeHeroStartingTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var model: WolfskinHomeViewModel + private val conductarr = ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()) + private val session = WolfskinSession() + private val navigation = NavigationManager() + private val facade = mockk() + private val release = CompletableDeferred() + private val visible = mutableStateOf(true) + private var previousFixtures = false + private val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null) + private val shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false; pickerLaunch = false } + + @After fun dispose() { + release.complete(null) + if (::model.isInitialized) model.viewModelScope.cancel() + conductarr.close() + ConductarrPreference.updateFixtures(previousFixtures) + } + + private fun mount(reduced: Boolean = false) { + ConductarrPreference.initialize(rule.activity) + previousFixtures = ConductarrPreference.fixtures + ConductarrPreference.updateFixtures(true) + coEvery { facade.preparePlayback(user.id, any()) } coAnswers { release.await() } + every { facade.audiences } returns null + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), conductarr, session) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced, LocalHomePlaybackStart provides session.playback) { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { + if (visible.value && navigation.backStack.last() !is Destination.Playback) + ShellSurface(shell) { modifier -> HomeDestination(shell, server, user, modifier, model) } + PlaybackHandoffVeil(session.playback) + } + } } } + await { model.snapshot.rows.firstOrNull()?.let { !it.skeleton && it.watchReady } == true } + rule.mainClock.advanceTimeBy(1200) + rule.mainClock.autoAdvance = false + } + + @Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = successfulStart(false) + @Test fun reducedMotionCapsBothFadesAndStillStartsOnce() = successfulStart(true) + + private fun successfulStart(reduced: Boolean) { + mount(reduced) + val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width + press() + rule.mainClock.advanceTimeBy(32) + rule.onNodeWithText("Starting…").assertIsDisplayed() + assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f) + press() + assertTrue(navigation.backStack.single() is Destination.Home) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + val samples = mutableListOf() + fun frame(phase: String, index: Int): Int { + val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + val pixel = try { image.getPixel(600, 500) } finally { image.recycle() } + samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size) + return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + } + repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() } + val id = UUID.randomUUID() + release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE)) + await { session.playback.launch != null } + val duration = motionDuration(WolfskinTokens.Motion.base, reduced) + var elapsed = 0 + val fading = mutableListOf() + while (navigation.backStack.last() !is Destination.Playback && elapsed <= duration + 48) { + fading.add(frame("home-black", elapsed / 16)) + rule.mainClock.advanceTimeByFrame() + elapsed += 16 + } + assertTrue("The player cannot open before the token-bounded fade", elapsed >= duration) + assertTrue("The page must visibly fade rather than wait and cut", fading.first() > fading.last()) + assertTrue("The black handoff cannot flicker", fading.zipWithNext().all { (before, after) -> after <= before }) + assertTrue("The final painted frame must be near black", fading.last() <= fading.first() / 10) + assertEquals(id, (navigation.backStack.last() as Destination.Playback).itemId) + assertEquals(2, navigation.backStack.size) + assertNull(session.playback.starting) + assertNull(session.playback.launch) + coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) } + File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n")) + rule.runOnIdle { navigation.goBack() } + rule.mainClock.advanceTimeBy(100) + rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() + rule.onNodeWithText("Starting…").assertDoesNotExist() + } + + @Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() { + mount() + press() + rule.mainClock.advanceTimeBy(200) + rule.onNodeWithText("Starting…").assertIsDisplayed() + release.completeExceptionally(IOException("offline")) + await { model.toast != null } + rule.mainClock.advanceTimeBy(200) + rule.onNodeWithText("Resume", substring = true).assertIsDisplayed() + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() + assertTrue(navigation.backStack.single() is Destination.Home) + } + + @Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() { + mount() + press() + rule.mainClock.advanceTimeBy(32) + rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeBy(32) + release.complete(BaseItemDto(id = UUID.randomUUID(), type = org.jellyfin.sdk.model.api.BaseItemKind.MOVIE)) + await { session.playback.starting == null } + rule.mainClock.advanceTimeBy(400) + assertTrue(navigation.backStack.single() is Destination.Home) + assertNull(session.playback.launch) + } + + private fun press() = rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) } + private fun await(condition: () -> Boolean) { + rule.waitUntil(5000) { + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + shadowOf(Looper.getMainLooper()).idle() + if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame() + condition() + } + rule.waitForIdle() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt index 128dae1e..547fa4b9 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -45,7 +45,7 @@ class HomeRecoveryTest { val facade = mockk() coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() } coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) } started.await() assertEquals(film.id, model.snapshot.rows.first().cards.first().id) @@ -67,7 +67,7 @@ class HomeRecoveryTest { coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() } coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline") coEvery { facade.readLibrary(any()) } returns HomeLibrary() - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) } startedA.await() assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } }) @@ -88,7 +88,7 @@ class HomeRecoveryTest { val cache = mockk() every { cache.read(any()) } returns null every { cache.write(any(), any(), any()) } throws IOException("fixture disk full") - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) model.loadLibrary(server, personA, viewerA) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) @@ -101,7 +101,7 @@ class HomeRecoveryTest { coEvery { facade.readLibrary(any()) } returns HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name")) assertTrue(model.snapshot.jellyfinAvailable) assertEquals(film.id, model.snapshot.rows.first().cards.first().id) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt index 595a9b32..55b92066 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeSeeAllNavigationTest.kt @@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest { } val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null) - val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) val facade = mockk() rule.setContent { val destination = navigation.backStack.last() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt new file mode 100644 index 00000000..6f68f7ea --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavigationCutTest.kt @@ -0,0 +1,233 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +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.test.* +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.* +import com.github.damontecres.wholphin.preferences.* +import com.github.damontecres.wholphin.services.* +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.util.UUID +import kotlinx.coroutines.cancel +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Each navigation change retires the outgoing shell in the first rendered frame. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class NavigationCutTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var host: PolishAppHost + @After fun dispose() { if (::host.isInitialized) host.close() } + + @Test fun titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false) + @Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true) + + private fun cuts(reduced: Boolean) { + host = PolishAppHost(rule) + host.mount(picker = false, reduced = reduced) + rule.mainClock.autoAdvance = false + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + val samples = mutableListOf() + val homeCard = SemanticsMatcher("Home card") { + it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) && + it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:") + } + fun strip(phase: String) { + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + repeat(21) { index -> + rule.mainClock.advanceTimeByFrame() + val clock = rule.mainClock.currentTime + rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath) + assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) + val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size + val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size + val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size + assertEquals("Only the live destination may own a nav bar", 1, shells) + assertEquals(if (phase == "open-title") 1 else 0, titles) + if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards) + else assertTrue("Back immediately paints Home's shelves", cards > 0) + samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards) + } + } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) } + strip("open-title") + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + strip("back") + rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) } + strip("tab") + File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n")) + } + + @Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() { + host = PolishAppHost(rule) + host.mount(picker = false) + rule.mainClock.autoAdvance = false + var launches = 0 + rule.runOnIdle { host.session.playback.begin("hero-play"); host.session.playback.ready { launches++ } } + rule.mainClock.advanceTimeBy(32) + val revision = host.session.state!!.inputRevision + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter); pressKey(Key.Escape) } + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertEquals(revision, host.session.state!!.inputRevision) + assertEquals(0, launches) + rule.mainClock.advanceTimeBy(200) + assertEquals(1, launches) + assertNull(host.session.playback.starting) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertTrue(host.session.state!!.navFocused) + assertFalse(rule.activity.isFinishing) + } + + @Test fun backBeforeTheBlackVeilCancelsThePendingStartWithoutExitingHome() { + host = PolishAppHost(rule) + host.mount(picker = false) + rule.runOnIdle { host.session.playback.begin("hero-play") } + rule.mainClock.advanceTimeBy(32) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertNull(host.session.playback.starting) + assertTrue(host.session.state!!.navFocused) + assertFalse(rule.activity.isFinishing) + } + + @Test fun setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false) + @Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = setupCuts(true) + + private fun setupCuts(reduced: Boolean) { + val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null) + val stack = mutableStateListOf(SetupDestination.ServerList) + val screensaver = mockk() + every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false)) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme { + com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null), + mockk(relaxed = true), screensaver, + Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), + setupInterceptor = { destination, modifier -> + androidx.tv.material3.Text(destination.javaClass.simpleName, modifier) + true + }) + } } } + rule.mainClock.autoAdvance = false + for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) { + val previous = stack.single().javaClass.simpleName + rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + val clock = rule.mainClock.currentTime + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath) + assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime) + rule.onNodeWithText(previous).assertDoesNotExist() + rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1) + } + } +} + +/** Real shell integration with fixture data and an injectable saved-user switch. */ +internal class PolishAppHost(private val rule: AndroidComposeTestRule, ComponentActivity>) : AutoCloseable { + val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null) + var users = listOf("Justin", "Mindel", "Shared").mapIndexed { index, name -> + JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-00000000000${index + 2}"), serverId = server.id, name = name, accessToken = null) + } + val session = WolfskinSession() + val navigation = NavigationManager() + val repository = mockk(relaxed = true) + val setup = mockk(relaxed = true) + private val conductarr = ConductarrSession(MemoryCacheStore()) + private val models = mutableListOf() + private var oldFixtures = false + private var oldScreens = true + private var current by mutableStateOf(users.first()) + val model get() = models.last() + var reduceMotion by mutableStateOf(false) + + fun showUser(user: JellyfinUser) { current = user } + + fun mount(picker: Boolean = true, reduced: Boolean = false, fixtures: Boolean = true) { + reduceMotion = reduced + ConductarrPreference.initialize(rule.activity) + WolfskinScreenPreference.initialize(rule.activity) + oldFixtures = ConductarrPreference.fixtures + oldScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(fixtures) + WolfskinScreenPreference.enabled = true + coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) } + coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) } + every { setup.navigateTo(any()) } answers { + val destination = firstArg() + if (destination is SetupDestination.AppContent) current = destination.current.user + } + rule.setContent { + CompositionLocalProvider(LocalReducedMotion provides reduceMotion) { + key(current.id) { + val model = remember { + WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), + mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), + org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } + } + DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } + WolfskinApp(server, current, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), + Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), model, + homeContent = { shell, _, user, modifier -> + val snapshot = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) } + HomeScreen(shell, snapshot, modifier) { action -> + if (action is HomeAction.Details || action is HomeAction.Play) + navigation.navigateTo(Destination.Wolfskin(titleRoute("Locke"))) + } + }, + titleContent = { route, shell, _ -> + val title = remember(route) { + val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { org.json.JSONObject(it.readText()) }) + TitleState(fixture.title("Locke", shell.person.key), shell.person) { + if (it == TitleAction.Pop) navigation.goBack() + } + } + androidx.activity.compose.BackHandler { title.back(shell) } + TitleSurface(title, shell) + }) + } + } + } + await { session.state?.people?.size == users.size && model.signedInPerson == "justin" } + rule.runOnIdle { session.state!!.pickerVisible = picker; session.state!!.pickerLaunch = picker } + rule.mainClock.advanceTimeBy(1200) + } + + fun await(condition: () -> Boolean) { + rule.waitUntil(10_000) { + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + shadowOf(Looper.getMainLooper()).idle() + if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame() + condition() + } + rule.waitForIdle() + } + override fun close() { + models.forEach { it.viewModelScope.cancel() } + conductarr.close() + ConductarrPreference.updateFixtures(oldFixtures) + WolfskinScreenPreference.enabled = oldScreens + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt new file mode 100644 index 00000000..66f184cd --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonCommitTest.kt @@ -0,0 +1,185 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.* +import java.io.File +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import org.junit.* +import org.junit.Assert.* +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** A frozen, inert picker protects the selected identity across an asynchronous app replacement. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PersonCommitTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var host: PolishAppHost + private val release = CompletableDeferred() + @After fun dispose() { release.complete(Unit); if (::host.isInitialized) host.close() } + + @Test fun choosingMindelCoversTheSwitchUntilTheirHomeCanPaint() = heldSwitch(false) + @Test fun reducedMotionUsesOnlyTheShortFullPickerFade() = heldSwitch(true) + + private fun heldSwitch(reduced: Boolean) { + host = PolishAppHost(rule) + host.mount(reduced = reduced) + coEvery { host.repository.changeUser(host.server, any()) } coAnswers { + release.await() + CurrentUser(host.server, secondArg()) + } + var pendingUser: JellyfinUser? = null + every { host.setup.navigateTo(any()) } answers { pendingUser = (firstArg() as SetupDestination.AppContent).current.user } + rule.mainClock.autoAdvance = false + press(listOf(Key.DirectionRight)) + rule.mainClock.advanceTimeBy(400) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P08/" + if (reduced) "reduced" else "normal").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "picker.png").absolutePath) + val commit = choose(listOf(Key.DirectionCenter)) + val origin = commit.startFrameNanos + val clockOrigin = rule.mainClock.currentTime + val samples = mutableListOf() + val others = mutableListOf() + val chosen = mutableListOf() + val bg = WolfskinTokens.Color.bg.toArgb() + repeat(31) { index -> + val timeMs = rule.mainClock.currentTime - clockOrigin + assertEquals("Frame strips advance only in 16 ms steps", index * 16L, timeMs) + rule.onNodeWithText("Sign in as", substring = true).assertDoesNotExist() + val file = File(directory, "commit-" + index.toString().padStart(2, '0') + ".png") + rule.onRoot().captureRoboImage(file.absolutePath) + assertEquals("Capturing a frame cannot advance animation time", timeMs, rule.mainClock.currentTime - clockOrigin) + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + val other = image.getPixel(600, 500) + val selected = image.getPixel(900, 500) + others.add(android.graphics.Color.red(other)) + chosen.add(selected) + if (timeMs >= if (reduced) 80 else 320) { + val pixels = IntArray(image.width * image.height) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + assertTrue("Only opaque background may remain while sign-in is pending", pixels.all { it == bg }) + } + samples.add("$timeMs,$other,$selected") + } finally { image.recycle() } + rule.mainClock.advanceTimeByFrame() + } + assertTrue("The nonchosen person must fade monotonically to background", others.zipWithNext().all { (before, after) -> after <= before }) + assertNotEquals(others.first(), others.last()) + if (!reduced) assertTrue("The chosen avatar stays intact throughout phase one", chosen.take(10).all { it == chosen.first() }) + File(directory, "samples.csv").writeText("timeMs,other,chosen\n" + samples.joinToString("\n")) + val shell = host.session.state!! + val revision = shell.inputRevision + val stack = host.navigation.backStack.toList() + press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.Escape)) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + rule.onRoot().performTouchInput { click(androidx.compose.ui.geometry.Offset(600f, 500f)) } + assertEquals(revision, shell.inputRevision) + assertEquals("mindel", shell.person.key) + assertEquals(stack, host.navigation.backStack.toList()) + if (reduced) { + rule.runOnIdle { host.reduceMotion = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeByFrame() + val file = File(directory, "motion-toggle.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + try { + assertEquals("A completed veil cannot uncover the chosen person when motion changes", bg, image.getPixel(900, 500)) + assertEquals(bg, image.getPixel(100, 80)) + } finally { image.recycle() } + } + release.complete(Unit) + host.await { pendingUser != null } + rule.mainClock.advanceTimeBy(160) + assertNotNull("Completing switch work cannot uncover an unauthenticated replacement app", host.session.personCommit) + rule.runOnIdle { host.showUser(pendingUser!!) } + host.await { host.model.signedInPerson == "mindel" && host.session.personCommit == null } + assertEquals(origin, commit.startFrameNanos) + rule.onAllNodesWithText("Sex and the City")[0].assertIsDisplayed() + rule.onRoot().captureRoboImage(File(directory, "home-first-frame.png").absolutePath) + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.heroFade.toLong() + 32) + rule.onRoot().captureRoboImage(File(directory, "home.png").absolutePath) + coVerify(exactly = 1) { host.repository.changeUser(host.server, any()) } + } + + @Test fun samePersonRunsTheChoreographyWithoutSwitchingCredentials() { + host = PolishAppHost(rule) + host.mount() + rule.mainClock.autoAdvance = false + choose(listOf(Key.DirectionCenter)) + assertNotNull(host.session.personCommit) + finishTimeline() + assertNull(host.session.personCommit) + rule.onAllNodesWithText("Yu Yu Hakusho")[0].assertIsDisplayed() + coVerify(exactly = 0) { host.repository.changeUser(any(), any()) } + } + + @Test fun pinProtectedPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(pin = "protected") + @Test fun requireLoginPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(requireLogin = true) + + private fun protectedPerson(pin: String? = null, requireLogin: Boolean = false) { + host = PolishAppHost(rule) + host.users = host.users.mapIndexed { index, user -> if (index == 1) user.copy(pin = pin, requireLogin = requireLogin) else user } + host.mount(fixtures = false) + rule.mainClock.autoAdvance = false + choose(listOf(Key.DirectionRight, Key.DirectionCenter)) + assertNotNull(host.session.personCommit) + awaitSwitchWork() + verify(exactly = 0) { host.setup.navigateTo(any()) } + finishTimeline() + assertNull(host.session.personCommit) + coVerify(exactly = 0) { host.repository.changeUser(any(), any()) } + verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } + } + + @Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() { + host = PolishAppHost(rule) + host.mount(fixtures = false) + coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed") + rule.mainClock.autoAdvance = false + choose(listOf(Key.DirectionRight, Key.DirectionCenter)) + awaitSwitchWork() + finishTimeline() + assertNull(host.session.personCommit) + verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) } + } + + private fun choose(keys: List): PersonCommit { + press(keys) + val commit = requireNotNull(host.session.personCommit) + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeUntil(1000) { commit.startFrameNanos != null } + return commit + } + + private fun finishTimeline() { + repeat(WolfskinTokens.Motion.base * 2 / 16 + 2) { + rule.mainClock.advanceTimeByFrame() + rule.waitForIdle() + } + } + + private fun awaitSwitchWork() { + rule.waitUntil(5000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + host.session.personCommit?.switchEnded == true + } + } + + private fun press(keys: List) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 2ae2763a..44dfa233 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -219,7 +219,7 @@ class PersonSwitchThreadingTest { coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary() val cache = mockk(relaxed = true) every { cache.read(any()) } returns null - val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr) + val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession()) val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true)) try { conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt index dca356a7..72b68b47 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -52,7 +52,7 @@ class PlayerHandoffTest { @Suppress("DEPRECATION") rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false } - model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())) + model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession()) } @Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() { @@ -78,15 +78,30 @@ class PlayerHandoffTest { } @Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() { - val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first() + val snapshot = HomeFixture(rule.activity).snapshot("mindel") + val title = snapshot.rows.first().cards.first() val id = UUID.fromString("00000000-0000-0000-0000-000000000003") coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto( id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE, userData = playbackUserData(id, 11_362_740_000L, playCount = 2)) val view = shell.view view.cards["hero"] = 0 + rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme { + androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { + ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } } + PlaybackHandoffVeil(model.session.playback) + } + } } } + rule.mainClock.advanceTimeBy(1000) + rule.mainClock.autoAdvance = false rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) } - awaitModel { navigation.backStack.last() is Destination.Playback } + awaitModel { model.session.playback.launch != null } + assertTrue("Preparation must not navigate before the veil", navigation.backStack.single() is Destination.Home) + rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeBy(80) + assertTrue("The first half of the fade must still belong to Home", navigation.backStack.single() is Destination.Home) + rule.mainClock.advanceTimeBy(192) + assertEquals(2, navigation.backStack.size) val destination = navigation.backStack.last() as Destination.Playback assertEquals(id, destination.itemId) assertEquals(1_136_274L, destination.positionMs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt index fdcaa710..3b9b2377 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -115,7 +115,7 @@ class WatchReadTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L) val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) try { val loading = launch { model.loadLibrary(server, user, person) } runCurrent() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt index 494c8412..8c200d56 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReturnTest.kt @@ -47,7 +47,7 @@ class WatchReturnTest { val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val session = ConductarrSession(MemoryCacheStore()) - val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session) + val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession()) val oldFixtures = ConductarrPreference.fixtures ConductarrPreference.updateFixtures(false) try { diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 35f10b1e..d1bd3482 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive import org.justinvisser.wolfskin.tv.read.playbackPositionMs import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.tv.read.HomeLibraryCache @@ -37,6 +38,7 @@ class WolfskinHomeViewModel @Inject constructor( private val navigation: NavigationManager, private val seeAll: WolfskinSeeAllLists, private val conductarr: ConductarrSession, + val session: WolfskinSession, ) : ViewModel() { val conductarrState get() = conductarr.identity val audiences get() = home.audiences @@ -49,6 +51,15 @@ class WolfskinHomeViewModel @Inject constructor( private var loadRevision = 0L private var catalogueSnapshot = DiscoverSnapshot() private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null + private var playbackJob: kotlinx.coroutines.Job? = null + private var playbackRevision = 0L + + /** Leaving Home invalidates late preparation results as well as a pending black handoff. */ + fun cancelPlayback() { + playbackRevision++ + playbackJob?.cancel() + session.playback.fail() + } suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) { catalogueJob?.cancel() @@ -164,23 +175,45 @@ class WolfskinHomeViewModel @Inject constructor( HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) } is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id))) - is HomeAction.Play -> viewModelScope.launch { - val personKey = shell.person.key - val selectedId = shell.person.jellyfinUserId - val matchesUser = if (selectedId != null) selectedId == user.id.toString() else - shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester) - if (!snapshot.jellyfinAvailable || !matchesUser) { - showToast(RecoveryMessage.PlaybackUnavailable) - return@launch - } - try { - val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) } - if (item == null) showToast(RecoveryMessage.PlaybackUnavailable) - else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) - } catch (exception: Exception) { - if (exception is CancellationException) throw exception + is HomeAction.Play -> startPlayback(action.title, shell, server, user) + } + } + + private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { + val start = session.playback + if (start.starting != null) return + playbackJob?.cancel() + val revision = ++playbackRevision + val personKey = shell.person.key + start.begin("hero-play") + playbackJob = viewModelScope.launch { + if (revision != playbackRevision || start.starting == null) return@launch + val selectedId = shell.person.jellyfinUserId + val matchesUser = if (selectedId != null) selectedId == user.id.toString() else + personKey == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && personKey == conductarr.identity.value?.operator?.requester) + if (!snapshot.jellyfinAvailable || !matchesUser) { + start.fail() + showToast(RecoveryMessage.PlaybackUnavailable) + return@launch + } + try { + val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, title) } + coroutineContext.ensureActive() + if (revision != playbackRevision || start.starting == null) return@launch + if (item == null) { + start.fail() showToast(RecoveryMessage.PlaybackUnavailable) + } else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) { + start.ready { + if (revision == playbackRevision && shell.person.key == personKey) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs())) + } + } else start.fail() + } catch (exception: Exception) { + if (revision == playbackRevision && start.starting != null) { + start.fail() + if (exception !is CancellationException) showToast(RecoveryMessage.PlaybackUnavailable) } + if (exception is CancellationException) throw exception } } } @@ -234,6 +267,7 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse val identity by model.conductarrState.collectAsState() val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}") LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) } + DisposableEffect(shell.person.key, server.id, user.id) { onDispose { model.cancelPlayback() } } val view = shell.rememberedView("home") LaunchedEffect(view.row, model.snapshot.rows) { val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id 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 7e39a508..9ba1c4ee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -1,6 +1,10 @@ package org.justinvisser.wolfskin.tv import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -12,6 +16,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -64,6 +72,8 @@ class WolfskinSession @Inject constructor() { var serverId: String? = null internal var fixtureMode: Boolean? = null var onAction: (ShellAction) -> Unit = {} + var personCommit by mutableStateOf(null) + val playback = org.justinvisser.wolfskin.design.PlaybackStart() } /** The shell routes only through the existing NavigationManager and real settings pages. */ @@ -214,32 +224,48 @@ class WolfskinShellViewModel @Inject constructor( } private fun selectPerson(person: ShellPerson, server: JellyfinServer) { + val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let { + org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key) + } + session.personCommit = commit + session.playback.fail() navigation.replace(Destination.Home()) - if (person.key == signedInPerson) return + if (person.key == signedInPerson) { + commit?.switchEnded = true + return + } identityJob?.cancel() personSwitchJob?.cancel() signedInPerson = "" conductarr.close() libraries.audiences = null personSwitchJob = viewModelScope.launch { - personSwitchMutex.withLock { - val saved = withContext(Dispatchers.IO) { - repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } - } - if (saved == null || saved.isProtected) { - if (ConductarrPreference.fixtures) return@launch - setupNavigation.navigateTo(SetupDestination.UserList(server)) - return@launch - } - try { - val current = repository.changeUser(server, saved) - coroutineContext.ensureActive() - setupNavigation.navigateTo(SetupDestination.AppContent(current)) - } catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - coroutineContext.ensureActive() - setupNavigation.navigateTo(SetupDestination.UserList(server)) + try { + personSwitchMutex.withLock { + val saved = withContext(Dispatchers.IO) { + repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } + } + if (saved == null || saved.isProtected) { + if (!ConductarrPreference.fixtures) { + val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) } + if (commit == null) open() else commit.fallback = open + } + return@launch + } + try { + val current = repository.changeUser(server, saved) + coroutineContext.ensureActive() + setupNavigation.navigateTo(SetupDestination.AppContent(current)) + commit?.appReplacementPending = true + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + coroutineContext.ensureActive() + val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) } + if (commit == null) open() else commit.fallback = open + } } + } finally { + commit?.switchEnded = true } } } @@ -265,6 +291,8 @@ fun WolfskinApp( viewModel: WolfskinShellViewModel = hiltViewModel(), homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit = { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, + titleContent: @Composable (String, ShellState, UserPreferences) -> Unit = + { route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() @@ -294,11 +322,26 @@ fun WolfskinApp( ) RequestsRefresh(state, viewModel::refreshRequests) val onBack = rememberWolfskinBackHandler(state, navigationManager) + val session = viewModel.session + val commit = session.personCommit + LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) { + if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) { + session.personCommit = null + commit.fallback?.invoke() + } + } 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) { + Box(modifier.onPreviewKeyEvent { event -> + if (session.personCommit != null || session.playback.launch != null) true else { + if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail() + false + } + }) { val current = navigationManager.backStack.last() + LaunchedEffect(current, state.person.key) { session.playback.fail() } if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) { val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle() Backdrop( @@ -309,15 +352,15 @@ fun WolfskinApp( } NavDisplay( backStack = navigationManager.backStack, - onBack = onBack, + onBack = { if (session.personCommit == null && session.playback.launch == null) { 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) }, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry( destination, contentKey = "${destination}_${server.id}_${user.id}", - metadata = if (destination is Destination.Wolfskin && destination.route == "updates") { - org.justinvisser.wolfskin.tv.update.updatesRouteTransitions - } else emptyMap(), ) { // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current @@ -335,14 +378,17 @@ fun WolfskinApp( if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { - ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } - } else TitleDestination(route, state, preferences) + ShellSurface(state, handlesContentKeys = false) { + if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) + } + } else titleContent(route, state, preferences) } else { ShellSurface(state, handlesContentKeys = upstream == null, settingsInstalledVersion = installedVersion.versionName, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> when { - (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) + (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> + if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier) route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, @@ -360,6 +406,16 @@ fun WolfskinApp( } }, ) + org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback, + Modifier.pointerInput(session.playback.launch) { + 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) { + session.playback.fail() + onBack() + } + BackHandler(enabled = commit != null || session.playback.launch != null) { } } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt deleted file mode 100644 index ec5de195..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -package org.justinvisser.wolfskin.tv.update - -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.togetherWith -import androidx.navigation3.ui.NavDisplay - -/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */ -internal val updatesRouteTransitions = - NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + - NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + - NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 29ac8a5d..cf33e6db 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -148,10 +148,51 @@ The on state has no unfocused ring: Justin (2026-11-07) read it as active/select Source — Justin (2026-11-07): “the watches icon on details pages has a border, making it look like it's active/selected.” +## UI polish — revision 7 (2026-11-07) + +Justin approved all twelve ideas on the [UI polish page](https://pages.justinvisser.org/wolfskin/ui-polish) and E01–E07 on the [everyday polish page](https://pages.justinvisser.org/wolfskin/ui-polish-everyday): "Do now". He approved the looks and copy exactly as the mocks show. Where this section and an earlier revision disagree, this section wins. + +| Idea | Behaviour | Tokens | +|---|---|---| +| P01 chips | Discover and Library chip fill, text and selected border cross-fade between rest, selected and focused; resting pixels unchanged | `focusMove` 180 ms, `easeOut` | +| P02 toasts | Every recovery toast fades in, holds 3200 ms, then fades out; a new message replaces the text without a second toast | `base` 160 ms, `ease` | +| P03 Sending | The request button keeps its width and reads "Sending…" from OK until the service answers | `base` 160 ms crossfade | +| P04 receipt | The outcome block fades in once when the service answers; the form goes in the same frame | `lateLineFade` 200 ms, `ease` | +| P05 + E03 rows | A row that already entered during this screen visit (including after Back from a pushed page, and after scrolling back) does not enter again; a new visit (tab switch, person switch) and first appearances are unchanged | `rowEnter` / `rowRise` | +| P06 sheets | Requests, Request, Version, Audio and subtitles, Media info and the other right-hand sheets fade with their scrim; one scrim at a time; Menu ↔ sheet hand-offs keep the scrim constant | new `sheetIn` 160 ms / `sheetOut` 100 ms, `easeOut` | +| P07 loading | Requests and picker placeholders breathe on the shared skeleton clock; buttons never pulse | `skeletonCycle`, `breatheEase` | +| P08 person | After OK on a person, the others fade to background, then the picker does, and their Home follows with no sign-in page between | `base` ×2, `ease` | +| P09 readiness | Rows below an expanding or collapsing request slide; the readiness content appears at once and is uncovered by the sliding rows; window shifts still jump | `lateLineFade` 200 ms, `easeOut` | +| P10 empty Requests | "Nothing coming in." / "New requests show up here." / Close (focused) | — | +| P11 Library results | A changed sort or filter result fades up as one: all visible rows together | `rowEnter`, `rowRise` | +| P12 thumbnails | A request without a poster (none, or failed) shows a centred 40 px film or TV glyph in `dim` | new `requests.thumbGlyph` 40 px | +| E01 screen changes | No whole-screen transition anywhere in the shell or the setup host; the incoming screen's own entrance carries the motion | — | +| E02 focus ring | One card ring per shell surface moves between cards, changing x, y, width, height and radius together; card scale and lift unchanged; settled pixels unchanged | `navBlob` spring; `focusMove` alpha | +| E04 Play | Play/Resume reads "Starting…" while the stream is prepared; the page fades to black before the player opens | `base` 160 ms, `ease` | +| E05 scrolling | Every key-driven scroll is a critically damped spring that carries velocity across key repeats and never overshoots | `navBlob` spring | +| E06 progress | Title and card progress fill to the new point after playback; first paint never animates | `heroFade` 320 ms, `easeInOut` | +| E07 seasons | The selection capsule slides between season chips; a season seen before shows its episodes at once | `navBlob` spring | + +Reduced motion (platform animator scale 0 or `LocalReducedMotion`): fades ≤ 80 ms; movement, springs, height and scroll snap; pulses stop. Motion never moves focus ownership; retained exit copies are inert and swallow pointer input. The layer budget is unchanged: one animated layer per shelf; overlays add one bounded panel layer; scrims, veils and the ring are draw-only. + +Decisions taken while specifying, and why: +- P08: the mock's 90 ms pause between the two fades has no token, so the second fade starts as the first ends. +- P09: the request that leaves the bounded 5 → 4 window disappears at the start, as before. +- P10: Close is the approved loading-state pill; the second line uses the `receiptGap` spacing. +- P12: the glyph size needed a new look token. +- By Justin's standing rule ("if we do things similarly elsewhere, assume my instructions likely apply there too"), these also apply: + - E02 covers title-page shelves, cast circles and the person filmography; + - P05/E03 cover Home, Discover and Library alike; + - E07's season chips use P01's colour fade. + +Native proofs: the `ui-polish//` captures listed under Sources and verification. + ## Sources and verification Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. +Revision-7 polish proofs are listed in `docker/README.md` under UI polish. + `MotionSurfaceTest`, `HeroMotionTest`, `NetworkArtTransitionTest` and `DiscoverSkeletonKeysTest` cover late-line geometry, loading/ready row eligibility, Back/nav ownership, rendered pulse suppression under both motion controls, strict main-thread I/O policy, network-versus-cache reveal, bounded hero blending, delayed art and interrupted hand-offs. Native follow-up cases compare the pulse pixels of a late-joining shelf, hold caption translation through both settled and interrupted exits, and observe late ratings/genres/overview fading without moving their slots or actions. Manually clocked captures explicitly deliver snapshot changes before advancing animation time; interrupted-exit assertions sample after the hide has committed. The frozen prototype includes `index.html`, its local media, the original CSS/JS/builder and a complete SHA-256 manifest; replay the HTML, not the preserved builder's live-publishing entry point. The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation. diff --git a/docker/README.md b/docker/README.md index abe554a0..d912c2fd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1039,6 +1039,23 @@ entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers menu Back/focus, preserved media info and household requests, movie stacking, the Mussolini resume example, request revocation, episode skeleton geometry, and native menu fade frames sampled in 16 ms steps. + +**UI polish (revision 7).** The tests below write 16 ms frame strips and `samples.csv` to `build/wolfskin-captures/ui-polish//`: +- `RequestsPolishTest` (P06 Requests, P07, P09, P10, P12) +- `DiscoverPolishTest` (P01, P03, P04, P06 Request sheet, E03 lanes) +- `LibraryPolishTest` (P01 toolbar, P05, P11) +- `HomePolishRowsTest` (E03) +- `RecoveryToastMotionTest` (P02) +- `FocusGlideTest` (E02) +- `ScrollMomentumTest` (E05) +- `PickerPolishTest` (P07 picker) +- app `TitlePolishTest` (P06 title sheets, E04 title, E06, E07) +- app `PersonCommitTest` (P08) +- app `NavigationCutTest` (E01) +- app `HomeHeroStartingTest` (E04 Home) + +`TitleRound2Test.menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim` now asserts one constant scrim and a monotonic panel fade. `RequestsKeysTest` asserts that OK on the empty Requests Close closes the sheet. + `TitleAsyncFocusTest` delays title and person replies through the real destination and view-model, then exercises content focus and hardware Back. Subtitles Off maps to the player's explicit disabled sentinel, not its unspecified default. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 8f3fe345..5b47355f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -17,9 +18,13 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.HomeType as T +/** Home's pending playback belongs to the app session rather than a transient hero composition. */ +val LocalHomePlaybackStart = compositionLocalOf { null } + /** The hero occupies one row while its backdrop extends beneath the first shelves. */ @Composable fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List, onButton: (Int) -> Unit, onHover: (Int) -> Unit) { + val start = LocalHomePlaybackStart.current HeroMotionScope { Box(Modifier.fillMaxWidth().height(L.row_hero)) { if (!row.skeleton) { @@ -57,7 +62,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List val button = measurables.single().measure(constraints) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt new file mode 100644 index 00000000..19a74027 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonCommit.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.semantics.clearAndSetSemantics +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S + +/** Frozen public identity data and a frame-clock origin survive replacement of the signed-in app. */ +@Stable +class PersonCommit(val people: List, val chosenIndex: Int, val chosenKey: String) { + var startFrameNanos: Long? = null + internal set + internal var elapsedMillis by mutableFloatStateOf(0f) + var timelineComplete by mutableStateOf(false) + internal set + var switchEnded by mutableStateOf(false) + var appReplacementPending by mutableStateOf(false) + var fallback: (() -> Unit)? = null +} + +/** The frozen picker is opaque and inert; only the covering rectangles change while switching identity. */ +@Composable +fun PersonCommitVeil(commit: PersonCommit, modifier: Modifier = Modifier) { + val reduced = reducedMotion() + val duration = motionDuration(M.base, reduced) + val total = if (reduced) duration else duration * 2 + LaunchedEffect(commit, total) { + while (!commit.timelineComplete) { + withFrameNanos { frame -> + val start = commit.startFrameNanos ?: frame.also { commit.startFrameNanos = it } + commit.elapsedMillis = (frame - start) / 1_000_000f + if (commit.elapsedMillis >= total) commit.timelineComplete = true + } + } + } + Box(modifier.fillMaxSize().background(C.bg).clearAndSetSemantics { } + .pointerInput(commit) { awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } } } + .drawWithContent { + drawContent() + val elapsed = commit.elapsedMillis + if (!reduced) { + val alpha = M.ease.transform((elapsed / duration).coerceIn(0f, 1f)) + val width = L.pickerAvatar.toPx() + val gap = L.pickerGap.toPx() + val left = (size.width - width * commit.people.size - gap * (commit.people.size - 1)) / 2 + commit.people.indices.forEach { index -> + if (index != commit.chosenIndex) drawRect(C.bg.copy(alpha = alpha), + topLeft = Offset(left + index * (width + gap) - gap / 2, S.pickerPeopleTop.toPx() - gap / 2), + size = Size(width + gap, S.pickerGearTop.toPx() - S.pickerPeopleTop.toPx() + gap / 2)) + } + } + val full = if (commit.timelineComplete) 1f else if (reduced) elapsed / duration else (elapsed - duration) / duration + drawRect(C.bg.copy(alpha = M.ease.transform(full.coerceIn(0f, 1f)))) + }) { + Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker)) + PickerPeople(commit.people, commit.chosenIndex, false, onPerson = null, onGear = null) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 18e8c341..312c87ea 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -37,15 +37,23 @@ fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { @Composable private fun PeoplePicker(state: ShellState) { + PickerPeople(state.people, state.pickerIndex, state.gearFocused, + onPerson = { index -> state.focusPerson(index); state.key(ShellKey.Ok) }, + onGear = { state.gearFocused = true; state.key(ShellKey.Ok) }) +} + +/** Live and frozen pickers share geometry; a frozen picker has no interactive modifiers. */ +@Composable +internal fun PickerPeople(people: List, focusedIndex: Int, gearFocused: Boolean, + onPerson: ((Int) -> Unit)?, onGear: (() -> Unit)?) { Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) { Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) } Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { - state.people.forEachIndexed { index, person -> - val focused = !state.gearFocused && state.pickerIndex == index - Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { - state.focusPerson(index); state.key(ShellKey.Ok) - }, horizontalAlignment = Alignment.CenterHorizontally) { + people.forEachIndexed { index, person -> + val focused = !gearFocused && focusedIndex == index + Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard) + .then(if (onPerson != null) Modifier.clickable { onPerson(index) } else Modifier), horizontalAlignment = Alignment.CenterHorizontally) { Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) Spacer(Modifier.height(S.pickerNameGap)) @@ -54,11 +62,11 @@ private fun PeoplePicker(state: ShellState) { } } val left = (S.stageWidth - L.gear) / 2 - Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) - .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) - if (state.gearFocused) { + Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(gearFocused, WolfskinTokens.Focus.scaleCard) + .focusRing(gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) + .then(if (onGear != null) Modifier.clickable(onClick = onGear) else Modifier), contentAlignment = Alignment.Center) { + Glyph("gear", if (gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) + if (gearFocused) { Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) { Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) } @@ -74,7 +82,7 @@ private fun RecoveryPicker(state: ShellState) { Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle) } if (loading) { - Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { + Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { repeat(state.people.size) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape)) @@ -103,16 +111,20 @@ private fun RecoveryPicker(state: ShellState) { /** Recovery actions remain focusable while profiles cannot be read. */ @Composable fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, - textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { + textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, + busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) { val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) - .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) + .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable { if (!busy) onClick() } .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { - if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) - Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) + val content: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Home.plateTop)) { + if (icon != null) Glyph(icon, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) + Text(label, modifier = Modifier.exactLineBox(textStyle), color = if (primary) C.onFocus else C.fg, style = textStyle) + } } + if (busyLabel == null) content() else BusyLabelSwap(busy, busyLabel, if (primary) C.onFocus else C.fg, textStyle, content) } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt new file mode 100644 index 00000000..76566c98 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerPolishTest.kt @@ -0,0 +1,63 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Loading profiles share one pulse without fading the recovery action. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PickerPolishTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun loadingAvatarsBreatheButtonStays() = capture(false) + @Test fun reducedMotionKeepsAvatarsAndButtonStill() = capture(true) + + private fun capture(reduced: Boolean) { + rule.mainClock.autoAdvance = false + val state = ShellState(fixturePeople(), "justin").apply { peopleStatus = PeopleStatus.Loading } + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { + ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + } } } + rule.mainClock.advanceTimeBy(32) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P07/${if (reduced) "reduced" else "normal"}").apply { mkdirs() } + val samples = (0..14).map { index -> + val file = File(directory, "picker-loading-${index.toString().padStart(2, '0')}.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val image = BitmapFactory.decodeFile(file.absolutePath) + val pixels = try { image.getPixel(650, 540) to image.getPixel(950, 820) } finally { image.recycle() } + rule.mainClock.advanceTimeBy(100, ignoreFrameDuration = true) + "${index * 100},${pixels.first},${pixels.second}" + } + File(directory, "samples.csv").writeText("timeMs,avatar,button\n" + samples.joinToString("\n")) + val avatars = samples.map { it.split(',')[1].toInt() } + val buttons = samples.map { it.split(',')[2].toInt() } + assertEquals("The recovery button never pulses", 1, buttons.distinct().size) + if (reduced) assertEquals("Reduced motion stops the shared pulse", 1, avatars.distinct().size) + else { + assertNotEquals("The avatar must breathe by the middle of the cycle", avatars.first(), avatars[7]) + assertNotEquals("The avatar must also pass through an intermediate alpha", avatars.first(), avatars[3]) + } + rule.runOnIdle { state.peopleStatus = PeopleStatus.Ready; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + rule.mainClock.advanceTimeBy(1000) + rule.onNodeWithTag("picker-person:justin").assertIsDisplayed() + rule.onNodeWithText("Continue as Justin").assertDoesNotExist() + assertTrue("Removing the last subscriber stops its infinite transition", + androidx.compose.runtime.Recomposer.runningRecomposers.value.all { !it.hasPendingWork }) + } +}