Fix TV startup navigation and complete title pages #39

Merged
justinzeus merged 7 commits from fm/tv-walk-fix1 into main 2026-11-06 13:16:43 +01:00
18 changed files with 829 additions and 33 deletions

View file

@ -0,0 +1,106 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeAvailabilityTest {
private var previousFixtures = false
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
conductarr.select("${server.id}:${user.id}", token, fake.url)
conductarr.authenticate()
val identity = conductarr.identity.value!!
val audiences = identity.provider.audiences()!!.filter {
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
}
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
val api = mockk<ApiClient>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
})
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
val libraries = LibraryReadFacade(api)
val session = WolfskinSession().apply {
state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply {
pickerVisible = false
focusNav(tabs.size)
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
assertEquals("The avatar must remain focused when Anime appears or disappears",
session.state!!.tabs.size, session.state!!.navIndex)
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
destinationTotals = mapOf("anime" to count))
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
} finally { model.viewModelScope.cancel() }
}
}
}
}
}

View file

@ -0,0 +1,111 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeRootRemovalTest {
private var previousFixtures = false
private var previousUrl = ""
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
previousUrl = ConductarrPreference.baseUrl
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking {
val title = Destination.Wolfskin("title/tv%3A123")
val person = Destination.Wolfskin("person/456")
val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true,
listOf(title) to false, listOf(title, person) to false)
for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val context = ApplicationProvider.getApplicationContext<Application>()
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns null
var hasTitles = true
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles }
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()
val shell = requireNotNull(session.state)
assertTrue(shell.person.hasAnime)
shell.key(ShellKey.Ok)
shell.focusNav(shell.tabs.indexOf("anime"))
shell.key(ShellKey.Ok)
assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList())
for (page in pushed) {
navigation.navigateTo(page)
shell.enterDestination(page.route)
}
if (avatarFocused) shell.focusNav(shell.tabs.size)
hasTitles = false
requireNotNull(model.onForeground(server, user)).join()
assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs)
assertEquals("Home must replace the missing root, not the current pushed page",
listOf(Destination.Home()) + pushed, navigation.backStack.toList())
assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route)
assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab)
if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex)
shell.key(ShellKey.Back)
if (pushed.isEmpty()) {
assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused)
assertEquals("home", shell.route)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
if (!avatarFocused) assertEquals(0, shell.navIndex)
} else {
assertEquals("Back pops only the current page and retains the replacement Home root",
listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList())
}
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest {
rule.runOnIdle {
assertNotNull(current.title)
assertEquals("The Amazing Spider-Man", current.title!!.title)
assertEquals("discover-title/${current.title!!.key}", shell.route)
}
}

View file

@ -0,0 +1,98 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
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.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import io.mockk.coEvery
import io.mockk.mockk
import java.util.concurrent.CountDownLatch
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverTitleTimingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
FakeConductarr().use { fake ->
fake.localArt = true
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val identity = session.identity.value!!
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}
lateinit var discover: DiscoverState
val release = CountDownLatch(1)
try {
rule.setContent {
val route = (navigation.backStack.last() as Destination.Wolfskin).route
LaunchedEffect(route) { shell.enterDestination(route) }
discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false)
WolfskinTheme {
if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
else ShellSurface(shell) { DiscoverScreen(discover, it) }
}
}
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
rule.runOnIdle {
fake.beforeResponse = { if (it.endsWith("/cast")) release.await() }
discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning"))
}
rule.waitForIdle()
assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/"))
rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } }
rule.runOnIdle { release.countDown() }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
}
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertEquals("cast", model.title!!.row)
rule.onNodeWithText("Sam Rockwell").assertIsDisplayed()
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertEquals("related", model.title!!.row)
rule.onNodeWithText("More like this").assertIsDisplayed()
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.Back) }
rule.waitForIdle()
assertNull(discover.title)
assertEquals("discover", shell.route)
} finally {
release.countDown()
model.viewModelScope.cancel()
}
}
}
}
}

View file

@ -26,6 +26,8 @@ internal class FakeConductarr : Closeable {
@Volatile var emptyAudiences = false
@Volatile var seasonTwoScore = 8.1
@Volatile var localArt = false
@Volatile var beforeResponse: ((String) -> Unit)? = null
@Volatile var audiencesBody: String? = null
private val imageBytes = java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
@ -37,6 +39,7 @@ internal class FakeConductarr : Closeable {
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
.body(okio.Buffer().write(imageBytes)).build()
val path = request.url.encodedPath
beforeResponse?.invoke(path)
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
if (request.method == "POST") return response(wire.getValue("hide").toString())
if (request.method == "DELETE") return response(wire.getValue("restore").toString())
@ -64,6 +67,7 @@ internal class FakeConductarr : Closeable {
}
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
if (key == "audiences") audiencesBody?.let { return response(it) }
if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
var body = wire.getValue(key).jsonObject

View file

@ -0,0 +1,152 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
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.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.*
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class FirstSignInNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var previousUrl: String
private var previousFixtures = false
private var previousScreens = true
@Before fun captureGlobalPreferences() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousUrl = ConductarrPreference.baseUrl
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
rule.mainClock.autoAdvance = true
}
@After fun restoreGlobalPreferences() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
rule.mainClock.autoAdvance = true
}
@Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() {
ConductarrPreference.updateFixtures(false)
WolfskinScreenPreference.enabled = true
FakeConductarr().use { fake ->
fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}"""
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
val api = mockk<ApiClient>()
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) {
coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true,
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
}
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model,
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
}
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
conductarr.identity.value?.signedIn == true && model.libraries.audiences != null
}
rule.onNodeWithText("Mindel").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible)
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
pressKey(Key.DirectionUp)
pressKey(Key.DirectionRight)
pressKey(Key.DirectionRight)
pressKey(Key.DirectionCenter)
}
rule.waitForIdle()
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("library-grid").assertIsDisplayed()
rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed()
rule.onNodeWithText("Desperate Housewives").assertDoesNotExist()
assertEquals(6, session.state!!.view.library!!.source.size)
assertFalse(session.state!!.person.hasAnime)
rule.onNodeWithText("Shows").performClick()
rule.waitForIdle()
try {
rule.waitUntil(timeoutMillis = 15_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodes(hasTestTag("library-grid") and
hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty()
}
} catch (timeout: ComposeTimeoutException) {
val shell = session.state!!
throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " +
"movies=${shell.rememberedView("movies").library?.source?.size}, " +
"shows=${shell.rememberedView("shows").library?.source?.size}/" +
"${shell.rememberedView("shows").library?.status}", timeout)
}
rule.onNodeWithTag("library-grid").assertIsDisplayed()
assertEquals(29, session.state!!.view.library!!.source.size)
rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed()
rule.onNodeWithText("Home").performClick()
rule.waitForIdle()
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible)
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -0,0 +1,54 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Scrolled cards must be occluded across the entire persistent navigation band. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NavBandOcclusionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun scrolledRowsNeverShowThroughTheNavBand() {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) {
items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) }
}
} } }
rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } }
rule.waitForIdle()
val pixels = rule.onRoot().captureToImage().toPixelMap()
val background = WolfskinTokens.Color.bg
for (y in listOf(5, 50, 135)) {
val actual = pixels[20, y]
assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f)
assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f)
assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f)
}
}
}

View file

@ -0,0 +1,163 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ShellStartupTimingTest {
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
@After fun resetMain() { Dispatchers.resetMain() }
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previous = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
conductarr.authenticate()
val activityEntered = CompletableDeferred<Unit>()
val releaseActivity = java.util.concurrent.CountDownLatch(1)
fake.beforeResponse = {
if (it == "/api/v1/activity") {
activityEntered.complete(Unit)
releaseActivity.await()
}
}
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
withTimeout(5_000) { activityEntered.await() }
val savedPeople = CompletableDeferred<Unit>()
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null }
val navigation = mockk<NavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val load = launch { model.load(server, user) }
try {
yield()
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
shell.key(ShellKey.Ok)
for (route in listOf("movies", "shows", "discover")) {
shell.focusNav(shell.tabs.indexOf(route))
shell.key(ShellKey.Ok)
verify { navigation.replace(Destination.Wolfskin(route)) }
assertEquals(route, shell.route)
}
savedPeople.complete(Unit)
load.join()
assertSame(shell, session.state)
assertEquals("discover", shell.route)
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
} finally {
releaseActivity.countDown()
activity.cancelAndJoin()
savedPeople.complete(Unit)
load.cancelAndJoin()
model.viewModelScope.cancel()
}
}
}
ConductarrPreference.updateBaseUrl(previous)
}
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previous = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
val switched = CompletableDeferred<Unit>()
coEvery { repository.changeUser(server, mindel) } coAnswers {
switched.complete(Unit)
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
}
val navigation = mockk<NavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
fake.beforeResponse = {
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
pending.complete(Unit)
release.await()
}
}
try {
model.load(server, shared)
withTimeout(5_000) { pending.await() }
val shell = requireNotNull(session.state)
shell.key(ShellKey.Ok)
shell.enterDestination("discover")
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(1)
shell.key(ShellKey.Ok)
assertEquals("home", shell.route)
verify { navigation.replace(Destination.Home()) }
withTimeout(5_000) { switched.await() }
model.load(server, mindel)
requireNotNull(model.onForeground(server, mindel)).join()
release.countDown()
assertSame(shell, session.state)
assertEquals("mindel", model.signedInPerson)
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
assertEquals("Mindel", shell.person.name)
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
assertEquals("home", shell.route)
} finally {
release.countDown()
model.viewModelScope.cancel()
}
}
}
ConductarrPreference.updateBaseUrl(previous)
}
}

View file

@ -81,6 +81,7 @@ class TitleReadFacadeTest {
every { repository.currentServer } returns null
every { repository.currentUser } returns null
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList())
val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
@ -103,6 +104,29 @@ class TitleReadFacadeTest {
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
}
@Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42"))))
val page = facade.read(firstId, null, preferences)
assertEquals(42L, page.item.tmdbId)
assertEquals(firstId.toString(), page.watchTarget.id)
assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id)
FakeConductarr().use { fake ->
ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
session.authenticate()
val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}
ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId),
org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) }
assertEquals(listOf("header", "cast", "related"), screen.rows)
assertEquals("Sam Rockwell", screen.data.cast.single().name)
assertEquals(firstId.toString(), screen.target?.id)
assertTrue(screen.data.item.held)
}
}
}
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
facade.read(firstId, null, preferences)

View file

@ -95,18 +95,6 @@ internal fun rememberDiscoverState(
}
}
}
LaunchedEffect(state.title?.key, state) {
val title = state.title ?: return@LaunchedEffect
if (!fixtures && identity?.signedIn == true) {
val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV
identity.provider.detail(title.tmdb, media).collect { result ->
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
}
}
}
val result = if (fixtures) fixtureState ?: state else state
LaunchedEffect(shell.route, result) {
result.enterRoute(shell.route)

View file

@ -95,14 +95,14 @@ class WolfskinShellViewModel @Inject constructor(
if (ConductarrPreference.fixtures) conductarr.close()
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl)
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
session.onAction = { action -> handleAction(action, server, user) }
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
session.serverId = server.id.toString()
fixtureMode = ConductarrPreference.fixtures
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
session.state = ShellState(readShellPeople(context, listOf(user)), signedInPerson) { session.onAction(it) }
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
session.state?.updatePeople(readShellPeople(context, saved.ifEmpty { listOf(user) }))
}
session.onAction = { action -> handleAction(action, server, user) }
identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user)
}
@ -123,12 +123,19 @@ class WolfskinShellViewModel @Inject constructor(
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
val hasAnime = try {
libraries.hasAnimeTitles(user.id)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
}
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
val updated = session.state?.people.orEmpty().map { person ->
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
val current = person.jellyfinUserId == user.id.toString()
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
name = if (current) identity.operator!!.name else profile?.name ?: person.name,
hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime)
hasAnime = if (current) hasAnime else person.hasAnime)
}
session.state?.updatePeople(updated)
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
@ -174,7 +181,14 @@ class WolfskinShellViewModel @Inject constructor(
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
when (action) {
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when {
action.route == "settings" -> navigation.navigateTo(target)
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target)
}
}
ShellAction.Pop -> navigation.goBack()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user)
@ -230,6 +244,8 @@ fun WolfskinApp(
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: WolfskinShellViewModel = hiltViewModel(),
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
@ -274,17 +290,23 @@ fun WolfskinApp(
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination ->
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
// Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current
val discover = LocalDiscoverState.current
val route = when (destination) {
is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
else -> "pushed"
}
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
val isCurrent = navigationManager.backStack.lastOrNull() == destination
LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
}
if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else {
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
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
@ -297,7 +319,7 @@ fun WolfskinApp(
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 -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
}
}
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
suspend fun hasAnimeTitles(userId: UUID): Boolean {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
for (library in libraries) {
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
if (titles.isNotEmpty()) return true
}
return false
}
/** A destination spans every matching accessible library; containers never become titles. */
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->

View file

@ -83,8 +83,8 @@ class TitleReadFacade @Inject constructor(
/** An episode opens its series with that exact episode as both play and watched target. */
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
val file = cacheFile(id)
val opened = api.userLibraryApi.getItem(id).content
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened
val opened = readItem(id)
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor(
result.copy(trailerUrl = trailer)
}
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
private suspend fun readItem(id: UUID): BaseItemDto {
val item = api.userLibraryApi.getItem(id).content
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
val metadata = try {
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
} ?: return item
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
}
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }

View file

@ -473,12 +473,38 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin
logins; that does not create selectable TV logins. Late name reads preserve the
open chooser's selected recipient and footer focus.
The launch picker publishes the current Jellyfin user and working navigation before
the saved-login database read. Saved people and Conductarr household metadata fill
that same shell later; slow activity or catalogue reads cannot hold its first usable
frame. Discover details navigate to the shared title page, including cast, performer
navigation and related titles, rather than the header-only design preview. Held
items whose detail omits ProviderIds or People get an explicit Jellyfin field read
before catalogue enrichment, without changing their playback identity. Scrolled
navigation draws an opaque band; the unscrolled hero treatment is unchanged.
Cached `NavDisplay` entries read the current shell and Discover composition locals,
not the temporary launch shell captured when the entry was first created. Otherwise
the screen can retain skeleton people, disconnected tab actions and an open picker
even after the real session is ready.
Only the current navigation entry may set the shared route or enable the outer
host's Back handler; outgoing entries can remain composed during a transition.
Nested title handlers retain their existing lifecycle-based behaviour.
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
authenticated fetcher. The app's network interceptor removes Authorization from
every non-Jellyfin artwork origin (scheme, host and port), including redirects;
current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps
the existing mapping or Jellyfin name rule. Returning to the foreground retries
household metadata without rebuilding the selected identity.
An Anime audience grants a library grouping, not proof of owned titles. Household
refresh enables Anime only after a one-title Jellyfin read confirms visible series
in an accessible Anime library for the signed-in user. This runs after the live
picker is published; failures retain that user's last confirmed availability.
Empty Anime Home shelves remain omitted.
When availability changes, focus follows the avatar or a surviving destination;
removing Anime never leaves an out-of-range nav selection. If Anime was the root,
both the shell and navigation stack replace it with Home while retaining pushed
title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus,
and the real navigation stack with one or two pushed pages.
| Screen seam | Production supplier |
| --- | --- |
@ -504,6 +530,21 @@ and a same-host/different-port origin with an authenticated upstream parent,
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
operator success plus failed/empty audiences, name-rule retention and foreground healing.
`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending
while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest`
covers omitted held-film provider ids with authenticated cast/related enrichment.
`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back
and writes native cast/related captures to `walk-fixes/`.
`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band.
`FirstSignInNavigationTest` starts the full app host with no existing shell and
immediate signed-in replies, then continues as Mindel and renders her six-film
Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also
checks that Continue closes the real session picker and selecting Home does not
reopen it.
`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared
and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or
Home shelf, while a real visible series enables both.
Not done here: deploying the dedicated hostname or widening server authority;
hardware installation/playback verification; releases, signing or version changes.
Unheld trailers remain absent because the server contract has no trailer field.

View file

@ -169,7 +169,7 @@ class DiscoverState(
fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}")
onPush("title/${android.net.Uri.encode(item.key)}")
}
fun openCollection(lane: DiscoverLane) {
var titles = lane.fullTitles.associateBy { it.key }
@ -237,7 +237,7 @@ class DiscoverState(
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
override fun key(key: ShellKey): Boolean {
if (overlay != null) { overlayKey(key); return true }
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false
if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let {
if (key == ShellKey.Back) { collection = null; onPop(); return true }

View file

@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
val density = LocalDensity.current.density
val band = remember(scrolled) {
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f))
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
val band = remember {
Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
}
Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) {
drawRect(band)
if (scrolled) drawRect(C.bg) else drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
}
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))

View file

@ -11,7 +11,7 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
sealed interface ShellAction {
data class Destination(val route: String) : ShellAction
data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction
data class Person(val person: ShellPerson) : ShellAction
data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction
@ -50,14 +50,23 @@ class ShellState(
var people by mutableStateOf(people)
private set
/** Refresh household labels without discarding this person's navigation memory. */
/** Refresh household data without losing valid nav focus or this person's view memory. */
fun updatePeople(value: List<ShellPerson>) {
val focusedTab = tabs.getOrNull(navIndex)
val avatarFocused = navIndex == tabs.size
val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId }
?: value.firstOrNull { it.key == person.key }
people = value
if (selected != null) person = selected
pickerIndex = people.indexOf(person).coerceAtLeast(0)
val removedRoot = rootRoute !in tabs
if (removedRoot) {
if (route == rootRoute) route = "home"
rootRoute = "home"
}
preparePersonMemory()
navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab
if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true))
}
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set

View file

@ -36,7 +36,7 @@ class DiscoverKeysTest {
rule.setContent {
WolfskinTheme {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
}
}
}