Candidate 3: merge polish-lane-c

This commit is contained in:
root 2026-11-08 06:50:17 +01:00
commit aa470b96b6
20 changed files with 1204 additions and 84 deletions

View file

@ -1,6 +1,9 @@
package com.github.damontecres.wholphin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@ -66,6 +69,9 @@ fun MainContent(
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),

View file

@ -57,7 +57,7 @@ class ConductarrHomeSurfaceTest {
coEvery { home.readLibrary(user.id) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session)
val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
try {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
rule.setContent { WolfskinTheme {

View file

@ -0,0 +1,192 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Real Home actions keep button geometry, reject duplicate starts and launch only after the black handoff. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeHeroStartingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var model: WolfskinHomeViewModel
private val conductarr = ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore())
private val session = WolfskinSession()
private val navigation = NavigationManager()
private val facade = mockk<HomeReadFacade>()
private val release = CompletableDeferred<BaseItemDto?>()
private val visible = mutableStateOf(true)
private var previousFixtures = false
private val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
private val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Mindel", accessToken = null)
private val shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false; pickerLaunch = false }
@After fun dispose() {
release.complete(null)
if (::model.isInitialized) model.viewModelScope.cancel()
conductarr.close()
ConductarrPreference.updateFixtures(previousFixtures)
}
private fun mount(reduced: Boolean = false) {
ConductarrPreference.initialize(rule.activity)
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(true)
coEvery { facade.preparePlayback(user.id, any()) } coAnswers { release.await() }
every { facade.audiences } returns null
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), conductarr, session)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced, LocalHomePlaybackStart provides session.playback) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
if (visible.value && navigation.backStack.last() !is Destination.Playback)
ShellSurface(shell) { modifier -> HomeDestination(shell, server, user, modifier, model) }
PlaybackHandoffVeil(session.playback)
}
} } }
await { model.snapshot.rows.firstOrNull()?.let { !it.skeleton && it.watchReady } == true }
rule.mainClock.advanceTimeBy(1200)
rule.mainClock.autoAdvance = false
}
@Test fun playSaysStartingThenFadesToBlackBeforeThePlayer() = successfulStart(false)
@Test fun reducedMotionCapsBothFadesAndStillStartsOnce() = successfulStart(true)
private fun successfulStart(reduced: Boolean) {
mount(reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
val width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
val clock = rule.mainClock.currentTime
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
assertEquals("Native capture cannot warm the animation clock", clock, rule.mainClock.currentTime)
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try { image.getPixel(600, 500) } finally { image.recycle() }
samples.add(phase + "," + index * 16 + "," + pixel + "," + navigation.backStack.size)
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
press()
val origin = rule.mainClock.currentTime
repeat(14) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
frame("home-start", index)
assertEquals("Every frame after OK is observed, including frame zero", origin + index * 16L, rule.mainClock.currentTime)
if (index >= 2) {
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
}
if (index == 3) press()
assertTrue(navigation.backStack.single() is Destination.Home)
}
val id = UUID.randomUUID()
release.complete(BaseItemDto(id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE))
await { session.playback.launch != null }
val duration = motionDuration(WolfskinTokens.Motion.base, reduced)
var elapsed = 0
val fading = mutableListOf<Int>()
while (navigation.backStack.last() !is Destination.Playback && elapsed <= duration + 48) {
fading.add(frame("home-black", elapsed / 16))
rule.mainClock.advanceTimeByFrame()
elapsed += 16
}
assertTrue("The player cannot open before the token-bounded fade", elapsed >= duration)
assertTrue("The page must visibly fade rather than wait and cut", fading.first() > fading.last())
assertTrue("The black handoff cannot flicker", fading.zipWithNext().all { (before, after) -> after <= before })
assertTrue("The final painted frame must be near black", fading.last() <= fading.first() / 10)
assertEquals(id, (navigation.backStack.last() as Destination.Playback).itemId)
assertEquals(2, navigation.backStack.size)
assertNull(session.playback.starting)
assertNull(session.playback.launch)
coVerify(exactly = 1) { facade.preparePlayback(user.id, any()) }
// Returning from the player requires a painted handoff first, not a pop in the same unrendered turn.
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-disposed.png").absolutePath)
rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0)
File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
rule.runOnIdle { navigation.goBack(); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "home-return-first.png").absolutePath)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText("Starting…").assertDoesNotExist()
}
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
mount()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/failure").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
press()
repeat(14) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "preparing-" + index.toString().padStart(2, '0') + ".png").absolutePath)
if (index >= 2) rule.onNodeWithText("Starting…").assertIsDisplayed()
}
release.completeExceptionally(IOException("offline"))
await { model.toast != null }
repeat(14) { index ->
rule.mainClock.advanceTimeByFrame()
rule.onRoot().captureRoboImage(File(directory, "restored-" + index.toString().padStart(2, '0') + ".png").absolutePath)
}
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
}
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
mount()
press()
assertEquals("Leaving Home must cancel an actual pending start", "hero-play", session.playback.starting)
rule.mainClock.advanceTimeBy(32)
rule.runOnIdle { visible.value = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(32)
release.complete(BaseItemDto(id = UUID.randomUUID(), type = org.jellyfin.sdk.model.api.BaseItemKind.MOVIE))
await { session.playback.starting == null }
rule.mainClock.advanceTimeBy(400)
assertTrue(navigation.backStack.single() is Destination.Home)
assertNull(session.playback.launch)
}
private fun press() {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
}
private fun await(condition: () -> Boolean) {
rule.waitUntil(5000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
condition()
}
rule.waitForIdle()
}
}

View file

@ -45,7 +45,7 @@ class HomeRecoveryTest {
val facade = mockk<HomeReadFacade>()
coEvery { facade.readWatch(personA.id) } coAnswers { started.complete(Unit); response.await() }
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
@ -67,7 +67,7 @@ class HomeRecoveryTest {
coEvery { facade.readWatch(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.readWatch(personB.id) } throws IOException("fixture offline")
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
startedA.await()
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
@ -88,7 +88,7 @@ class HomeRecoveryTest {
val cache = mockk<HomeLibraryCache>()
every { cache.read(any()) } returns null
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
model.loadLibrary(server, personA, viewerA)
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
@ -101,7 +101,7 @@ class HomeRecoveryTest {
coEvery { facade.readLibrary(any()) } returns HomeLibrary()
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)

View file

@ -64,7 +64,7 @@ class HomeSeeAllNavigationTest {
}
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val model = WolfskinHomeViewModel(rule.activity, mockk(), mockk(), navigation, lists, ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
val facade = mockk<LibraryReadFacade>()
rule.setContent {
val destination = navigation.backStack.last()

View file

@ -0,0 +1,413 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import android.os.Looper
import androidx.compose.foundation.background
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.*
import com.github.damontecres.wholphin.services.*
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.util.UUID
import kotlinx.coroutines.cancel
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
private const val PLAYER_PAINT = 0xff204060
/** Native forward cuts paint by 32 ms, Back by 64 ms; all outgoing semantics retire by 64 ms. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NavigationCutTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var host: PolishAppHost
@After fun dispose() { if (::host.isInitialized) host.close() }
@Test fun titleAndTabPaintWithinTwoFramesBackWithinFour() = cuts(false)
@Test fun reducedMotionKeepsOpaqueCutsAndRetiresOutgoingByFourFrames() = cuts(true)
private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(picker = false, reduced = reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
val homeCard = SemanticsMatcher("Home card") {
it.config.contains(androidx.compose.ui.semantics.SemanticsProperties.TestTag) &&
it.config[androidx.compose.ui.semantics.SemanticsProperties.TestTag].startsWith("home-card:")
}
// Establish a painted Home and a focused shelf card before starting the navigation clock.
rule.onRoot().captureRoboImage(File(directory, "home-precondition.png").absolutePath)
rule.onNodeWithText("Who’s watching?").assertDoesNotExist()
rule.runOnIdle { host.session.state!!.focusNav(0); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
repeat(2) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L) }
rule.waitForIdle()
}
assertFalse("Home card activation cannot start from a picker", host.session.state!!.pickerVisible)
assertNotNull("The OK precondition is a shelf card, not the nav or hero", host.session.state!!.contentFocus?.cardKey)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.onRoot().captureRoboImage(File(directory, "home-card-ready.png").absolutePath)
rule.mainClock.autoAdvance = false
fun strip(phase: String, previousFrame: File) {
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null
// These fixture-art probes are outside the entering title copy. Composition alone cannot prove paint.
val x = intArrayOf(1500, 1700, 1900, 1500, 1700, 1900)
val y = intArrayOf(300, 500, 700, 700, 900, 300)
val outgoingPaint = IntArray(x.size)
val paint = IntArray(x.size)
val previous = BitmapFactory.decodeFile(previousFrame.absolutePath)
val opaqueAvatar = try {
for (point in x.indices) outgoingPaint[point] = previous.getPixel(x[point], y[point])
previous.getPixel(1774, 90)
} finally { previous.recycle() }
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
val libraries = rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().size
val image = BitmapFactory.decodeFile(file.absolutePath)
val avatar = try {
for (point in x.indices) paint[point] = image.getPixel(x[point], y[point])
image.getPixel(1774, 90)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$shells,$titles,$cards,$libraries,$avatar,${paint.joinToString(";")}")
assertEquals("Observing a native frame cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
assertEquals("The persistent avatar cannot acquire whole-screen transition alpha or a second blended nav", opaqueAvatar, avatar)
val composed = when (phase) {
"open-title" -> titles == 1
"back" -> cards > 0
else -> libraries == 1
}
val incoming = composed && !paint.contentEquals(outgoingPaint)
if (incoming && firstIncoming == null) {
firstIncoming = index
val limit = if (phase == "back") 4 else 2
assertTrue("$phase first painted incoming after $index frames (${index * 16} ms), exceeding $limit frames", index <= limit)
}
if (firstIncoming != null) assertTrue("Native paint cannot revert to the outgoing screen", incoming)
if (index >= 4) {
assertEquals("By 64 ms exactly one destination owns the nav semantics", 1, shells)
assertEquals(if (phase == "open-title") 1 else 0, titles)
assertEquals(if (phase == "tab") 1 else 0, libraries)
if (phase != "back") assertEquals("Outgoing Home cards must retire by 64 ms", 0, cards)
else assertTrue("Back must restore Home, not leave a blank destination", cards > 0)
}
}
assertNotNull("Incoming destination did not appear within the 320 ms strip", firstIncoming)
} finally {
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards,libraries,avatarPixel,backdropPixels\n" + samples.joinToString("\n"))
}
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter, 0L) }
assertEquals("OK must change the back stack before the first observed frame", 2, host.navigation.backStack.size)
strip("open-title", File(directory, "home-card-ready.png"))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back", File(directory, "open-title-20.png"))
// Settle focus before OK; that focus animation is not a screen transition.
rule.mainClock.autoAdvance = true
rule.runOnIdle { host.session.state!!.focusNav(2); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.onRoot().captureRoboImage(File(directory, "tab-ready.png").absolutePath)
assertTrue(host.navigation.backStack.single() is Destination.Home)
rule.mainClock.autoAdvance = false
rule.runOnIdle { host.session.state!!.key(ShellKey.Ok) }
strip("tab", File(directory, "tab-ready.png"))
}
@Test fun playbackVeilIgnoresKeysAndBackThenReturnsBackOwnershipToHome() {
host = PolishAppHost(rule)
host.mount(picker = false)
rule.mainClock.autoAdvance = false
var launches = 0
rule.runOnIdle { host.session.playback.begin("hero-play"); host.session.playback.ready { launches++ } }
rule.mainClock.advanceTimeBy(32)
val revision = host.session.state!!.inputRevision
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter); pressKey(Key.Escape) }
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(revision, host.session.state!!.inputRevision)
assertEquals(0, launches)
rule.mainClock.advanceTimeBy(200)
assertEquals(1, launches)
assertNull(host.session.playback.starting)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertTrue(host.session.state!!.navFocused)
assertFalse(rule.activity.isFinishing)
}
@Test fun backBeforeTheBlackVeilCancelsThePendingStartWithoutExitingHome() {
host = PolishAppHost(rule)
host.mount(picker = false)
rule.runOnIdle { host.session.playback.begin("hero-play") }
rule.mainClock.advanceTimeBy(32)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertNull(host.session.playback.starting)
assertTrue(host.session.state!!.navFocused)
assertFalse(rule.activity.isFinishing)
}
@Test fun fullRootPlaybackHandoffNeverRepaintsHome() = rootHandoff(false)
@Test fun reducedMotionFullRootHandoffNeverRepaintsHome() = rootHandoff(true)
private fun rootHandoff(reduced: Boolean) {
host = PolishAppHost(rule)
host.playerReady = false
host.mount(picker = false, reduced = reduced)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/root-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "home-ready.png").absolutePath)
rule.mainClock.autoAdvance = false
val destination = Destination.Playback(UUID.randomUUID(), 0L)
rule.runOnIdle {
host.session.playback.begin("hero-play")
host.session.playback.ready { host.navigation.navigateTo(destination) }
}
val origin = rule.mainClock.currentTime
val samples = mutableListOf<String>()
var navigationTime: Long? = null
var playerPainted = false
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, "handoff-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
val logicalPlayer = host.navigation.backStack.last() == destination
val time = rule.mainClock.currentTime - origin
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
val pixel = image.getPixel(600, 500)
val avatar = image.getPixel(1774, 90)
samples.add("$time,$logicalPlayer,$pixel,$avatar")
if (logicalPlayer) {
if (navigationTime == null) navigationTime = time
assertTrue("After handoff only opaque black or the incoming player may paint; never old Home",
pixel == android.graphics.Color.BLACK || pixel == PLAYER_PAINT.toInt())
assertEquals("The old Home nav cannot flash through the completed veil", pixel, avatar)
assertEquals(pixel, image.getPixel(100, 900))
assertEquals(pixel, image.getPixel(1800, 900))
if (pixel == PLAYER_PAINT.toInt()) playerPainted = true
if (pixel == android.graphics.Color.BLACK && time == navigationTime + 16) {
val revision = host.session.state!!.inputRevision
rule.onRoot().performKeyInput {
pressKey(Key.DirectionRight, 0L); pressKey(Key.DirectionCenter, 0L); pressKey(Key.Escape, 0L)
}
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals("Keys cannot reach old Home during the pending native player scene", revision, host.session.state!!.inputRevision)
assertEquals(destination, host.navigation.backStack.last())
}
if (time == navigationTime + 48) rule.runOnIdle {
host.playerReady = true
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
}
} finally { image.recycle() }
assertEquals("The full-root probe cannot warm the manual clock", origin + index * 16L, rule.mainClock.currentTime)
}
assertTrue("The incoming player must eventually be uncovered", playerPainted)
assertEquals(2, host.navigation.backStack.size)
rule.onAllNodesWithTag("wolfskin-shell").assertCountEquals(0)
assertNull(host.session.playback.starting)
assertNull(host.session.playback.launch)
} finally {
File(directory, "samples.csv").writeText("timeMs,logicalPlayer,bodyPixel,avatarPixel\n" + samples.joinToString("\n"))
}
}
@Test fun setupHostKeepsOpaquePaintAndRetiresOutgoingWithinFourFrames() = setupCuts(false)
@Test fun reducedMotionSetupHostKeepsTheSameCutDeadlines() = setupCuts(true)
private fun setupCuts(reduced: Boolean) {
val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null)
val stack = mutableStateListOf<SetupDestination>(SetupDestination.ServerList)
val colors = mapOf("ServerList" to 0xff304060.toInt(), "UserList" to 0xff406030.toInt(), "AppContent" to 0xff603040.toInt())
val screensaver = mockk<ScreensaverService>()
every { screensaver.state } returns kotlinx.coroutines.flow.MutableStateFlow(ScreensaverState(false, false, false, false))
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { androidx.tv.material3.MaterialTheme {
com.github.damontecres.wholphin.MainContent(stack, NavigationManager(), UserPreferences(AppPreferences.getDefaultInstance(), null),
mockk(relaxed = true), screensaver,
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
setupInterceptor = { destination, modifier ->
androidx.compose.foundation.layout.Box(modifier.then(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight))
.then(Modifier.background(androidx.compose.ui.graphics.Color(colors.getValue(destination.javaClass.simpleName))))) {
androidx.tv.material3.Text(destination.javaClass.simpleName)
}
true
})
} } }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
rule.onRoot().captureRoboImage(File(directory, "setup-precondition.png").absolutePath)
rule.onAllNodesWithText("ServerList").assertCountEquals(1)
rule.mainClock.autoAdvance = false
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
val previous = stack.single().javaClass.simpleName
val next = destination.javaClass.simpleName
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
val origin = rule.mainClock.currentTime
var firstIncoming: Int? = null
try {
repeat(21) { index ->
if (index > 0) rule.mainClock.advanceTimeByFrame()
val file = File(directory, next + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
rule.waitForIdle()
val counts = colors.keys.associateWith { rule.onAllNodesWithText(it).fetchSemanticsNodes().size }
val image = BitmapFactory.decodeFile(file.absolutePath)
val pixel = try {
val sample = image.getPixel(600, 500)
assertEquals("The entire setup scene must share its opaque paint", sample, image.getPixel(100, 500))
assertEquals(sample, image.getPixel(1500, 500))
assertEquals(sample, image.getPixel(600, 900))
sample
} finally { image.recycle() }
samples.add("$next,${index * 16},${counts.getValue(previous)},${counts.getValue(next)},$pixel")
assertEquals("Native setup observation cannot warm the clock", origin + index * 16L, rule.mainClock.currentTime)
val incoming = pixel == colors.getValue(next)
assertTrue("Setup native paint is exactly the outgoing or incoming scene, never an alpha composite",
incoming || pixel == colors.getValue(previous))
if (incoming && firstIncoming == null) {
firstIncoming = index
assertTrue("$next first visible after $index frames (${index * 16} ms), not within two frames", index <= 2)
}
if (firstIncoming != null) assertTrue("Incoming native paint cannot revert to the outgoing scene", incoming)
if (index >= 4) {
assertEquals("By 64 ms only the incoming setup screen may remain in semantics", 1, counts.values.sum())
rule.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(next).assertCountEquals(1)
}
}
assertNotNull("Setup screen did not appear within 320 ms", firstIncoming)
} finally {
File(directory, "samples.csv").writeText("destination,timeMs,previous,incoming,pixel\n" + samples.joinToString("\n"))
}
}
}
}
/** Real shell integration with fixture data and an injectable saved-user switch. */
internal class PolishAppHost(private val rule: AndroidComposeTestRule<ActivityScenarioRule<ComponentActivity>, ComponentActivity>) : AutoCloseable {
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
var users = listOf("Justin", "Mindel", "Shared").mapIndexed { index, name ->
JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-00000000000${index + 2}"), serverId = server.id, name = name, accessToken = null)
}
val session = WolfskinSession()
val navigation = NavigationManager()
val repository = mockk<ServerRepository>(relaxed = true)
val setup = mockk<SetupNavigationManager>(relaxed = true)
private val conductarr = ConductarrSession(MemoryCacheStore())
private val models = mutableListOf<WolfskinShellViewModel>()
private var oldFixtures = false
private var oldScreens = true
private var current by mutableStateOf(users.first())
val model get() = models.last()
var reduceMotion by mutableStateOf(false)
var playerReady by mutableStateOf(true)
fun showUser(user: JellyfinUser) { current = user }
fun mount(picker: Boolean = true, reduced: Boolean = false, fixtures: Boolean = true) {
reduceMotion = reduced
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
oldFixtures = ConductarrPreference.fixtures
oldScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(fixtures)
WolfskinScreenPreference.enabled = true
val backdrop = mockk<BackdropService>(relaxed = true)
every { backdrop.backdropFlow } returns kotlinx.coroutines.flow.MutableStateFlow(BackdropResult.NONE)
coEvery { repository.serverDao.getServer(server.id) } answers { JellyfinServerUsers(server, users) }
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
every { setup.navigateTo(any()) } answers {
val destination = firstArg<SetupDestination>()
if (destination is SetupDestination.AppContent) current = destination.current.user
}
rule.setContent {
CompositionLocalProvider(LocalReducedMotion provides reduceMotion) {
key(current.id) {
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, backdrop,
mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true),
org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), model,
homeContent = { shell, _, user, modifier ->
val snapshot = remember(user.id) { HomeFixture(rule.activity).snapshot(user.name!!.lowercase()) }
HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details || action is HomeAction.Play)
navigation.navigateTo(Destination.Wolfskin(titleRoute("Locke")))
}
},
titleContent = { route, shell, _ ->
val title = remember(route) {
val fixture = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { org.json.JSONObject(it.readText()) })
TitleState(fixture.title("Locke", shell.person.key), shell.person) {
if (it == TitleAction.Pop) navigation.goBack()
}
}
androidx.activity.compose.BackHandler { title.back(shell) }
TitleSurface(title, shell)
},
fullScreenContent = { destination, modifier ->
assertTrue(destination is Destination.Playback)
androidx.compose.foundation.layout.Box(modifier.then(if (playerReady) Modifier.background(androidx.compose.ui.graphics.Color(PLAYER_PAINT)) else Modifier)
.testTag("polish-player"))
})
}
}
}
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
rule.runOnIdle {
session.state!!.pickerVisible = picker
session.state!!.pickerLaunch = picker
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
}
rule.mainClock.advanceTimeBy(1200)
}
fun await(condition: () -> Boolean) {
rule.waitUntil(10_000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
condition()
}
rule.waitForIdle()
}
override fun close() {
models.forEach { it.viewModelScope.cancel() }
conductarr.close()
ConductarrPreference.updateFixtures(oldFixtures)
WolfskinScreenPreference.enabled = oldScreens
}
}

