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
13 changed files with 383 additions and 26 deletions
Showing only changes of commit d401ebc355 - Show all commits

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,7 @@ internal class FakeConductarr : Closeable {
@Volatile var emptyAudiences = false
@Volatile var seasonTwoScore = 8.1
@Volatile var localArt = false
@Volatile var beforeResponse: ((String) -> Unit)? = null
private val imageBytes = java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
@ -37,6 +38,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())

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

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,6 +473,15 @@ 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.
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;
@ -504,6 +513,13 @@ 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.
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

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