Keep loaded title refreshes stable and guard request identities #66
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue