Candidate 3: merge polish-lane-d

This commit is contained in:
root 2026-11-08 06:50:17 +01:00
commit 4785fd8d5e
13 changed files with 820 additions and 78 deletions

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