Merge pull request 'Keep loaded title refreshes stable and guard request identities' (#66) from fm/tv-discover-details-flash into fm/tv-round2

This commit is contained in:
justinzeus 2026-11-07 22:22:58 +01:00
commit 985ebc53a7
11 changed files with 305 additions and 39 deletions

View file

@ -85,4 +85,36 @@ class ConductarrTitlePagesTest {
assertTrue(emptyList<LibraryRating>().withEpisode(8.0, null).isEmpty())
assertTrue(emptyList<LibraryRating>().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)) }
}
}

View file

@ -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<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(relaxed = true))
val revokedLookup = CompletableDeferred<java.util.UUID?>()
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) }
}
}
}

View file

@ -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<ComponentActivity>()
@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<java.util.UUID?>()
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<TitleReadFacade>()
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<TitlePageData>()
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)

View file

@ -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<ComponentActivity>()
@Test fun missingCatalogueIdentityShowsRecoveryAndCannotProduceAFakeHideReceipt() {
FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val facade = mockk<TitleReadFacade>()
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() }
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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.

View file

@ -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."
}

View file

@ -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."
}
}
}