Candidate 3: merge polish-lane-c
This commit is contained in:
commit
aa470b96b6
20 changed files with 1204 additions and 84 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
Loading…
Add table
Add a link
Reference in a new issue