diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt index 1838f45c..231ffef7 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePlaybackNavigationTest.kt @@ -1,6 +1,9 @@ package org.justinvisser.wolfskin.tv import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Rule import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences @@ -35,6 +38,7 @@ import java.util.UUID @Config(sdk = [34], application = Application::class) @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class TitlePlaybackNavigationTest { + @get:Rule val rule = createAndroidComposeRule() private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000001") private val firstId = UUID.fromString("00000000-0000-0000-0000-000000000002") private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000003") @@ -110,24 +114,36 @@ class TitlePlaybackNavigationTest { userData = playbackUserData(firstId, 960_780_000L, playCount = 1)) val session = ConductarrSession(MemoryCacheStore()) val model = WolfskinTitleViewModel(RuntimeEnvironment.getApplication(), facade, mockk(), navigation, session, mockk(relaxed = true), mockk(relaxed = true)) + rule.mainClock.autoAdvance = false + rule.setContent { model.title?.let { PlaybackHandoffVeil(it.playbackStart) } } + fun finishHandoff() { + rule.waitForIdle() + assertFalse("Preparation alone cannot navigate before black", navigation.backStack.last() is Destination.Playback) + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48) + rule.waitForIdle() + } try { model.load(titleRoute(seriesId.toString()), person, preferences, fixtures = false) advanceUntilIdle() model.title!!.click("header", 0) advanceUntilIdle() + finishHandoff() assertEquals(Destination.Playback(secondId, 1_136_274L), navigation.backStack.last()) navigation.goBack() model.title!!.click("header", model.title!!.options.indexOfFirst { it.id == "restart" }) advanceUntilIdle() + finishHandoff() assertEquals(Destination.Playback(firstId, 0L), navigation.backStack.last()) navigation.goBack() model.load(titleRoute(firstId.toString()), person, preferences, fixtures = false) advanceUntilIdle() model.title!!.click("header", 0) advanceUntilIdle() + finishHandoff() assertEquals(Destination.Playback(firstId, 96_078L), navigation.backStack.last()) coVerify(exactly = 1) { facade.preparePlayback(seriesId) } } finally { + rule.mainClock.autoAdvance = true model.viewModelScope.cancel() session.close() Dispatchers.resetMain() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt new file mode 100644 index 00000000..d64a6730 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitlePolishTest.kt @@ -0,0 +1,481 @@ +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.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File +import java.util.UUID + +/** Native title transitions preserve focus, stream identity and immediate cached content. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitlePolishTest { + @get:Rule val rule = createAndroidComposeRule() + private var model: WolfskinTitleViewModel? = null + private var session: ConductarrSession? = null + private lateinit var state: TitleState + private lateinit var shell: ShellState + private val reduced = mutableStateOf(false) + private val entryActive = mutableStateOf(true) + private val samples = mutableListOf() + private var bitmaps: Map = emptyMap() + private val person = ShellPerson("justin", "Justin", "J", false) + private val id = UUID.fromString("00000000-0000-0000-0000-000000000091") + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + + @After fun dispose() { model?.viewModelScope?.cancel(); session?.close() } + private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) }) + private fun prepareShell() { + shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(id.toString())); navFocused = false } + } + private fun render(data: TitlePageData = fixture().title("Locke", "justin"), reducedMotion: Boolean = false) { + reduced.value = reducedMotion + prepareShell() + state = TitleState(data, person) { } + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { + TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) + } } } + rule.waitForIdle() + rule.mainClock.advanceTimeBy(600) + rule.mainClock.autoAdvance = false + } + private fun mountModel(facade: TitleReadFacade, data: TitlePageData, reducedMotion: Boolean) : NavigationManager { + reduced.value = reducedMotion + prepareShell() + coEvery { facade.readSaved(id, any()) } returns null + coEvery { facade.read(id, any(), any()) } returns data + val navigation = NavigationManager().apply { navigateTo(Destination.Wolfskin(titleRoute(id.toString()))) } + session = ConductarrSession(MemoryCacheStore()) + model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session!!, + mockk(relaxed = true), mockk(relaxed = true)) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value, LocalTitleBitmaps provides bitmaps) { WolfskinTheme { + val route = titleRoute(id.toString()) + ScreenEntry(shell, route, entryActive.value && navigation.backStack.lastOrNull() == Destination.Wolfskin(route)) { + TitleDestination(route, shell, preferences, Modifier.requiredSize(960.dp, 540.dp), model!!, fixtures = false) + } + } } } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); model!!.title?.data?.status == TitleReadStatus.Ready } + state = model!!.title!! + rule.waitForIdle() + rule.mainClock.advanceTimeBy(600) + rule.mainClock.autoAdvance = false + return navigation + } + private fun tick() { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } + private fun path(idea: String, name: String, index: Int): File = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), + "ui-polish/$idea/${if (reduced.value) "reduced-" else ""}$name-${index.toString().padStart(2, '0')}.png").also { requireNotNull(it.parentFile).mkdirs() } + private fun frame(idea: String, name: String, index: Int, sample: (android.graphics.Bitmap) -> Int): Int { + val file = path(idea, name, index) + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + return try { sample(bitmap).also { samples.add("$name,${index * 16},$it") } } finally { bitmap.recycle() } + } + private fun brightness(bitmap: android.graphics.Bitmap, x: Int, y: Int): Int = bitmap.getPixel(x, y).let { + android.graphics.Color.red(it) + android.graphics.Color.green(it) + android.graphics.Color.blue(it) + } + private fun saveSamples(idea: String) { + File(path(idea, "samples", 0).parentFile, "${if (reduced.value) "reduced-" else ""}${samples.first().substringBefore(',')}-samples.csv") + .writeText("phase,timeMs,value\n" + samples.joinToString("\n")) + } + private fun monotonic(values: List) { + assertNotEquals("Motion changes visible pixels", values.first(), values.last()) + val direction = if (values.last() > values.first()) 1 else -1 + values.zipWithNext().forEach { (before, after) -> assertTrue("No reversal: $values", (after - before) * direction >= -1) } + } + + @Test fun versionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(false) + @Test fun reducedVersionSheetFadesWithScrimAndReturnsFocusOnce() = versionSheet(true) + private fun versionSheet(reducedMotion: Boolean) { + render(fixture().title("Locke", "justin"), reducedMotion) + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "version" }) } + rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.DirectionCenter) } + val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetIn + val scrim = mutableListOf() + val panel = mutableListOf() + repeat(duration / 16 + 3) { index -> + frame("P06", "title-version", index) { bitmap -> + scrim.add(brightness(bitmap, 400, 900)); brightness(bitmap, 1900, 900).also { panel.add(it) } + } + tick() + } + monotonic(scrim); monotonic(panel) + val opener = state.column + rule.onRoot().performKeyInput { pressKey(androidx.compose.ui.input.key.Key.Escape) } + val owner = hasTestTag("title-page") or hasTestTag("title-option-version") + val closing = mutableListOf() + repeat((if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.sheetOut) / 16 + 3) { index -> + closing.add(frame("P06", "title-version-out", index) { brightness(it, 1900, 900) }) + if (index == 2) { + rule.waitForIdle() + rule.runOnIdle { } + rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists() + rule.onNodeWithTag("title-sheet", useUnmergedTree = true) + .assert(SemanticsMatcher.keyNotDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused)) + rule.onAllNodes(hasAnyAncestor(hasTestTag("title-sheet")) and + SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsProperties.Focused), useUnmergedTree = true).assertCountEquals(0) + } + assertNull(state.sheet); assertEquals(opener, state.column); assertFalse(shell.navFocused) + tick() + } + monotonic(closing) + rule.onNodeWithTag("title-sheet").assertDoesNotExist() + rule.onNode(owner and isFocused(), useUnmergedTree = true).assertExists() + saveSamples("P06") + } + + private fun pendingPlayback(reducedMotion: Boolean): Triple, NavigationManager> { + val facade = mockk() + val prepared = CompletableDeferred() + coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() } + val data = fixture().title("Locke", "justin") + val target = data.item.copy(id = id.toString()) + val navigation = mountModel(facade, data.copy(item = target, playTarget = target, watchTarget = target, + related = listOf(TitleItem("other", "Other title", "movie"))), reducedMotion) + return Triple(facade, prepared, navigation) + } + + @Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = playback(false) + @Test fun reducedPlaySaysStartingThenFadesToBlackBeforeThePlayer() = playback(true) + private fun playback(reducedMotion: Boolean) { + val (facade, prepared, navigation) = pendingPlayback(reducedMotion) + val resting = rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot + rule.runOnIdle { state.click("header", 0) } + repeat(13) { index -> frame("E04", "title-start", index) { brightness(it, 100, 900) }; tick() } + rule.onNodeWithText("Starting…").assertIsDisplayed() + assertEquals(resting.width, rule.onNodeWithTag("title-option-play").fetchSemanticsNode().boundsInRoot.width, .1f) + rule.runOnIdle { state.click("header", 0) } + coVerify(exactly = 1) { facade.preparePlayback(id) } + rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.playbackStart.launch != null } + assertFalse(navigation.backStack.last() is Destination.Playback) + val values = mutableListOf() + val duration = if (reducedMotion) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base + repeat(duration / 16 + 3) { index -> + values.add(frame("E04", "title-veil", index) { brightness(it, 100, 900) }) + if (index * 16 < duration) assertFalse(navigation.backStack.last() is Destination.Playback) + tick() + } + assertEquals(Destination.Playback(id, 0L), navigation.backStack.last()) + assertEquals(1, navigation.backStack.count { it is Destination.Playback }) + assertTrue("The handoff reaches black before navigation", values.contains(0)) + assertNull(state.playbackStart.starting) + rule.runOnIdle { navigation.goBack() } + coEvery { facade.preparePlayback(id) } throws IllegalStateException("offline") + rule.runOnIdle { state.click("header", 0) } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.PlaybackUnavailable } + repeat(14) { tick() } + rule.onNodeWithText("Starting…").assertDoesNotExist() + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed() + assertFalse(navigation.backStack.last() is Destination.Playback) + saveSamples("E04") + } + + @Test fun progressFillsToTheNewPointAfterPlayback() = progress(false) + @Test fun reducedProgressSnapsToTheNewPointAfterPlayback() = progress(true) + @Test fun lateRuntimeMetadataAnimatesAnExistingResumeTrack() = progress(false, runtimeKnown = false) + @Test fun reducedLateRuntimeMetadataSnapsAnExistingResumeTrack() = progress(true, runtimeKnown = false) + private fun progress(reducedMotion: Boolean, runtimeKnown: Boolean = true) { + val data = fixture().title("Locke", "justin") + val target = data.item.copy(runtimeSeconds = if (runtimeKnown) 1000 else 0, resumeSeconds = 134) + val from = if (runtimeKnown) .134f else 0f + val phase = if (runtimeKnown) "title-progress" else "title-progress-late-runtime" + render(data.copy(playTarget = target), reducedMotion) + val bounds = rule.onNodeWithTag("title-progress").fetchSemanticsNode().boundsInRoot + val width = (WolfskinTokens.Title.progressWidth.value * 2).toInt() + fun edge(bitmap: android.graphics.Bitmap): Int = (0 until width).lastOrNull { x -> + bitmap.getPixel(bounds.left.toInt() + x, bounds.center.y.toInt()) == WolfskinTokens.Color.accent.toArgb() + }?.plus(1) ?: 0 + val initial = frame("E06", "$phase-initial", 0, ::edge) + assertEquals(width * from, initial.toFloat(), 1f) + rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(runtimeSeconds = 1000, resumeSeconds = 419))) } + val values = List(23) { index -> + frame("E06", phase, index, ::edge).also { + if (index == 1) rule.onNodeWithText("10 min left").assertIsDisplayed() + tick() + } + } + assertEquals(width * .419f, values.last().toFloat(), 1f) + if (reducedMotion) assertEquals(values.last(), values[1]) else { + monotonic(values) + assertEquals(width * (from + .419f) / 2f, values[11].toFloat(), width * .06f) + } + rule.onNodeWithText("10 min left").assertIsDisplayed() + rule.runOnIdle { state.update(state.data.copy(playTarget = target.copy(id = "other", runtimeSeconds = 1000, resumeSeconds = 800))) } + tick() + assertEquals(width * .8f, frame("E06", "title-new-target", 0, ::edge).toFloat(), 1f) + saveSamples("E06") + } + + @Test fun cardProgressFillsAfterPlaybackWithoutAnimatingItsFirstPaint() = cardProgress(false) + @Test fun reducedCardProgressSnapsAfterPlayback() = cardProgress(true) + private fun cardProgress(reducedMotion: Boolean) { + reduced.value = reducedMotion + val title = HomeTitle("card", "Card", "movie", runtimeSeconds = 1000, resumeSeconds = 134.0, held = true) + val row = mutableStateOf(HomeRow("resume", HomeRowKind.Shelf, cards = listOf(title))) + val memory = HomeViewMemory() + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { + Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { HomeShelf(row.value, memory, false, 0, emptyList(), {}, {}) } + } } } + rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false + val bounds = rule.onNodeWithTag("home-card:resume:0").fetchSemanticsNode().boundsInRoot + val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt() + fun edge(bitmap: android.graphics.Bitmap): Int = (0 until bounds.width.toInt()).lastOrNull { x -> + bitmap.getPixel(bounds.left.toInt() + x, y) == WolfskinTokens.Color.accent.toArgb() + }?.plus(1) ?: 0 + assertEquals(bounds.width * .134f, frame("E06", "card-progress-initial", 0, ::edge).toFloat(), 1f) + rule.runOnIdle { row.value = row.value.copy(cards = listOf(title.copy(resumeSeconds = 419.0))) } + val values = List(23) { index -> frame("E06", "card-progress", index, ::edge).also { tick() } } + assertEquals(bounds.width * .419f, values.last().toFloat(), 1f) + if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values) + saveSamples("E06") + } + + @Test fun seasonCapsuleGlidesAndCachedSeasonSkipsSkeleton() = seasons(false) + @Test fun reducedSeasonCapsuleSnapsAndCachedSeasonSkipsSkeleton() = seasons(true) + private fun seasons(reducedMotion: Boolean) { + val facade = mockk() + val series = TitleItem(id.toString(), "Series", "tv", held = true, seasons = 6) + fun image(color: Int) = android.graphics.Bitmap.createBitmap(2, 2, android.graphics.Bitmap.Config.ARGB_8888) + .apply { eraseColor(color) }.asImageBitmap() + bitmaps = mapOf("cache:fifth" to image(android.graphics.Color.RED), "cache:sixth" to image(android.graphics.Color.GREEN)) + val fifth = TitleItem("fifth", "Cached episode", "episode", season = 5, episode = 1, backdrop = "cache:fifth") + val sixth = fifth.copy(id = "sixth", name = "New episode", season = 6, backdrop = "cache:sixth") + val refresh = CompletableDeferred() + val data = TitlePageData(series, selectedSeason = 5, episodes = listOf(fifth)) + val navigation = mountModel(facade, data, reducedMotion) + coEvery { facade.read(id, 6, any()) } returns data.copy(selectedSeason = 6, episodes = listOf(sixth)) + coEvery { facade.read(id, 5, any()) } coAnswers { refresh.await() } + rule.runOnIdle { state.focus("seasons", 4) } + repeat(25) { tick() } + rule.runOnIdle { state.click("seasons", 5); state.focus("seasons", 0) } + val row = rule.onNode(hasText("Season 6") and hasClickAction()).fetchSemanticsNode().boundsInRoot + fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x -> + val pixel = bitmap.getPixel(x, (row.top + 4).toInt()) + android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230 + } ?: -1 + val values = List(23) { index -> frame("E07", "season", index, ::left).also { tick() } } + assertTrue(values.all { it >= 0 }) + if (reducedMotion) assertEquals(values.last(), values[1]) else monotonic(values) + values.forEach { assertTrue("No capsule overshoot", it <= values.last() + 1) } + rule.runOnIdle { state.click("seasons", 4) } + assertEquals("Cached episodes are installed synchronously", listOf(fifth), state.data.episodes) + assertEquals(TitleReadStatus.Ready, state.data.episodesStatus) + tick() + // Frame 0 may retain the outgoing real cards; the first incoming subtree is due by frame 1. + repeat(4) { index -> + assertEquals(listOf(fifth), state.data.episodes) + assertEquals(TitleReadStatus.Ready, state.data.episodesStatus) + val slot = hasTestTag("title-episode-slot-0") + rule.onAllNodes(slot, useUnmergedTree = true).assertCountEquals(1) + rule.onNode(slot and hasAnyDescendant(hasClickAction()), useUnmergedTree = true).assertExists() + rule.onAllNodes(hasText("Cached episode") or hasText("New episode"), useUnmergedTree = true).assertCountEquals(1) + val bounds = rule.onNode(slot, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val pixel = frame("E07", "cached-return", index) { bitmap -> + val pixels = (1..3).map { part -> + bitmap.getPixel((bounds.left + bounds.width * part / 4).toInt(), (bounds.top + 24).toInt()) + } + assertTrue("Every frame has opaque real episode art, never a skeleton, blank row or partial alpha", + pixels.all { it == android.graphics.Color.RED || it == android.graphics.Color.GREEN }) + assertEquals("The episode artwork stays uniformly opaque", 1, pixels.distinct().size) + pixels.first() + } + if (index >= 1) { + assertEquals("The first incoming frame paints cached Season 5", android.graphics.Color.RED, pixel) + rule.onNode(hasText("Cached episode") and hasAnyAncestor(slot), useUnmergedTree = true).assertExists() + } + tick() + } + rule.onNode(hasClickAction() and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true) + .performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.OnClick) { it() } + assertEquals(Destination.Wolfskin(titleRoute(fifth.id)), navigation.backStack.last()) + rule.runOnIdle { navigation.goBack() } + rule.runOnIdle { refresh.completeExceptionally(IllegalStateException("offline")) } + rule.waitUntil(5000) { shadowOf(Looper.getMainLooper()).idle(); state.toast == RecoveryMessage.StillUnreachable } + assertEquals(listOf(fifth), state.data.episodes) + saveSamples("E07") + } + + @Test fun titleScrollKeepsTheCastPinnedWithoutChangingFocus() = titleScroll(false) + @Test fun reducedTitleScrollSnapsWithoutChangingFocus() = titleScroll(true) + private fun titleScroll(reducedMotion: Boolean) { + val data = fixture().title("Andor", "justin") + render(data.copy(episodes = data.episodes.distinctBy { it.id }), reducedMotion) + rule.runOnIdle { state.focus("cast", 0) } + val positions = mutableListOf() + repeat(26) { index -> + frame("E05", "title-scroll", index) { brightness(it, 400, 900) } + val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "cast" }?.offset + if (offset != null) positions.add(offset) + tick() + } + rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress } + rule.waitForIdle() + val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "cast" }.offset + assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled) + if (reducedMotion) assertEquals(positions.last(), positions.first()) + else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) } + assertEquals("cast", state.row); assertEquals(0, state.column); assertFalse(shell.navFocused) + saveSamples("E05") + } + + @Test fun personScrollKeepsTheSelectedCreditRowPinned() = personScroll(false) + @Test fun reducedPersonScrollSnapsToTheSelectedCreditRow() = personScroll(true) + private fun personScroll(reducedMotion: Boolean) { + reduced.value = reducedMotion + prepareShell() + val person = PersonState(TitlePerson("actor", "Actor"), List(30) { TitleItem("credit-$it", "Credit $it", "movie") }, onAction = { }) + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced.value) { WolfskinTheme { + PersonSurface(person, shell, Modifier.requiredSize(960.dp, 540.dp)) + } } } + rule.waitForIdle(); rule.mainClock.advanceTimeBy(600); rule.mainClock.autoAdvance = false + rule.runOnIdle { person.index = 24 } + val positions = mutableListOf() + repeat(26) { index -> + frame("E05", "person-scroll", index) { brightness(it, 400, 900) } + val offset = shell.view.listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "credit-24" }?.offset + if (offset != null) positions.add(offset) + tick() + } + rule.mainClock.advanceTimeUntil(2000) { !shell.view.listState.isScrollInProgress } + rule.waitForIdle() + val settled = shell.view.listState.layoutInfo.visibleItemsInfo.first { it.key == "credit-24" }.offset + assertEquals((WolfskinTokens.Layout.pinnedLine.value * 2).toInt(), settled) + if (reducedMotion) assertEquals(positions.last(), positions.first()) + else positions.zipWithNext().forEach { (before, after) -> assertTrue(after <= before + 1) } + assertEquals(24, person.index); assertFalse(shell.navFocused) + saveSamples("E05") + } + + @Test fun cardFirstPaintUsesTheCurrentFractionBeforeAnyAnimationFrame() { + rule.mainClock.autoAdvance = false + rule.setContent { WolfskinTheme { + Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { + TitleCard(false, Modifier.testTag("first-card"), progress = .134f) { } + } + } } + rule.waitForIdle() + val bounds = rule.onNodeWithTag("first-card").fetchSemanticsNode().boundsInRoot + val y = (bounds.bottom - (WolfskinTokens.CardMetadata.bottom.value + WolfskinTokens.CardMetadata.resumeLine.value / 2) * 2).toInt() + val edge = frame("E06", "card-first-paint", 0) { bitmap -> + (0 until bounds.width.toInt()).lastOrNull { bitmap.getPixel(bounds.left.toInt() + it, y) == WolfskinTokens.Color.accent.toArgb() }?.plus(1) ?: 0 + } + assertEquals(bounds.width * .134f, edge.toFloat(), 1f) + } + + @Test fun seasonCapsuleRetargetsWithoutStoppingBetweenChoices() = retargetSeason(false) + @Test fun reducedSeasonCapsuleRetargetsInOneFrame() = retargetSeason(true) + private fun retargetSeason(reducedMotion: Boolean) { + val series = TitleItem("repeat", "Series", "tv", held = true, seasons = 8) + render(TitlePageData(series, selectedSeason = 3), reducedMotion) + rule.runOnIdle { state.focus("seasons", 0) } + repeat(25) { tick() } + val row = rule.onNode(hasText("Season 5") and hasClickAction()).fetchSemanticsNode().boundsInRoot + fun left(bitmap: android.graphics.Bitmap): Int = (0 until bitmap.width).firstOrNull { x -> + val pixel = bitmap.getPixel(x, (row.top + 4).toInt()) + android.graphics.Color.red(pixel) > 180 && android.graphics.Color.green(pixel) < 210 && android.graphics.Color.blue(pixel) > 230 + } ?: -1 + rule.runOnIdle { state.click("seasons", 3); state.focus("seasons", 0) } + val first = List(6) { index -> frame("E07", "season-repeat-first", index, ::left).also { tick() } } + rule.runOnIdle { state.click("seasons", 4); state.focus("seasons", 0) } + val second = List(23) { index -> frame("E07", "season-repeat-second", index, ::left).also { tick() } } + assertTrue(first.all { it >= 0 } && second.all { it >= 0 }) + if (reducedMotion) assertEquals(second.last(), second[1]) else { + val before = first.last() - first[first.lastIndex - 1] + val after = second[2] - second[1] + assertTrue("A moving capsule cannot restart from zero velocity: before=$before, after=$after", before > 0 && after >= before * .75f) + second.zipWithNext().forEach { (previous, next) -> assertTrue(next >= previous - 1) } + } + saveSamples("E07") + } + + @Test fun backCancelsPreparationWithoutALateFailureToast() = cancelPreparation(false) + @Test fun reducedBackCancelsPreparationWithoutALateFailureToast() = cancelPreparation(true) + private fun cancelPreparation(reducedMotion: Boolean) { + val (_, prepared, navigation) = pendingPlayback(reducedMotion) + rule.runOnIdle { state.click("header", 0) } + tick() + rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "version" }) } + tick() + rule.runOnIdle { state.back(shell) } + assertNull(state.playbackStart.starting) + assertNull(state.sheet) + rule.runOnIdle { prepared.completeExceptionally(IllegalStateException("late failure")) } + repeat(24) { tick() } + assertNull(state.toast) + assertFalse(navigation.backStack.last() is Destination.Playback) + rule.onNodeWithText("Starting…").assertDoesNotExist() + rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertDoesNotExist() + } + + @Test fun leavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(false) + @Test fun reducedLeavingTheTitleEntryCancelsPreparationWithoutALatePlayer() = cancelOwnership(true) + private fun cancelOwnership(reducedMotion: Boolean) { + val (facade, initial, navigation) = pendingPlayback(reducedMotion) + var prepared = initial + coEvery { facade.preparePlayback(id) } coAnswers { prepared.await() } + for (inactive in listOf(true, false)) { + rule.runOnIdle { state.click("header", 0) } + tick() + rule.runOnIdle { if (inactive) entryActive.value = false else state.click("related", 0) } + tick() + rule.mainClock.advanceTimeUntil(timeoutMillis = 80) { + !rule.onNodeWithTag("title-page").fetchSemanticsNode().config + .contains(androidx.compose.ui.semantics.SemanticsProperties.Focused) + } + val destination = navigation.backStack.last() + assertNull("The rendered entry has lost ownership (inactive=$inactive)", state.playbackStart.starting) + rule.runOnIdle { prepared.complete(BaseItemDto(id = id, type = BaseItemKind.MOVIE)) } + repeat(24) { tick() } + assertEquals("A late prepared stream cannot take over the new page", destination, navigation.backStack.last()) + assertFalse(destination is Destination.Playback) + assertNull(state.toast) + rule.onNodeWithText("Starting…").assertDoesNotExist() + rule.runOnIdle { + if (inactive) { entryActive.value = true; prepared = CompletableDeferred() } else navigation.goBack() + } + tick() + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt index 12aaadf2..10842787 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -197,10 +197,10 @@ class TitleRound2Test { render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) } menu() rule.mainClock.autoAdvance = false - val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() } + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P06/menu-to-info").apply { mkdirs() } rule.runOnIdle { state.clickSheet(0) } var waited = 0 - while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) { + while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.sheetIn + 16) { rule.mainClock.advanceTimeByFrame() rule.waitForIdle() waited += 16 @@ -212,7 +212,7 @@ class TitleRound2Test { val bitmap = BitmapFactory.decodeFile(path.absolutePath) fun brightness(x: Int, y: Int): Int { val pixel = bitmap.getPixel(x, y) - return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel) + return (android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)) / 3 } return try { (brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") } @@ -222,7 +222,10 @@ class TitleRound2Test { rule.waitForIdle() frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() } } - info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) } + info.forEach { assertEquals("Media info keeps one full scrim on every frame", info.last().second, it.second) } + info.zipWithNext().forEach { (before, after) -> assertTrue("The panel fades monotonically instead of popping", after.first >= before.first - 1) } + assertNotEquals("The Media info panel visibly fades", info.first().first, info.last().first) + assertEquals("The panel settles by sheetIn plus one frame", info.last().first, info[(WolfskinTokens.Motion.sheetIn / 16 + 1).coerceAtMost(info.lastIndex)].first) rule.runOnIdle { state.back(shell) } repeat(14) { index -> rule.mainClock.advanceTimeBy(16) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt index f2bcdf90..fb98bd3a 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleThemeMusicTest.kt @@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.TitleItem import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.design.WolfskinTokens import org.justinvisser.wolfskin.tv.read.TitleReadFacade import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -175,6 +176,8 @@ class TitleThemeMusicTest { assertEquals(listOf("play:$seriesId"), playback.events) playback.onStop = { if (playback.playing != null) assertEquals(destination, navigation.backStack.last()) } rule.runOnIdle { titleModel.title!!.click("header", 0) } + rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.base.toLong() + 48) + rule.waitForIdle() rule.waitUntil(5_000) { navigation.backStack.last() is Destination.Playback } playback.onStop = {} rule.waitForIdle() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index aeb7f687..0bd7253d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -4,6 +4,7 @@ import android.content.Context import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -83,19 +84,38 @@ class WolfskinTitleViewModel @Inject constructor( private var personReadPending = false private var personReadFinished = false private var loadJob: Job? = null + private var playbackJob: Job? = null + private var playbackGeneration = 0 + private val seasonEpisodes = mutableMapOf>() private val saveMutex = Mutex() private var performerPage = 1 var conductarrAction: ((TitleAction) -> Unit)? = null val watchScope: String get() = "${repository.currentServer?.id}:${repository.currentUser?.id}" - fun openRoute(route: String) = navigation.navigateTo(Destination.Wolfskin(route)) - fun popRoute() = navigation.goBack() + fun openRoute(route: String) { + cancelPlayback() + navigation.navigateTo(Destination.Wolfskin(route)) + } + fun popRoute() { + cancelPlayback() + navigation.goBack() + } + /** Preparation belongs to the active entry, including when Back closes an overlay. */ + internal fun cancelPlayback() { + playbackJob?.cancel() + playbackJob = null + title?.playbackStart?.fail() + } private val fixtureSelections by lazy { context.getSharedPreferences("wolfskin-title-fixture", Context.MODE_PRIVATE) } /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/") + if (!sameTitle) { + cancelPlayback() + seasonEpisodes.clear() + } val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG } if (force && readyTitle && fixture == null && @@ -117,6 +137,7 @@ class WolfskinTitleViewModel @Inject constructor( preferences = userPreferences val id = Uri.decode(destination.substringAfter('/')) if (fixtureRefresh == null) { + cancelPlayback() // Losing authorization clears the old data, but is not another first-load skeleton. val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle) @@ -140,13 +161,26 @@ class WolfskinTitleViewModel @Inject constructor( hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), ) if (fixtureRefresh != null) { - if (title === fixtureRefresh) fixtureRefresh.update(restored) - } else title = TitleState(restored, viewer, ::handle) + if (title === fixtureRefresh) updateTitle(fixtureRefresh, restored) + } else { + title = TitleState(restored, viewer, ::handle) + rememberSeason(restored) + } } } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) } } + private fun rememberSeason(data: TitlePageData) { + if (data.episodesStatus == TitleReadStatus.Ready) data.selectedSeason?.let { seasonEpisodes[it] = data.episodes } + } + + private fun updateTitle(current: TitleState, data: TitlePageData) { + if (title !== current) return + current.update(data) + rememberSeason(data) + } + private suspend fun readTitle(id: String, retry: Boolean = false) { val current = title ?: return val uuid = id.toUuidOrNull() @@ -157,9 +191,9 @@ class WolfskinTitleViewModel @Inject constructor( val heldId = uuid ?: catalogue?.let { facade.findHeld(it[1].toLong(), it[0]) } if (title !== current) return if (heldId != null) { - if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { current.update(it) } + if (current.data.status != TitleReadStatus.Ready) facade.readSaved(heldId, preferences)?.let { updateTitle(current, it) } held = facade.read(heldId, current.requestedSeason, preferences) - current.update(held) + updateTitle(current, held) } } catch (exception: Exception) { if (exception is CancellationException) throw exception @@ -174,7 +208,7 @@ class WolfskinTitleViewModel @Inject constructor( if (identity != null && tmdb != null) { val media = if ((catalogue?.get(0) ?: held?.item?.kind) == "movie") MediaType.MOVIE else MediaType.TV ConductarrTitlePages(identity).title(tmdb, media, held).collect { data -> - if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) current.update(data) + if (title === current && conductarr.identity.value?.client === identity.client && conductarr.identity.value?.signedIn == true) updateTitle(current, data) } } else if (uuid == null) current.update(current.data.copy(status = TitleReadStatus.Unavailable, connection = TitleConnection.SignedOut, requestsAllowed = false)) } @@ -253,9 +287,13 @@ class WolfskinTitleViewModel @Inject constructor( private fun handle(action: TitleAction) { when (action) { - TitleAction.Pop -> navigation.goBack() - is TitleAction.Person -> navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) + TitleAction.Pop -> popRoute() + is TitleAction.Person -> { + cancelPlayback() + navigation.navigateTo(action.person.id.toUuidOrNull()?.let { Destination.MediaItem(it, org.jellyfin.sdk.model.api.BaseItemKind.PERSON) } ?: Destination.Wolfskin(personRoute(action.person.id))) + } is TitleAction.OpenTitle -> { + cancelPlayback() val destination = action.item.id.toUuidOrNull()?.let { Destination.MediaItem(it, when (action.item.kind) { "movie" -> org.jellyfin.sdk.model.api.BaseItemKind.MOVIE "episode" -> org.jellyfin.sdk.model.api.BaseItemKind.EPISODE @@ -267,7 +305,7 @@ class WolfskinTitleViewModel @Inject constructor( is TitleAction.Play -> { val opened = title?.data?.watchTarget play((if (action.restart || opened?.kind != "tv") action.item.id else opened.id).toUuidOrNull(), - restart = action.restart) + restart = action.restart, control = if (action.restart) "restart" else "play") } is TitleAction.Trailer -> if (action.url.startsWith("jellyfin:")) play(action.url.removePrefix("jellyfin:").toUuidOrNull(), restart = true) else { @@ -276,16 +314,21 @@ class WolfskinTitleViewModel @Inject constructor( } is TitleAction.Version, is TitleAction.Track, is TitleAction.Watched -> savePlayback(action) is TitleAction.Season -> { + val current = title ?: return loadJob?.cancel() + val cached = seasonEpisodes[action.number] + if (cached == null) current.showSeasonLoading() else current.showSeason(action.number, cached) loadJob = viewModelScope.launch { - val current = title ?: return@launch - current.showSeasonLoading() val source = fixture if (source == null) readTitle(Uri.decode(route.substringAfter('/'))) - else { + else try { val data = withContext(Dispatchers.IO) { source.title(Uri.decode(route.substringAfter('/')), viewer.key, action.number) } - current.update(data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, + updateTitle(current, data.copy(selectedVersion = current.versionId, selectedAudio = current.audioIndex, selectedSubtitle = current.subtitleIndex, watchTarget = current.data.watchTarget.copy(watched = current.watched), hidden = current.data.hidden)) + } catch (exception: Exception) { + if (exception is CancellationException) throw exception + if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() + current.toast = RecoveryMessage.StillUnreachable } } } @@ -304,19 +347,33 @@ class WolfskinTitleViewModel @Inject constructor( } } - private fun play(id: UUID?, restart: Boolean) { - val current = title + private fun play(id: UUID?, restart: Boolean, control: String = "restart") { + val current = title ?: return + if (current.playbackStart.starting != null) return + val generation = ++playbackGeneration + playbackJob?.cancel() + current.playbackStart.begin(control) val playbackScope = watchScope - viewModelScope.launch { + playbackJob = viewModelScope.launch { try { if (id == null) throw IllegalArgumentException("No Jellyfin playback identity") val item = facade.preparePlayback(id) ?: throw IllegalArgumentException("No playable item") - if (title !== current || watchScope != playbackScope) return@launch - themeSongPlayer.stop() - navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + if (generation != playbackGeneration) return@launch + if (title !== current || watchScope != playbackScope || current.playbackStart.starting == null) { + current.playbackStart.fail() + return@launch + } + current.playbackStart.ready { + if (generation == playbackGeneration && title === current && watchScope == playbackScope) { + themeSongPlayer.stop() + navigation.navigateTo(Destination.Playback(item.id, if (restart) 0L else item.playbackPositionMs())) + } + } } catch (exception: Exception) { + val active = generation == playbackGeneration && current.playbackStart.starting != null + if (active) current.playbackStart.fail() if (exception is CancellationException) throw exception - title?.toast = RecoveryMessage.PlaybackUnavailable + if (active && title === current && watchScope == playbackScope) current.toast = RecoveryMessage.PlaybackUnavailable } } } @@ -358,20 +415,30 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen val watchRefresh = rememberWatchRefresh(viewModel.watchScope) LaunchedEffect(route, shell.person.key, identity?.client, identity?.signedIn, fixtures, watchRefresh) { viewModel.load(route, shell.person, preferences, fixtures, force = true) } val title = viewModel.title?.takeIf { it.person.key == shell.person.key } + val active = isScreenEntryActive() + DisposableEffect(viewModel, title, active) { + if (!active) viewModel.cancelPlayback() + onDispose { viewModel.cancelPlayback() } + } TitleThemeMusic(title?.data?.item, preferences, viewModel.themeSongPlayer, - active = isScreenEntryActive() && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready, + active = active && route.startsWith("title/") && title?.data?.status == TitleReadStatus.Ready, owner = "${shell.person.key}:${viewModel.watchScope}") val requests = if (route.startsWith("title/")) rememberTitleRequests(viewModel, shell, route, identity, fixtures, title) else null LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } val person = viewModel.person.takeIf { title != null } - BackHandler(enabled = isScreenEntryActive() && title != null) { - viewModel.handleBack(route, shell) { - if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + BackHandler(enabled = active && title != null) { + if (title?.playbackStart?.launch == null) { + viewModel.cancelPlayback() + viewModel.handleBack(route, shell) { + if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) + } } } Box(modifier.onPreviewKeyEvent { event -> + if (title?.playbackStart?.launch != null) return@onPreviewKeyEvent true + if (readShellKey(event) == ShellKey.Back && event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) viewModel.cancelPlayback() if (requests?.overlay == null || shell.pickerVisible) false else readShellKey(event)?.let { if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) requests.key(it) true diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index c6d45ab7..80507be8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -116,7 +115,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier else -> L.row_phead + L.row_gh + D.gridHeight * (first - 2) } val current = with(density) { before.toPx() } + list.firstVisibleItemScrollOffset - if (target != current) list.animateScrollBy(target - current, titleFrameScrollSpec) + if (target != current) animateFrameScroll(list, target - current) } Box(modifier.testTag("person-page").semantics { stateDescription = "${state.person.id}:${state.index}" } .titleWheel { wheelPending = true; shell.navFocused = false } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index ea760054..cacef3f0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -1,5 +1,11 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -33,6 +39,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) + val animatedProgress = rememberTitleProgress(null, progress.coerceIn(0f, 1f), hasResumeLine = progress > 0f) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) @@ -51,9 +58,21 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs val top = Offset(0f, size.height - edge - fill) if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill)) drawRect(WolfskinTokens.Color.accent, top, - Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill)) + Size(size.width * if (progress > 0f) animatedProgress.value else 1f, fill)) } } }, content = content) } } + +/** Target and line-kind changes cut; a held resume line interpolates even when runtime arrives late. */ +@Composable +internal fun rememberTitleProgress(identity: String?, fraction: Float, hasResumeLine: Boolean): State { + val reduced = reducedMotion() + val progress = remember(identity, hasResumeLine) { Animatable(fraction) } + LaunchedEffect(progress, fraction, reduced) { + if (reduced) progress.snapTo(fraction) + else progress.animateTo(fraction, tween(WolfskinTokens.Motion.heroFade, easing = WolfskinTokens.Motion.easeInOut)) + } + return if (reduced) rememberUpdatedState(fraction) else progress.asState() +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index 2441c8ed..ab52201a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role @@ -25,24 +26,35 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D +/** Retained sheet controls are painted but cannot receive focus, hover or clicks. */ +internal val LocalTitleControlsEnabled = compositionLocalOf { true } + /** Compact title controls reserve captions beneath the whole row, not inside an expanding button. */ @Composable -fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, onClick: () -> Unit) { +fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Modifier, onFocus: (() -> Unit)? = null, overArtwork: Boolean = true, busy: Boolean = false, busyLabel: String? = null, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current + val enabled = LocalTitleControlsEnabled.current val background = if (option.primary) C.fg else if (overArtwork) { if (focused) C.titleButtonSecondaryFocused else C.titleButtonSecondary } else if (focused) C.buttonSecondaryFocused else C.buttonSecondary val foreground = if (option.primary) C.onFocus else C.fg val shape = RoundedCornerShape(WolfskinTokens.Radius.button) val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) - Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) + Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleControl) .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(background, shape) - .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), + .clickable(enabled = enabled && !busy) { focusOwner?.invoke(); onClick() }.then(dimensions), horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { - if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) - if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + if (busyLabel != null) BusyLabelSwap(busy, busyLabel, foreground, WolfskinTokens.Type.button) { + Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { + if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(L.buttonIcon)) + Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + } + } else { + if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) + if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index f7cfe54f..caa84a14 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -4,6 +4,8 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.runtime.getValue import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.platform.testTag @@ -13,7 +15,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -86,10 +87,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target -> Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) { if (target.resumeSeconds > 0) { - Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) { - val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f - Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(C.accent)) - } + val fraction = if (target.runtimeSeconds > 0) (target.resumeSeconds.toFloat() / target.runtimeSeconds).coerceIn(0f, 1f) else 0f + val progress = rememberTitleProgress(target.id, fraction, hasResumeLine = true) + Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress)) + .drawBehind { drawRect(C.accent, size = Size(size.width * progress.value, size.height)) }) Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress) } else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress) } @@ -97,7 +98,8 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, - onFocus = { state.focus("header", index) }) { state.click("header", index) } } + onFocus = { state.focus("header", index) }, busy = option.id == "play" && state.playbackStart.starting == "play", + busyLabel = if (option.id == "play") "Starting…" else null) { state.click("header", index) } } } if (state.options.any { it.icon }) { val option = state.options.getOrNull(state.column)?.takeIf { active && it.icon } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt index 64e848e1..080262ae 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt @@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -41,15 +44,21 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) { LaunchedEffect(visible, reduced) { if (visible) focus.requestFocus() opacity.animateTo(if (visible) 1f else 0f, - tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) + tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut)) } if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) { Box(Modifier.fillMaxSize().background(C.sheetScrim) - .clickable { if (visible) state.back(shell) } + .clickable(enabled = visible) { state.back(shell) } + .pointerInput(visible) { + if (!visible) awaitPointerEventScope { + while (true) awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() } + } + } .focusRequester(focus).onPreviewKeyEvent { event -> val key = readShellKey(event) visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { + }.focusable(enabled = visible)) { + CompositionLocalProvider(LocalTitleControlsEnabled provides visible) { Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth) .graphicsLayer { alpha = opacity.value } .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) @@ -64,6 +73,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) { } } } + } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 49039828..c1e01ab1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -1,5 +1,11 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable @@ -44,25 +50,65 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** Sheets sit above navigation and consume pointer and key input across the entire scrim. */ @Composable fun TitleOptionsSheet(state: TitleState, shell: ShellState) { + val visible = state.sheet != null && state.sheet != TitleSheet.Menu + val menu = state.sheet == TitleSheet.Menu + val previous = remember { arrayOf(null) } + val retainedSheet = remember { arrayOf(null) } + val retained = remember(state) { TitleState(state.data, state.person) { } } + val panelScrim = remember { booleanArrayOf(false) } + val ownsScrim = visible || (panelScrim[0] && !menu) + val fromMenu = visible && previous[0] == TitleSheet.Menu + val fade = rememberSheetFade(visible) + val scrim = remember { Animatable(0f) } + val reduced = reducedMotion() val focus = remember { FocusRequester() } - LaunchedEffect(state.sheet) { focus.requestFocus() } + SideEffect { + previous[0] = state.sheet + if (visible || menu) panelScrim[0] = visible + if (visible) { + retainedSheet[0] = state.sheet + retained.update(state.data) + retained.sheetIndex = state.sheetIndex + retained.versionId = state.versionId + retained.audioIndex = state.audioIndex + retained.subtitleIndex = state.subtitleIndex + retained.watched = state.watched + } + } + LaunchedEffect(visible, menu, reduced) { + if (visible) focus.requestFocus() + if (menu || fromMenu) scrim.snapTo(1f) + if (!menu) scrim.animateTo(if (visible) 1f else 0f, + tween(motionDuration(if (visible) WolfskinTokens.Motion.sheetIn else WolfskinTokens.Motion.sheetOut, reduced), easing = WolfskinTokens.Motion.easeOut)) + } + if (!visible && fade.value == 0f && (!ownsScrim || scrim.value == 0f)) return + val sheet = if (visible) state.sheet else retainedSheet[0] + val content = if (visible) state else retained val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) } - Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { } - .testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" } + Box(Modifier.fillMaxSize().sheetScrim { if (!ownsScrim) 0f else if (fromMenu) 1f else scrim.value } + .pointerInput(visible) { + awaitPointerEventScope { + while (true) awaitPointerEvent(if (visible) PointerEventPass.Final else PointerEventPass.Initial).changes.forEach { it.consume() } + } + } + .testTag("title-sheet").semantics { stateDescription = "$sheet:${content.sheetIndex}" } .focusRequester(focus).onPreviewKeyEvent { event -> val key = readShellKey(event) - key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { - CssColumn(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) - .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), - gap = if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { - when (state.sheet) { - TitleSheet.Version -> VersionChoices(state) - TitleSheet.Tracks -> TrackChoices(state) - TitleSheet.MediaInfo -> MediaInformation(state) - TitleSheet.ConfirmWatched -> WatchedConfirmation(state) - TitleSheet.HiddenReceipt -> HiddenReceipt(state) - TitleSheet.Menu, null -> Unit + visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) + }.focusable(enabled = visible)) { + CompositionLocalProvider(LocalTitleControlsEnabled provides visible, LocalTitleFocusOwner provides null) { + CssColumn(Modifier.sheetPanelFade { fade.value }.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight() + .dropShadow(RectangleShape, shadow).background(C.surface) + .verticalScroll(rememberScrollState(), enabled = visible).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), + gap = if (sheet == TitleSheet.Tracks) D.trackGap else if (sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap) { + when (sheet) { + TitleSheet.Version -> VersionChoices(content) + TitleSheet.Tracks -> TrackChoices(content) + TitleSheet.MediaInfo -> MediaInformation(content) + TitleSheet.ConfirmWatched -> WatchedConfirmation(content) + TitleSheet.HiddenReceipt -> HiddenReceipt(content) + TitleSheet.Menu, null -> Unit + } } } } @@ -100,11 +146,12 @@ private fun TrackChoices(state: TitleState) { @Composable private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focused: Boolean, onFocus: () -> Unit, onClick: () -> Unit) { + val enabled = LocalTitleControlsEnabled.current val foreground = if (focused) C.onFocus else C.fg val sizing = if (why == null) Modifier.height(D.trackHeight) else Modifier.padding(vertical = D.versionRowVertical) - Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleRow) + Row(Modifier.fillMaxWidth().sheetFocused(focused).titlePointerFocus(onFocus.takeIf { enabled }).focusScale(focused, WolfskinTokens.Focus.scaleRow) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) - .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), + .clickable(enabled = enabled, onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { CssColumn(Modifier.weight(1f)) { Text(label, color = foreground, style = TitleTextStyles.listTitle, modifier = Modifier.exactLineBox(TitleTextStyles.listTitle)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 81fb0ce0..9cc90fb0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -28,9 +28,23 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester @@ -54,6 +68,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi val view = screenView(shell) LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> + if (state.playbackStart.launch != null) return@onPreviewKeyEvent true // No content target exists during an initial load; Down must not enter a skeleton. if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false else when (readShellKey(event)) { @@ -69,9 +84,10 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } } - if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell) TitleMenu(state, shell) + if (!shell.pickerVisible) TitleOptionsSheet(state, shell) if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } + PlaybackHandoffVeil(state.playbackStart) } } @@ -81,7 +97,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism LaunchedEffect(message) { if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } } - if (message != null) RecoveryToast(message, modifier) + RecoveryToastHost(message, modifier) } /** One keyed vertical frame renders the exact header, season, cast and related order. */ @@ -92,6 +108,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val entryActive = LocalScreenActive.current var wheelPending by remember { mutableStateOf(false) } val focus = remember { FocusRequester() } + val sheetWasOpen = remember { booleanArrayOf(state.sheet != null) } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } val compactHeader = state.data.status == TitleReadStatus.Ready val item = state.data.item @@ -99,7 +116,12 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) { - if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() + val restoring = sheetWasOpen[0] && state.sheet == null + sheetWasOpen[0] = state.sheet != null + if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) { + if (restoring) withFrameNanos { } + focus.requestFocus() + } } val displayedRows = remember(state.data) { buildList { @@ -138,7 +160,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() } val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f else y - with(density) { L.pinnedLine.toPx() } - if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) + if (target != position) animateFrameScroll(list, target - position) } val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } } val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } } @@ -197,13 +219,66 @@ private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) { private fun TitleSeasons(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current val list = androidx.compose.foundation.lazy.rememberLazyListState() - val visibleChip = if (active) state.column else state.data.seasonNumbers.indexOf(state.season) - LaunchedEffect(visibleChip, state.data.seasonNumbers) { + val reduced = reducedMotion() + val selectedIndex = state.data.seasonNumbers.indexOf(state.season) + val visibleChip = if (active) state.column else selectedIndex + LaunchedEffect(visibleChip, state.data.seasonNumbers, reduced) { if (visibleChip >= 0) { - if (NetworkArtTransition.reduced) list.scrollToItem(visibleChip) else list.animateScrollToItem(visibleChip) + val visible = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == visibleChip } + if (visible == null) list.scrollToItem(visibleChip) + else animateFrameScroll(list, visible.offset.toFloat()) } } - androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop), state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { + var capsuleIndex by remember { mutableIntStateOf(selectedIndex) } + val capsulePosition = remember { Animatable(0f) } + val capsuleWidth = remember { Animatable(0f) } + // A stable virtual row coordinate preserves the spring's frame time and velocity on retarget. + var capsuleTarget by remember { mutableFloatStateOf(0f) } + val selection = rememberUpdatedState(selectedIndex) + LaunchedEffect(list, reduced) { + snapshotFlow { selection.value }.collectLatest { index -> + val target = snapshotFlow { list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } }.filterNotNull().first() + val previous = list.layoutInfo.visibleItemsInfo.firstOrNull { it.index == capsuleIndex } + if (reduced || capsuleWidth.value == 0f || previous == null) { + capsulePosition.snapTo(0f) + capsuleWidth.snapTo(target.size.toFloat()) + capsuleTarget = 0f + capsuleIndex = index + } else { + val targetPosition = capsuleTarget + target.offset - previous.offset + capsuleTarget = targetPosition + capsuleIndex = index + launch { capsulePosition.animateTo(targetPosition, spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) } + if (capsuleWidth.targetValue != target.size.toFloat()) launch { + capsuleWidth.animateTo(target.size.toFloat(), spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness)) + } + } + } + } + androidx.compose.foundation.lazy.LazyRow(Modifier.height(L.row_chips).padding(top = D.chipTop).drawWithCache { + val height = D.chipHeight.toPx() + val radius = WolfskinTokens.Radius.chip.toPx() + val border = D.chipBorder.toPx() + val stroke = Stroke(border) + val inset = Offset(border / 2, border / 2) + val outerCorner = CornerRadius(radius) + val innerCorner = CornerRadius(radius - border / 2) + onDrawBehind { + val layout = list.layoutInfo + val visible = layout.visibleItemsInfo + val selected = capsuleIndex + var index = 0 + while (index < visible.size) { + val chip = visible[index++] + if (chip.index != selected) continue + val topLeft = Offset(chip.offset + layout.beforeContentPadding + capsulePosition.value - capsuleTarget, 0f) + val bounds = Size(capsuleWidth.value, height) + drawRoundRect(C.accentSoft, topLeft, bounds, outerCorner) + drawRoundRect(C.accent, topLeft + inset, Size(bounds.width - border, bounds.height - border), innerCorner, style = stroke) + break + } + } + }, state = list, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(D.chipGap)) { val seasonNumbers = state.data.seasonNumbers val keys = seasonNumbers.map { "season-$it" }.uniqueKeys() items(seasonNumbers.size, key = { keys[it] }, contentType = { "chip" }) { index -> @@ -211,12 +286,12 @@ private fun TitleSeasons(state: TitleState, active: Boolean) { val focused = active && state.column == index val selected = state.season == number val shape = RoundedCornerShape(WolfskinTokens.Radius.chip) + val label = animatedFocusColor(if (focused) C.onFocus else if (selected) C.accentText else C.fg) Box(Modifier.height(D.chipHeight).titlePointerFocus { state.focus("seasons", index) }.focusScale(focused, WolfskinTokens.Focus.scaleControl) .onGloballyPositioned { state.recordCenter("seasons", index, it.boundsInRoot().center.x) } - .background(if (focused) C.fg else if (selected) C.accentSoft else C.chipRest, shape) - .then(if (selected && !focused) Modifier.border(D.chipBorder, C.accent, shape) else Modifier) + .background(if (focused) C.fg else if (selected) androidx.compose.ui.graphics.Color.Transparent else C.chipRest, shape) .clickable { focusOwner?.invoke(); state.click("seasons", index) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) { - Text("Season $number", color = if (focused) C.onFocus else if (selected) C.accentText else C.fg, style = WolfskinTokens.Type.label) + Text("Season $number", color = label, style = WolfskinTokens.Type.label) } } } @@ -238,4 +313,3 @@ private fun TitleReadError(state: TitleState) { } } -internal val titleFrameScrollSpec get() = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 70157f2e..d7cdba74 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -20,6 +20,11 @@ class TitleState( private val centers = HashMap>() fun recordCenter(row: String, index: Int, x: Float) { centers.getOrPut(row) { HashMap() }[index] = x } fun showSeasonLoading() { if (data.episodesStatus != TitleReadStatus.Loading) data = data.copy(episodesStatus = TitleReadStatus.Loading) } + /** Cached episodes replace the loading state on the season-selection frame. */ + fun showSeason(number: Int, episodes: List) { + season = number + data = data.copy(selectedSeason = number, episodes = episodes, episodesStatus = TitleReadStatus.Ready) + } fun restoreSeason() { season = data.selectedSeason ?: season; data = data.copy(episodesStatus = TitleReadStatus.Ready) } var sheet by mutableStateOf(null) private set @@ -37,6 +42,7 @@ class TitleState( var subtitleIndex by mutableStateOf(initial.selectedSubtitle) var watched by mutableStateOf(initial.watchTarget.watched) var toast by mutableStateOf(null) + val playbackStart = PlaybackStart() private var armedCard: String? = null private var seasonChosen = false val requestedSeason: Int? get() = season.takeIf { seasonChosen } @@ -115,6 +121,7 @@ class TitleState( /** Key-up can only activate the same card that armed the first key-down. */ fun key(key: ShellKey, down: Boolean, repeat: Int, shell: ShellState): Boolean { + if (playbackStart.launch != null) return true if (key == ShellKey.Ok) { if (!down) { okHeld = false @@ -147,6 +154,8 @@ class TitleState( /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ fun back(shell: ShellState) { + if (playbackStart.launch != null) return + playbackStart.fail() when { sheet != null -> closeSheet() shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) @@ -187,7 +196,7 @@ class TitleState( private fun activateOption(id: String) { when (id) { - "play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) } + "play", "restart" -> if (data.watchReady && playbackStart.starting == null) target?.let { onAction(TitleAction.Play(it, id == "restart")) } "version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version } "tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks } "menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }