Polish picker switching and Home playback handoff

This commit is contained in:
justinzeus 2026-11-08 03:16:09 +01:00
parent 74b737fb52
commit 612ae79f0c
20 changed files with 974 additions and 83 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,166 @@
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 width = rule.onNode(hasText("Resume", substring = true) and hasClickAction()).fetchSemanticsNode().boundsInRoot.width
press()
rule.mainClock.advanceTimeBy(32)
rule.onNodeWithText("Starting…").assertIsDisplayed()
assertEquals(width, rule.onNode(hasText("Starting…") and hasClickAction()).fetchSemanticsNode().boundsInRoot.width, 0f)
press()
assertTrue(navigation.backStack.single() is Destination.Home)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E04/" + if (reduced) "reduced" else "normal").apply { mkdirs() }
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png")
rule.onRoot().captureRoboImage(file.absolutePath)
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)
}
repeat(13) { frame("home-start", it); rule.mainClock.advanceTimeByFrame() }
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()) }
File(directory, "samples.csv").writeText("phase,timeMs,pixel,stackSize\n" + samples.joinToString("\n"))
rule.runOnIdle { navigation.goBack() }
rule.mainClock.advanceTimeBy(100)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText("Starting…").assertDoesNotExist()
}
@Test fun failedPreparationRestoresResumeAndShowsTheExistingRecoveryToast() {
mount()
press()
rule.mainClock.advanceTimeBy(200)
rule.onNodeWithText("Starting…").assertIsDisplayed()
release.completeExceptionally(IOException("offline"))
await { model.toast != null }
rule.mainClock.advanceTimeBy(200)
rule.onNodeWithText("Resume", substring = true).assertIsDisplayed()
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
}
@Test fun leavingHomeCancelsPreparationAndCannotLaunchALateResult() {
mount()
press()
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) }
private fun await(condition: () -> Boolean) {
rule.waitUntil(5000) {
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
shadowOf(Looper.getMainLooper()).idle()
if (!rule.mainClock.autoAdvance) rule.mainClock.advanceTimeByFrame()
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,233 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
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.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
/** Each navigation change retires the outgoing shell in the first rendered frame. */
@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 titleBackAndTabReplaceTheOutgoingScreenInOneFrame() = cuts(false)
@Test fun reducedMotionKeepsTheSameSingleFrameCuts() = cuts(true)
private fun cuts(reduced: Boolean) {
host = PolishAppHost(rule)
host.mount(picker = false, reduced = reduced)
rule.mainClock.autoAdvance = false
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:")
}
fun strip(phase: String) {
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
repeat(21) { index ->
rule.mainClock.advanceTimeByFrame()
val clock = rule.mainClock.currentTime
rule.onRoot().captureRoboImage(File(directory, phase + "-" + index.toString().padStart(2, '0') + ".png").absolutePath)
assertEquals("Native drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
val shells = rule.onAllNodesWithTag("wolfskin-shell").fetchSemanticsNodes().size
val titles = rule.onAllNodesWithTag("title-page").fetchSemanticsNodes().size
val cards = rule.onAllNodes(homeCard).fetchSemanticsNodes().size
assertEquals("Only the live destination may own a nav bar", 1, shells)
assertEquals(if (phase == "open-title") 1 else 0, titles)
if (phase != "back") assertEquals("Outgoing Home cards cannot survive the cut", 0, cards)
else assertTrue("Back immediately paints Home's shelves", cards > 0)
samples.add(phase + "," + index * 16 + "," + shells + "," + titles + "," + cards)
}
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown, 0L); pressKey(Key.DirectionCenter, 0L) }
strip("open-title")
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
strip("back")
rule.runOnIdle { host.session.state!!.focusNav(2); host.session.state!!.key(ShellKey.Ok) }
strip("tab")
File(directory, "samples.csv").writeText("phase,timeMs,shells,titles,homeCards\n" + samples.joinToString("\n"))
}
@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 setupHostReplacesServerAndUserScreensInOneFrame() = setupCuts(false)
@Test fun reducedMotionSetupHostAlsoCutsInOneFrame() = 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 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.tv.material3.Text(destination.javaClass.simpleName, modifier)
true
})
} } }
rule.mainClock.autoAdvance = false
for (destination in listOf(SetupDestination.UserList(server), SetupDestination.AppContent(CurrentUser(server, user)))) {
val previous = stack.single().javaClass.simpleName
rule.runOnIdle { stack[0] = destination; androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
val clock = rule.mainClock.currentTime
val directory = File(System.getProperty("wolfskin.captureDirectory"), "ui-polish/E01/setup-" + if (reduced) "reduced" else "normal").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, destination.javaClass.simpleName + ".png").absolutePath)
assertEquals("Native setup drawing cannot advance the first-frame clock", clock, rule.mainClock.currentTime)
rule.onNodeWithText(previous).assertDoesNotExist()
rule.onAllNodesWithText(destination.javaClass.simpleName).assertCountEquals(1)
}
}
}
/** 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)
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
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, mockk(relaxed = true),
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)
})
}
}
}
await { session.state?.people?.size == users.size && model.signedInPerson == "justin" }
rule.runOnIdle { session.state!!.pickerVisible = picker; session.state!!.pickerLaunch = picker }
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,6 +1,10 @@
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.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@ -12,6 +16,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
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 +72,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 +224,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 +291,8 @@ 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) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
@ -294,11 +322,26 @@ 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()
}
}
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) 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,15 +352,15 @@ fun WolfskinApp(
}
NavDisplay(
backStack = navigationManager.backStack,
onBack = onBack,
onBack = { if (session.personCommit == null && session.playback.launch == null) { 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
@ -335,14 +378,17 @@ fun WolfskinApp(
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 +406,16 @@ fun WolfskinApp(
}
},
)
org.justinvisser.wolfskin.design.PlaybackHandoffVeil(session.playback,
Modifier.pointerInput(session.playback.launch) {
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) {
session.playback.fail()
onBack()
}
BackHandler(enabled = commit != null || session.playback.launch != null) { }
}
}
}

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