diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt index d3ea3804..2cb05947 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt @@ -85,4 +85,36 @@ class ConductarrTitlePagesTest { assertTrue(emptyList().withEpisode(8.0, null).isEmpty()) assertTrue(emptyList().withEpisode(8.0, 0).isEmpty()) } + + @Test fun requestIdentityUsesTheTitleRootNotTheEpisodeBeingWatched() { + val viewer = ShellPerson("shared", "Shared", "S", true) + val episode = TitleItem("episode-id", "Episode", "episode", tmdbId = 999) + val series = TitlePageData(TitleItem("series-id", "Series", "tv", held = true, tmdbId = 44), + watchTarget = episode, seasonNumbers = listOf(0, 1, 2)) + val request = requireNotNull(series.requestTitle(viewer)) + assertEquals(44L, request.tmdb) + assertEquals("tv", request.type) + assertEquals(listOf(1, 2), request.seasons) + assertEquals(setOf("shared"), request.heldBy) + val catalogue = TitlePageData(TitleItem("movie:42", "Film", "movie")).requestTitle(viewer) + assertEquals(42L, catalogue?.tmdb) + assertEquals("movie", catalogue?.type) + } + + @Test fun incompleteOrNonCatalogueRootsCannotBecomeRequestsForAnotherTitle() { + val viewer = ShellPerson("shared", "Shared", "S", true) + val invalid = listOf( + TitleItem("fixture-slug", "Unknown", "movie"), + TitleItem("fixture-slug", "Unknown", "movie", tmdbId = 0), + TitleItem("fixture-slug", "Unknown", "tv", tmdbId = -1), + TitleItem("tv:42", "Wrong media", "movie"), + TitleItem("42", "Not a catalogue route", "tv"), + TitleItem("movie:0", "Zero", "movie"), + TitleItem("tv:-42", "Negative", "tv"), + TitleItem("movie:42:1", "Malformed", "movie"), + TitleItem("episode-id", "Orphan episode", "episode", tmdbId = 999), + TitleItem("person:216", "Person", "person", tmdbId = 216), + ) + invalid.forEach { item -> assertNull(item.toString(), TitlePageData(item).requestTitle(viewer)) } + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index 27d2d214..26f7d3f4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -22,6 +22,7 @@ import com.github.takahirom.roborazzi.captureRoboImage import io.mockk.coEvery import io.mockk.mockk import java.io.File +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.* @@ -50,6 +51,7 @@ class ConductarrTitleSurfaceTest { val facade = mockk() coEvery { facade.findHeld(any(), any()) } returns null val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true)) + val revokedLookup = CompletableDeferred() try { val route = titleRoute("movie:42") val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { @@ -93,16 +95,26 @@ class ConductarrTitleSurfaceTest { assertFalse(body.containsKey("actor")) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-receipt.png").absolutePath) rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } + rule.mainClock.autoAdvance = false + coEvery { facade.findHeld(any(), any()) } coAnswers { revokedLookup.await() } fake.ratingsStatus = 401 runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } rule.waitUntil(30_000) { org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + rule.mainClock.advanceTimeByFrame() viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable } + repeat(12) { index -> + rule.mainClock.advanceTimeBy(16) + assertEquals("Revoking access must clear private facts without replaying the first-load skeleton", + TitleReadStatus.Unavailable, viewModel.title!!.data.status) + if (index == 0 || index == 11) rule.onRoot().captureRoboImage(File(directory, "conductarr-title-revoked-$index.png").absolutePath) + } + revokedLookup.complete(null) assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) assertFalse(viewModel.title!!.options.any { it.id == "request" }) assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" }) - } finally { viewModel.viewModelScope.cancel() } + } finally { viewModel.viewModelScope.cancel(); revokedLookup.complete(null) } } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt index 5be264e6..49036e95 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/DiscoverRefreshMotionTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.preferences.AppPreferences @@ -45,10 +46,6 @@ class DiscoverRefreshMotionTest { @get:Rule val rule = createAndroidComposeRule() @Test fun loadedCatalogueRefreshKeepsReadyActionsOpaqueAndStationary() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - rule.mainClock.autoAdvance = false FakeConductarr().use { fake -> fake.localArt = true ConductarrSession(MemoryCacheStore()).use { session -> @@ -61,17 +58,9 @@ class DiscoverRefreshMotionTest { val route = titleRoute("tv:44") val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) - val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() val refreshLookup = CompletableDeferred() try { - rule.setContent { WolfskinTheme { - model.title?.let { state -> - val bitmaps = listOfNotNull(state.data.item.poster, state.data.item.backdrop).associateWith { art } - CompositionLocalProvider(LocalTitleBitmaps provides bitmaps) { - TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) - } - } - } } + render(model, shell) rule.runOnIdle { model.load(route, person, preferences, fixtures = false) } rule.waitUntil(30_000) { Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() @@ -102,6 +91,119 @@ class DiscoverRefreshMotionTest { } } + @Test fun loadedFixtureRefreshKeepsTheSameReadyHeaderAndCatalogueIdentity() { + ConductarrSession(MemoryCacheStore()).use { session -> + val model = WolfskinTitleViewModel(rule.activity, mockk(relaxed = true), TitleFixtureProvider(rule.activity), + NavigationManager(), session, mockk(relaxed = true)) + val person = ShellPerson("justin", "Justin", "J", true) + val route = titleRoute("movie-the-amazing-spider-man-2012") + val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + try { + render(model, shell) + rule.runOnIdle { model.load(route, person, preferences, fixtures = true) } + rule.waitUntil(30_000) { + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.status == TitleReadStatus.Ready + } + rule.mainClock.advanceTimeBy(640) + val loaded = model.title!! + val request = requireNotNull(loaded.data.requestTitle(person)) + assertEquals(1930L, request.tmdb) + assertEquals("movie", request.type) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-fixture-refresh").apply { mkdirs() } + val bounds = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot + val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt()) + val baseline = brightness(File(directory, "loaded.png"), bounds, pixels) + val refreshedWatched = !loaded.data.watchTarget.watched + rule.activity.getSharedPreferences("wolfskin-title-fixture", android.content.Context.MODE_PRIVATE).edit() + .putBoolean("${person.key}:${loaded.data.watchTarget.id}:watched", refreshedWatched).commit() + rule.runOnIdle { + model.load(route, person, preferences, fixtures = true, force = true) + assertEquals("A fixture foreground refresh cannot reinstate Loading", TitleReadStatus.Ready, model.title!!.data.status) + } + repeat(12) { index -> + rule.mainClock.advanceTimeBy(16) + assertEquals(TitleReadStatus.Ready, model.title!!.data.status) + assertFalse(shell.navFocused) + assertEquals(bounds, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot) + assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels)) + } + rule.waitUntil(30_000) { + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.watchTarget?.watched == refreshedWatched + } + assertEquals(refreshedWatched, model.title!!.watched) + } finally { model.viewModelScope.cancel() } + } + } + + @Test fun loadedHeldRefreshKeepsTheHeaderVisibleWhilePlaybackIsRevalidated() { + ConductarrSession(MemoryCacheStore()).use { session -> + val id = java.util.UUID.fromString("00000000-0000-0000-0000-000000000042") + val held = TitlePageData(TitleItem(id.toString(), "Held film", "movie", held = true)) + val facade = mockk() + coEvery { facade.readSaved(any(), any()) } returns null + coEvery { facade.read(any(), any(), any()) } returns held + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true)) + val person = ShellPerson("shared", "Shared", "S", false) + val route = titleRoute(id.toString()) + val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination(route) } + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + val refresh = CompletableDeferred() + try { + render(model, shell) + rule.runOnIdle { model.load(route, person, preferences, fixtures = false) } + rule.waitUntil(30_000) { + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.status == TitleReadStatus.Ready + } + rule.mainClock.advanceTimeBy(640) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "discover-held-refresh").apply { mkdirs() } + val bounds = rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot + val pixels = IntArray(bounds.width.toInt() * bounds.height.toInt()) + val baseline = brightness(File(directory, "loaded.png"), bounds, pixels) + coEvery { facade.read(any(), any(), any()) } coAnswers { refresh.await() } + rule.runOnIdle { + model.load(route, person, preferences, fixtures = false, force = true) + assertEquals(TitleReadStatus.Ready, model.title!!.data.status) + assertFalse(model.title!!.data.watchReady) + } + repeat(12) { index -> + rule.mainClock.advanceTimeBy(16) + assertEquals(TitleReadStatus.Ready, model.title!!.data.status) + assertEquals(bounds, rule.onNodeWithText("Held film").fetchSemanticsNode().boundsInRoot) + assertEquals(baseline, brightness(File(directory, "refresh-${index.toString().padStart(2, '0')}.png"), bounds, pixels)) + } + refresh.complete(held) + rule.waitUntil(30_000) { + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.watchReady == true + } + assertEquals(TitleReadStatus.Ready, model.title!!.data.status) + assertTrue(model.title!!.options.any { it.id == "play" }) + } finally { model.viewModelScope.cancel(); refresh.complete(held) } + } + } + + private fun render(model: WolfskinTitleViewModel, shell: ShellState) { + rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) + @Suppress("DEPRECATION") + rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + rule.mainClock.autoAdvance = false + val art = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.DKGRAY) }.asImageBitmap() + rule.setContent { WolfskinTheme { + model.title?.let { state -> + val paths = listOfNotNull(state.data.item.poster, state.data.item.backdrop) + + state.data.cast.mapNotNull { it.portrait } + + (state.data.related + state.data.episodes).flatMap { listOfNotNull(it.poster, it.backdrop) } + CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) { + TitleSurface(state, shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) + } + } + } } + } + private fun brightness(file: File, bounds: Rect, pixels: IntArray): Long { rule.onRoot().captureRoboImage(file.absolutePath) val bitmap = BitmapFactory.decodeFile(file.absolutePath) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt new file mode 100644 index 00000000..fbfd10eb --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRequestSafetyTest.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +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.takahirom.roborazzi.captureRoboImage +import io.mockk.coEvery +import io.mockk.mockk +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.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** Even an incomplete/stale requestable page must recover through Wolfskin, not throw. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleRequestSafetyTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun missingCatalogueIdentityShowsRecoveryAndCannotProduceAFakeHideReceipt() { + FakeConductarr().use { fake -> + ConductarrSession(MemoryCacheStore()).use { session -> + session.select("server:shared", SHARED_TOKEN, fake.url) + runBlocking { session.authenticate() } + val facade = mockk() + coEvery { facade.findHeld(any(), any()) } returns null + val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), NavigationManager(), session, mockk(relaxed = true)) + val person = ShellPerson("shared", "Shared", "S", true) + val route = titleRoute("movie:42") + val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("discover"); enterDestination(route) } + val art = ImageBitmap(1, 1) + try { + rule.setContent { WolfskinTheme { + val data = model.title?.data + val paths = listOfNotNull(data?.item?.poster, data?.item?.backdrop) + + data?.cast.orEmpty().mapNotNull { it.portrait } + + data?.related.orEmpty().flatMap { listOfNotNull(it.poster, it.backdrop) } + CompositionLocalProvider(LocalTitleBitmaps provides paths.associateWith { art }) { + TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false) + } + } } + rule.waitUntil(30_000) { + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.title?.data?.connection == TitleConnection.SignedIn && model.conductarrAction != null + } + // A stale/partial read must not make the action adapter throw even if its + // previous requestable snapshot still exists. + rule.runOnIdle { + val current = model.title!! + current.update(current.data.copy(item = TitleItem("missing-identity", "Unidentified film", "movie"), pick = true)) + } + rule.onNodeWithText("Request").performClick() + rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed() + rule.onNodeWithText("Unidentified film").assertIsDisplayed() + assertFalse(shell.navFocused) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "title-request-safety").apply { mkdirs() } + rule.onRoot().captureRoboImage(File(directory, "missing-identity-request.png").absolutePath) + rule.onNodeWithText("Not interested").performClick() + assertFalse(model.title!!.data.hidden) + assertNull(model.title!!.sheet) + rule.onNodeWithText(RecoveryMessage.TitleActionUnavailable).assertIsDisplayed() + rule.onRoot().captureRoboImage(File(directory, "missing-identity-hide.png").absolutePath) + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt index 9030249d..56f39145 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrTitlePages.kt @@ -115,10 +115,16 @@ private fun Title.titleItem(provider: ConductarrProvider): TitleItem = TitleItem otherOwners = provider.holders(this).toList(), tmdbId = tmdbId, ) -internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle = DiscoverTitle( - item.tmdbId ?: item.id.substringAfter(':').toLongOrNull() ?: error("Request needs a catalogue identity"), if (item.kind == "movie") "movie" else "tv", item.name, item.year, - genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, - runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, - seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, - heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), -) +internal fun TitlePageData.requestTitle(viewer: ShellPerson): DiscoverTitle? { + if (item.kind != "movie" && item.kind != "tv") return null + val tmdb = item.tmdbId?.takeIf { it > 0 } + ?: item.id.takeIf { it.startsWith("${item.kind}:") }?.substringAfter(':')?.toLongOrNull()?.takeIf { it > 0 } + ?: return null + return DiscoverTitle( + tmdb, item.kind, item.name, item.year, + genres = item.genres, overview = item.overview, poster = item.poster, backdrop = item.backdrop, + runtimeSeconds = item.runtimeSeconds.toInt(), seasonCount = item.seasons.takeIf { it > 0 }, + seasons = seasonNumbers.filter { it > 0 }.takeIf { it.isNotEmpty() }, ratings = item.ratings.map { DiscoverRating(it.source, it.score, it.label) }, + heldBy = item.otherOwners.toSet() + if (item.held) setOf(viewer.key) else emptySet(), + ) +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 2f6cd82c..2ede84be 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -90,10 +90,11 @@ class WolfskinTitleViewModel @Inject constructor( /** Only an explicit preview/test choice admits assets; real catalogue ids use Conductarr. */ fun load(destination: String, selected: ShellPerson, userPreferences: UserPreferences, fixtures: Boolean = BuildConfig.DEBUG, force: Boolean = false) { if (!force && route == destination && ::viewer.isInitialized && viewer.key == selected.key) return - if (force && route == destination && ::viewer.isInitialized && viewer.key == selected.key && - title?.data?.status == TitleReadStatus.Ready && - (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true) && - !destination.startsWith("person/") && fixture == null) { + val sameTitle = route == destination && ::viewer.isInitialized && viewer.key == selected.key && !destination.startsWith("person/") + val readyTitle = sameTitle && title?.data?.status == TitleReadStatus.Ready + val fixtureRefresh = title?.takeIf { force && readyTitle && fixture != null && fixtures && BuildConfig.DEBUG } + if (force && readyTitle && fixture == null && + (title?.data?.connection != TitleConnection.SignedIn || conductarr.identity.value?.signedIn == true)) { loadJob?.cancel() preferences = userPreferences title?.let { it.update(it.data.copy(watchReady = false)) } @@ -103,14 +104,18 @@ class WolfskinTitleViewModel @Inject constructor( route = destination loadJob?.cancel() person = null - fixture = null + if (fixtureRefresh == null) fixture = null personReadPending = false personReadFinished = false performerPage = 1 viewer = selected preferences = userPreferences val id = Uri.decode(destination.substringAfter('/')) - title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = TitleReadStatus.Loading), viewer, ::handle) + if (fixtureRefresh == null) { + // Losing authorization clears the old data, but is not another first-load skeleton. + val status = if (sameTitle && title?.data?.status != TitleReadStatus.Loading) TitleReadStatus.Unavailable else TitleReadStatus.Loading + title = TitleState(TitlePageData(TitleItem(id, "", if (destination.startsWith("person/")) "person" else "movie"), status = status), viewer, ::handle) + } loadJob = viewModelScope.launch { if (fixtures && BuildConfig.DEBUG && id.toUuidOrNull() == null) { val source = fixtureProvider.read() @@ -122,13 +127,16 @@ class WolfskinTitleViewModel @Inject constructor( val data = withContext(Dispatchers.IO) { source.title(id, selected.key) } val key = "${viewer.key}:${data.item.id}" val watchKey = "${viewer.key}:${data.watchTarget.id}" - title = TitleState(data.copy( + val restored = data.copy( selectedVersion = fixtureSelections.getString("$key:version", null), selectedAudio = if (fixtureSelections.contains("$key:audio")) fixtureSelections.getInt("$key:audio", -1) else null, selectedSubtitle = fixtureSelections.getInt("$key:subtitle", -1), watchTarget = data.watchTarget.copy(watched = fixtureSelections.getBoolean("$watchKey:watched", data.watchTarget.watched)), hidden = fixtureSelections.getBoolean("$key:hidden", data.hidden), - ), viewer, ::handle) + ) + if (fixtureRefresh != null) { + if (title === fixtureRefresh) fixtureRefresh.update(restored) + } else title = TitleState(restored, viewer, ::handle) } } else if (destination.startsWith("person/")) readPerson(id) else readTitle(id) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt index 3e9709aa..4cad6e4f 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt @@ -75,6 +75,7 @@ class TitleFixture(private val fixture: JSONObject) { seriesName = source.optString("seriesTitle").takeIf { it.isNotEmpty() }, season = source.optInt("seasonNumber"), episode = source.optInt("episodeNumber"), ratings = ratings(source), otherOwners = owners.filter { it.getString("key") != person }.map { it.getString("name") }, isPosterBackdrop = source.optJSONObject("artwork")?.optString("backdropKind") == "poster_crop", + tmdbId = source.optLong("tmdbId").takeIf { it > 0 }, ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt index f4188e3f..2b23a738 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -24,17 +24,20 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She } } SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } - LaunchedEffect(requests, title?.data) { + val requestItem = remember(fixtures, title?.data, shell.person.key) { + if (fixtures) null else title?.data?.requestTitle(shell.person) + } + LaunchedEffect(requests, title?.data, requestItem) { val data = title?.data ?: return@LaunchedEffect - if (!fixtures && requests != null && data.item.tmdbId != null) { - val item = data.requestTitle(shell.person) + val item = requestItem ?: return@LaunchedEffect + if (requests != null) { requests.updateSnapshot(DiscoverSnapshot(picks = if (data.pick) listOf(item) else emptyList(), hidden = if (data.hidden) listOf(item) else emptyList(), status = if (data.connection == TitleConnection.SignedOut) DiscoverStatus.NeedsSignIn else DiscoverStatus.Ready, savedAt = data.savedAt, reachable = data.connection == TitleConnection.SignedIn, requestsAllowed = data.requestsAllowed)) } } - val hidden = title?.data?.takeIf { it.item.tmdbId != null }?.let { data -> requests?.isHidden(data.requestTitle(shell.person)) } + val hidden = requestItem?.let { requests?.isHidden(it) } LaunchedEffect(requests, hidden, requests?.toast) { val data = title?.data ?: return@LaunchedEffect if (!fixtures && hidden != null && data.hidden != hidden) title.update(data.copy(hidden = hidden)) @@ -49,12 +52,21 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She viewModel.conductarrAction = { action -> val current = viewModel.title when (action) { - is TitleAction.Request -> if (current != null && requests != null) requests.openRequest(action.forSomeoneElse, current.data.requestTitle(shell.person)) - is TitleAction.Requests -> shell.openRequests() - is TitleAction.Hide -> if (current != null && requests != null) { + is TitleAction.Request -> if (current != null) { val item = current.data.requestTitle(shell.person) - if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) - requests.overlay = null // TitleState already owns the native hide receipt and Undo. + if (item == null || requests == null) current.toast = RecoveryMessage.TitleActionUnavailable + else requests.openRequest(action.forSomeoneElse, item) + } + is TitleAction.Requests -> shell.openRequests() + is TitleAction.Hide -> if (current != null) { + val item = current.data.requestTitle(shell.person) + if (item == null || requests == null) { + current.update(current.data.copy(hidden = !action.hidden)) + current.toast = RecoveryMessage.TitleActionUnavailable + } else { + if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false) + requests.overlay = null // TitleState already owns the native hide receipt and Undo. + } } else -> error("Not a Conductarr title action") } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index ff6237db..46588b1b 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -104,6 +104,8 @@ The title page's scroll dim is a draw-only colour overlay, not an alpha-animated The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial title/person loading retains destination focus rather than focusing the root nav; Down cannot enter a skeleton, and Back still pops the route without opening the picker. An already-ready catalogue or held title stays Ready while a same-route, same-person refresh reads new data; the existing signed-in authentication guard remains authoritative, and loaded copy/rows do not remount or replay their entry motion. +Fixture foreground/watch refreshes likewise reread saved choices in place without losing Ready or remounting rows. Revoked catalogue authorization clears signed-in facts directly to the unavailable state without replaying the first-load skeleton. Catalogue-backed request/hide actions require a positive movie/series identity; incomplete metadata shows the Wolfskin recovery toast instead of throwing or acknowledging an unperformed hide. Debug title fixtures carry their existing TMDB identity, including the series root when viewing an episode. + Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. `Settings.Global.ANIMATOR_DURATION_SCALE == 0`, exposed by Compose's window `MotionDurationScale`, or the host's `LocalReducedMotion` flag disables pulse and translation/scroll motion. Focus scale and nav geometry snap; permitted fades are capped at **80 ms**. The window context supplies live scale state without settings or disk reads on the main thread. Fetch/decode dispatchers and image URL logic are unchanged. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt index 3e8dd23c..fac5305f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -40,4 +40,5 @@ fun RecoveryToast(message: String, modifier: Modifier = Modifier) { object RecoveryMessage { const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen." const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again." + const val TitleActionUnavailable = "Can’t update this title right now." } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index 1d363e19..70157f2e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -201,7 +201,8 @@ class TitleState( val hidden = id == "hide" data = data.copy(hidden = hidden) onAction(TitleAction.Hide(data.item, hidden)) - if (hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } else toast = "Back in your picks." + if (hidden && data.hidden) { sheetIndex = 0; sheet = TitleSheet.HiddenReceipt } + else if (!hidden && !data.hidden) toast = "Back in your picks." } } }