View file

@ -0,0 +1,185 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.*
import java.io.File
import java.io.IOException
import kotlinx.coroutines.CompletableDeferred
import org.junit.*
import org.junit.Assert.*
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** A frozen, inert picker protects the selected identity across an asynchronous app replacement. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PersonCommitTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var host: PolishAppHost
private val release = CompletableDeferred<Unit>()
@After fun dispose() { release.complete(Unit); if (::host.isInitialized) host.close() }
@Test fun choosingMindelCoversTheSwitchUntilTheirHomeCanPaint() = heldSwitch(false)
@Test fun reducedMotionUsesOnlyTheShortFullPickerFade() = heldSwitch(true)
private fun heldSwitch(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(reduced = reduced)
coEvery { host.repository.changeUser(host.server, any()) } coAnswers {
release.await()
CurrentUser(host.server, secondArg())
}
var pendingUser: JellyfinUser? = null
every { host.setup.navigateTo(any()) } answers { pendingUser = (firstArg() as SetupDestination.AppContent).current.user }
rule.mainClock.autoAdvance = false
press(listOf(Key.DirectionRight))
rule.mainClock.advanceTimeBy(400)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/P08/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "picker.png").absolutePath)
val commit = choose(listOf(Key.DirectionCenter))
val origin = commit.startFrameNanos
val clockOrigin = rule.mainClock.currentTime
val samples = mutableListOf<String>()
val others = mutableListOf<Int>()
val chosen = mutableListOf<Int>()
val bg = WolfskinTokens.Color.bg.toArgb()
repeat(31) { index ->
val timeMs = rule.mainClock.currentTime - clockOrigin
assertEquals("Frame strips advance only in 16 ms steps", index * 16L, timeMs)
rule.onNodeWithText("Sign in as", substring = true).assertDoesNotExist()
val file = File(directory, "commit-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
assertEquals("Capturing a frame cannot advance animation time", timeMs, rule.mainClock.currentTime - clockOrigin)
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
val other = image.getPixel(600, 500)
val selected = image.getPixel(900, 500)
others.add(android.graphics.Color.red(other))
chosen.add(selected)
if (timeMs >= if (reduced) 80 else 320) {
val pixels = IntArray(image.width * image.height)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
assertTrue("Only opaque background may remain while sign-in is pending", pixels.all { it == bg })
}
samples.add("$timeMs,$other,$selected")
} finally { image.recycle() }
rule.mainClock.advanceTimeByFrame()
}
assertTrue("The nonchosen person must fade monotonically to background", others.zipWithNext().all { (before, after) -> after <= before })
assertNotEquals(others.first(), others.last())
if (!reduced) assertTrue("The chosen avatar stays intact throughout phase one", chosen.take(10).all { it == chosen.first() })
File(directory, "samples.csv").writeText("timeMs,other,chosen\n" + samples.joinToString("\n"))
val shell = host.session.state!!
val revision = shell.inputRevision
val stack = host.navigation.backStack.toList()
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.Escape))
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
rule.onRoot().performTouchInput { click(androidx.compose.ui.geometry.Offset(600f, 500f)) }
assertEquals(revision, shell.inputRevision)
assertEquals("mindel", shell.person.key)
assertEquals(stack, host.navigation.backStack.toList())
if (reduced) {
rule.runOnIdle { host.reduceMotion = false; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
val file = File(directory, "motion-toggle.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
try {
assertEquals("A completed veil cannot uncover the chosen person when motion changes", bg, image.getPixel(900, 500))
assertEquals(bg, image.getPixel(100, 80))
} finally { image.recycle() }
}
release.complete(Unit)
host.await { pendingUser != null }
rule.mainClock.advanceTimeBy(160)
assertNotNull("Completing switch work cannot uncover an unauthenticated replacement app", host.session.personCommit)
rule.runOnIdle { host.showUser(pendingUser!!) }
host.await { host.model.signedInPerson == "mindel" && host.session.personCommit == null }
assertEquals(origin, commit.startFrameNanos)
rule.onAllNodesWithText("Sex and the City")[0].assertIsDisplayed()
rule.onRoot().captureRoboImage(File(directory, "home-first-frame.png").absolutePath)
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.heroFade.toLong() + 32)
rule.onRoot().captureRoboImage(File(directory, "home.png").absolutePath)
coVerify(exactly = 1) { host.repository.changeUser(host.server, any()) }
}
@Test fun samePersonRunsTheChoreographyWithoutSwitchingCredentials() {
host = PolishAppHost(rule)
host.mount()
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionCenter))
assertNotNull(host.session.personCommit)
finishTimeline()
assertNull(host.session.personCommit)
rule.onAllNodesWithText("Yu Yu Hakusho")[0].assertIsDisplayed()
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
}
@Test fun pinProtectedPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(pin = "protected")
@Test fun requireLoginPersonReturnsToSetupWithoutChangingCredentials() = protectedPerson(requireLogin = true)
private fun protectedPerson(pin: String? = null, requireLogin: Boolean = false) {
host = PolishAppHost(rule)
host.users = host.users.mapIndexed { index, user -> if (index == 1) user.copy(pin = pin, requireLogin = requireLogin) else user }
host.mount(fixtures = false)
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
assertNotNull(host.session.personCommit)
awaitSwitchWork()
verify(exactly = 0) { host.setup.navigateTo(any()) }
finishTimeline()
assertNull(host.session.personCommit)
coVerify(exactly = 0) { host.repository.changeUser(any(), any()) }
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
@Test fun failedCredentialSwitchRevealsSetupOnlyAfterThePickerIsCovered() {
host = PolishAppHost(rule)
host.mount(fixtures = false)
coEvery { host.repository.changeUser(any(), any()) } throws IOException("switch failed")
rule.mainClock.autoAdvance = false
choose(listOf(Key.DirectionRight, Key.DirectionCenter))
awaitSwitchWork()
finishTimeline()
assertNull(host.session.personCommit)
verify(exactly = 1) { host.setup.navigateTo(SetupDestination.UserList(host.server)) }
}
private fun choose(keys: List<Key>): PersonCommit {
press(keys)
val commit = requireNotNull(host.session.personCommit)
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeUntil(1000) { commit.startFrameNanos != null }
return commit
}
private fun finishTimeline() {
repeat(WolfskinTokens.Motion.base * 2 / 16 + 2) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
}
}
private fun awaitSwitchWork() {
rule.waitUntil(5000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
host.session.personCommit?.switchEnded == true
}
}
private fun press(keys: List<Key>) = rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it, 0L) } }
}

