Keep loaded title refreshes stable and guard request identities
This commit is contained in:
parent
b51dced5fe
commit
5f759b8fef
11 changed files with 305 additions and 39 deletions
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue