Keep loaded title refreshes stable and guard request identities
All checks were successful
review/code-claude Code APPROVE: no stale data, request guard recovers via toast
review/design-claude Design PASS: no look change beyond flash removal

This commit is contained in:
justinzeus 2026-11-07 22:16:30 +01:00
parent b51dced5fe
commit 5f759b8fef
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")
}