View file

@ -219,7 +219,7 @@ class PersonSwitchThreadingTest {
coEvery { home.readLibrary(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary()
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr, WolfskinSession())
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository, mockk(relaxed = true))
try {
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)

View file

@ -52,7 +52,7 @@ class PlayerHandoffTest {
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()), WolfskinSession())
}
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
@ -78,15 +78,30 @@ class PlayerHandoffTest {
}
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val snapshot = HomeFixture(rule.activity).snapshot("mindel")
val title = snapshot.rows.first().cards.first()
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
coEvery { facade.preparePlayback(user.id, title) } returns org.jellyfin.sdk.model.api.BaseItemDto(
id = id, type = org.jellyfin.sdk.model.api.BaseItemKind.EPISODE,
userData = playbackUserData(id, 11_362_740_000L, playCount = 2))
val view = shell.view
view.cards["hero"] = 0
rule.setContent { androidx.compose.runtime.CompositionLocalProvider(LocalHomePlaybackStart provides model.session.playback) { WolfskinTheme {
androidx.compose.foundation.layout.Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) {
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }
PlaybackHandoffVeil(model.session.playback)
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { model.act(HomeAction.Play(title), shell, server, user) }
awaitModel { navigation.backStack.last() is Destination.Playback }
awaitModel { model.session.playback.launch != null }
assertTrue("Preparation must not navigate before the veil", navigation.backStack.single() is Destination.Home)
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeBy(80)
assertTrue("The first half of the fade must still belong to Home", navigation.backStack.single() is Destination.Home)
rule.mainClock.advanceTimeBy(192)
assertEquals(2, navigation.backStack.size)
val destination = navigation.backStack.last() as Destination.Playback
assertEquals(id, destination.itemId)
assertEquals(1_136_274L, destination.positionMs)

View file

@ -115,7 +115,7 @@ class WatchReadTest {
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns (HomeLibrary(resume = listOf(HomeTitle(resume.id.toString(), "Cached episode", "episode", resumeSeconds = 10.0))) to 1L)
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
val model = WolfskinHomeViewModel(RuntimeEnvironment.getApplication(), facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
try {
val loading = launch { model.loadLibrary(server, user, person) }
runCurrent()

View file

@ -47,7 +47,7 @@ class WatchReturnTest {
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val session = ConductarrSession(MemoryCacheStore())
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session)
val model = WolfskinHomeViewModel(rule.activity, facade, cache, NavigationManager(), WolfskinSeeAllLists(), session, WolfskinSession())
val oldFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
try {

View file

@ -23,6 +23,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import org.justinvisser.wolfskin.tv.read.playbackPositionMs
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
@ -37,6 +38,7 @@ class WolfskinHomeViewModel @Inject constructor(
private val navigation: NavigationManager,
private val seeAll: WolfskinSeeAllLists,
private val conductarr: ConductarrSession,
val session: WolfskinSession,
) : ViewModel() {
val conductarrState get() = conductarr.identity
val audiences get() = home.audiences
@ -49,6 +51,15 @@ class WolfskinHomeViewModel @Inject constructor(
private var loadRevision = 0L
private var catalogueSnapshot = DiscoverSnapshot()
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
private var playbackJob: kotlinx.coroutines.Job? = null
private var playbackRevision = 0L
/** Leaving Home invalidates late preparation results as well as a pending black handoff. */
fun cancelPlayback() {
playbackRevision++
playbackJob?.cancel()
session.playback.fail()
}
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
catalogueJob?.cancel()
@ -164,23 +175,45 @@ class WolfskinHomeViewModel @Inject constructor(
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
is HomeAction.Play -> viewModelScope.launch {
val personKey = shell.person.key
val selectedId = shell.person.jellyfinUserId
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
if (!snapshot.jellyfinAvailable || !matchesUser) {
showToast(RecoveryMessage.PlaybackUnavailable)
return@launch
}
try {
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, action.title) }
if (item == null) showToast(RecoveryMessage.PlaybackUnavailable)
else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
is HomeAction.Play -> startPlayback(action.title, shell, server, user)
}
}
private fun startPlayback(title: HomeTitle, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
val start = session.playback
if (start.starting != null) return
playbackJob?.cancel()
val revision = ++playbackRevision
val personKey = shell.person.key
start.begin("hero-play")
playbackJob = viewModelScope.launch {
if (revision != playbackRevision || start.starting == null) return@launch
val selectedId = shell.person.jellyfinUserId
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
personKey == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && personKey == conductarr.identity.value?.operator?.requester)
if (!snapshot.jellyfinAvailable || !matchesUser) {
start.fail()
showToast(RecoveryMessage.PlaybackUnavailable)
return@launch
}
try {
val item = withContext(WholphinDispatchers.IO) { home.preparePlayback(user.id, title) }
coroutineContext.ensureActive()
if (revision != playbackRevision || start.starting == null) return@launch
if (item == null) {
start.fail()
showToast(RecoveryMessage.PlaybackUnavailable)
} else if (shell.person.key == personKey && (sourceScope == null || sourceScope == "${server.id}:${user.id}")) {
start.ready {
if (revision == playbackRevision && shell.person.key == personKey) navigation.navigateTo(Destination.Playback(item.id, item.playbackPositionMs()))
}
} else start.fail()
} catch (exception: Exception) {
if (revision == playbackRevision && start.starting != null) {
start.fail()
if (exception !is CancellationException) showToast(RecoveryMessage.PlaybackUnavailable)
}
if (exception is CancellationException) throw exception
}
}
}
@ -234,6 +267,7 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
val identity by model.conductarrState.collectAsState()
val watchRefresh = rememberWatchRefresh("${server.id}:${user.id}")
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences, watchRefresh) { model.load(server, user, shell.person) }
DisposableEffect(shell.person.key, server.id, user.id) { onDispose { model.cancelPlayback() } }
val view = shell.rememberedView("home")
LaunchedEffect(view.row, model.snapshot.rows) {
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id

View file

@ -1,17 +1,29 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -64,6 +76,8 @@ class WolfskinSession @Inject constructor() {
var serverId: String? = null
internal var fixtureMode: Boolean? = null
var onAction: (ShellAction) -> Unit = {}
var personCommit by mutableStateOf<org.justinvisser.wolfskin.design.PersonCommit?>(null)
val playback = org.justinvisser.wolfskin.design.PlaybackStart()
}
/** The shell routes only through the existing NavigationManager and real settings pages. */
@ -214,32 +228,48 @@ class WolfskinShellViewModel @Inject constructor(
}
private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
val commit = session.state?.takeIf { it.peopleStatus == PeopleStatus.Ready }?.let {
org.justinvisser.wolfskin.design.PersonCommit(it.people.toList(), it.people.indexOfFirst { candidate -> candidate.key == person.key }, person.key)
}
session.personCommit = commit
session.playback.fail()
navigation.replace(Destination.Home())
if (person.key == signedInPerson) return
if (person.key == signedInPerson) {
commit?.switchEnded = true
return
}
identityJob?.cancel()
personSwitchJob?.cancel()
signedInPerson = ""
conductarr.close()
libraries.audiences = null
personSwitchJob = viewModelScope.launch {
personSwitchMutex.withLock {
val saved = withContext(Dispatchers.IO) {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
}
if (saved == null || saved.isProtected) {
if (ConductarrPreference.fixtures) return@launch
setupNavigation.navigateTo(SetupDestination.UserList(server))
return@launch
}
try {
val current = repository.changeUser(server, saved)
coroutineContext.ensureActive()
setupNavigation.navigateTo(SetupDestination.AppContent(current))
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive()
setupNavigation.navigateTo(SetupDestination.UserList(server))
try {
personSwitchMutex.withLock {
val saved = withContext(Dispatchers.IO) {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
}
if (saved == null || saved.isProtected) {
if (!ConductarrPreference.fixtures) {
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
if (commit == null) open() else commit.fallback = open
}
return@launch
}
try {
val current = repository.changeUser(server, saved)
coroutineContext.ensureActive()
setupNavigation.navigateTo(SetupDestination.AppContent(current))
commit?.appReplacementPending = true
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
coroutineContext.ensureActive()
val open = { setupNavigation.navigateTo(SetupDestination.UserList(server)) }
if (commit == null) open() else commit.fallback = open
}
}
} finally {
commit?.switchEnded = true
}
}
}
@ -265,6 +295,10 @@ fun WolfskinApp(
viewModel: WolfskinShellViewModel = hiltViewModel(),
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
titleContent: @Composable (String, ShellState, UserPreferences) -> Unit =
{ route, shell, userPreferences -> TitleDestination(route, shell, userPreferences) },
fullScreenContent: @Composable (Destination, Modifier) -> Unit =
{ destination, contentModifier -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
@ -294,11 +328,35 @@ fun WolfskinApp(
)
RequestsRefresh(state, viewModel::refreshRequests)
val onBack = rememberWolfskinBackHandler(state, navigationManager)
val session = viewModel.session
val commit = session.personCommit
LaunchedEffect(commit, commit?.timelineComplete, commit?.switchEnded, commit?.appReplacementPending, viewModel.signedInPerson) {
if (commit?.timelineComplete == true && (state.person.key == viewModel.signedInPerson || (commit.switchEnded && !commit.appReplacementPending))) {
session.personCommit = null
commit.fallback?.invoke()
}
}
var composedDestination by remember(server.id, user.id, navigationManager) { mutableStateOf<Destination?>(null) }
val playerEntryPending by remember(server.id, user.id, navigationManager) {
derivedStateOf {
val target = navigationManager.backStack.lastOrNull()
target is Destination.Playback && composedDestination != target
}
}
// Keep input blocked until the composition that removes the black cover has actually replaced it.
val playerCoverVisible = playerEntryPending
WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover,
org.justinvisser.wolfskin.design.LocalHomePlaybackStart provides session.playback,
org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) {
Box(modifier) {
Box(modifier.onPreviewKeyEvent { event ->
if (session.personCommit != null || session.playback.launch != null || playerEntryPending || playerCoverVisible) true else {
if (event.type == KeyEventType.KeyDown && org.justinvisser.wolfskin.design.readShellKey(event) == org.justinvisser.wolfskin.design.ShellKey.Back) session.playback.fail()
false
}
}) {
val current = navigationManager.backStack.last()
LaunchedEffect(current, state.person.key) { session.playback.fail() }
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
Backdrop(
@ -309,40 +367,46 @@ fun WolfskinApp(
}
NavDisplay(
backStack = navigationManager.backStack,
onBack = onBack,
onBack = { if (session.personCommit == null && session.playback.launch == null && !playerEntryPending && !playerCoverVisible) { session.playback.fail(); onBack() } },
transitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
popTransitionSpec = { (EnterTransition.None togetherWith ExitTransition.None).using(null) },
predictivePopTransitionSpec = { _ -> (EnterTransition.None togetherWith ExitTransition.None).using(null) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination ->
NavEntry(
destination,
contentKey = "${destination}_${server.id}_${user.id}",
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
} else emptyMap(),
) {
// Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current
val discover = LocalDiscoverState.current
val route = destination.toWolfskinRoute()
val isCurrent = navigationManager.backStack.lastOrNull() == destination
if (isCurrent) SideEffect { composedDestination = destination }
LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
}
org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) {
if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
// Loading, error and external-player content may not paint their own background.
fullScreenContent(destination, Modifier.fillMaxSize()
.then(if (destination is Destination.Playback) Modifier.background(Color.Black) else Modifier))
} else {
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) }
} else TitleDestination(route, state, preferences)
ShellSurface(state, handlesContentKeys = false) {
if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it)
}
} else titleContent(route, state, preferences)
} else {
ShellSurface(state, handlesContentKeys = upstream == null,
settingsInstalledVersion = installedVersion.versionName,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson ->
if (session.personCommit == null) ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
@ -360,6 +424,20 @@ fun WolfskinApp(
}
},
)
org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback,
Modifier.pointerInput(session.playback.launch) {
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } }
})
if (playerCoverVisible) Box(Modifier.fillMaxSize().background(Color.Black)
.pointerInput(Unit) {
awaitPointerEventScope { while (true) awaitPointerEvent().changes.forEach { it.consume() } }
})
if (commit != null) org.justinvisser.wolfskin.design.PersonCommitVeil(commit)
BackHandler(enabled = session.playback.starting != null && session.playback.launch == null && !playerCoverVisible) {
session.playback.fail()
onBack()
}
BackHandler(enabled = commit != null || session.playback.launch != null || playerCoverVisible) { }
}
}
}

View file

@ -1,13 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.navigation3.ui.NavDisplay
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
internal val updatesRouteTransitions =
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }