diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt index b18f6a22..d2530d4f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverLoadingFocusTest.kt @@ -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) } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt new file mode 100644 index 00000000..9fbbeaa0 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverTitleTimingTest.kt @@ -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() + + @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() + 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() + } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index b682b34c..c751b56e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -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()) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt new file mode 100644 index 00000000..5f2aa9e7 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/NavBandOcclusionTest.kt @@ -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() + + @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) + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt new file mode 100644 index 00000000..c3bf121a --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -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() + 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() + 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() + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeople.await(); null } + val navigation = mockk(relaxed = true) + val libraries = mockk(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() + 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(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns + com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel)) + val switched = CompletableDeferred() + coEvery { repository.changeUser(server, mindel) } coAnswers { + switched.complete(Unit) + com.github.damontecres.wholphin.data.CurrentUser(server, mindel) + } + val navigation = mockk(relaxed = true) + val libraries = mockk(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() + 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) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index cc4d75d6..fde2ff9c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -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()) } returns successQueryResult(emptyList()) val context = mockk { every { filesDir } returns directory.root } facade = TitleReadFacade(api, mockk(relaxed = true), playback, repository, mockk(), 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()) } 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) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index 8b11ee3a..ee29266b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -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) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 3d182180..a9948c2a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -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) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 73ebcd07..4b3a3a4a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -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) } diff --git a/docker/README.md b/docker/README.md index db9676ad..fba9a5d9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 65143485..f0e2dabb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -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 } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 9042e3fe..1fedb8c7 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -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)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt index ae66b19b..e5bd3c3a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverKeysTest.kt @@ -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) } } }