Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82

Merged
justinzeus merged 27 commits from fm/tv-round2-candidate3 into fm/tv-round2 2026-11-08 13:58:41 +01:00
13 changed files with 820 additions and 78 deletions
Showing only changes of commit f05b0de156 - Show all commits

View file

@ -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<ComponentActivity>()
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<ServerRepository>(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()

View file

@ -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<ComponentActivity>()
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<String>()
private var bitmaps: Map<String, ImageBitmap> = 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<ServerRepository>(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<Int>) {
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<Int>()
val panel = mutableListOf<Int>()
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<Int>()
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<TitleReadFacade, CompletableDeferred<BaseItemDto?>, NavigationManager> {
val facade = mockk<TitleReadFacade>()
val prepared = CompletableDeferred<BaseItemDto?>()
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<Int>()
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<TitleReadFacade>()
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<TitlePageData>()
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<Int>()
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<Int>()
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()
}
}
}

View file

@ -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)

View file

@ -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()

View file

@ -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<Int, List<TitleItem>>()
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

View file

@ -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 }

View file

@ -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<Float> {
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()
}

View file

@ -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)
}
}
}

View file

@ -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 }

View file

@ -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) {
}
}
}
}
}
}
}

View file

@ -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<TitleSheet?>(null) }
val retainedSheet = remember { arrayOf<TitleSheet?>(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))

View file

@ -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<Float>(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)

View file

@ -20,6 +20,11 @@ class TitleState(
private val centers = HashMap<String, MutableMap<Int, Float>>()
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<TitleItem>) {
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<TitleSheet?>(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<String?>(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 }