Merge current TV recovery states into Conductarr wiring

This commit is contained in:
justinzeus 2026-11-05 23:24:21 +01:00
commit a00db96dc8
43 changed files with 1041 additions and 199 deletions

View file

@ -63,6 +63,7 @@ class ConductarrSurfaceTest {
rule.mainClock.advanceTimeBy(500) rule.mainClock.advanceTimeBy(500)
rule.waitUntil(10_000) { rule.waitUntil(10_000) {
rule.mainClock.advanceTimeByFrame() rule.mainClock.advanceTimeByFrame()
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" } fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }
} }
rule.waitForIdle() rule.waitForIdle()

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.IOException
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeLibraryCache
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved Home projections survive outages but never survive an authenticated identity change. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class HomeRecoveryTest {
private val context = ApplicationProvider.getApplicationContext<Application>()
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
private val personA = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
private val personB = personA.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000003"), name = "Mindel")
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
private val library = HomeLibrary(resume = listOf(film))
@Test fun savedHomePaintsBeforeRefreshAndStaysAfterFailedRetry() = runBlocking {
val cache = HomeLibraryCache(context)
cache.write("${server.id}:${personA.id}", library, 1000L)
val started = CompletableDeferred<Unit>()
val response = CompletableDeferred<HomeLibrary>()
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } coAnswers { started.complete(Unit); response.await() }
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
response.completeExceptionally(IOException("fixture offline"))
refresh.await()
assertFalse(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
assertNotNull(model.snapshot.savedAt)
assertEquals(RecoveryMessage.StillUnreachable, model.toast)
}
@Test fun personBSeesNeitherPersonAsSavedHomeNorTheirLateRefreshResult() = runBlocking {
val facade = mockk<HomeReadFacade>()
val cache = HomeLibraryCache(context)
cache.write("${server.id}:${personA.id}", library, 1000L)
val startedA = CompletableDeferred<Unit>()
val responseA = CompletableDeferred<HomeLibrary>()
coEvery { facade.read(personA.id) } coAnswers { startedA.complete(Unit); responseA.await() }
coEvery { facade.read(personB.id) } throws IOException("fixture offline")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
startedA.await()
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
model.loadLibrary(server, personB, "mindel")
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
assertNull(model.snapshot.savedAt)
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
responseA.complete(library)
requestA.await()
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
assertFalse(model.snapshot.jellyfinAvailable)
}
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
val cache = mockk<HomeLibraryCache>()
every { cache.read(any()) } returns null
every { cache.write(any(), any(), any()) } throws IOException("fixture disk full")
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
model.loadLibrary(server, personA, "justin")
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
assertNull(model.toast)
}
}

View file

@ -0,0 +1,114 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import java.io.File
import java.io.IOException
import java.util.UUID
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Shadows.shadowOf
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.HomeReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Actual Home actions either open upstream playback or leave focus and the saved Home intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class PlayerHandoffTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
private val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Mindel", serverId = server.id, accessToken = null)
private val navigation = NavigationManager()
private val facade = mockk<HomeReadFacade>()
private lateinit var model: WolfskinHomeViewModel
private lateinit var shell: ShellState
@Before fun setup() {
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
shell = ShellState(listOf(ShellPerson("mindel", "Mindel", "M", false)), "mindel").apply { pickerVisible = false }
model = WolfskinHomeViewModel(rule.activity, facade, mockk(), navigation, WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
}
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
val title = snapshot.rows.first().cards.first()
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
model.toast?.let { RecoveryToast(it) }
}
} }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
awaitModel { model.toast != null }
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
assertTrue(navigation.backStack.single() is Destination.Home)
assertFalse(shell.navFocused)
assertEquals("hero", snapshot.rows[shell.view.row].id)
assertEquals(0, shell.view.cards["hero"] ?: 0)
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "st-090-integrated.png").absolutePath)
}
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
coEvery { facade.playableId(user.id, title) } returns id
val view = shell.view
view.cards["hero"] = 0
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
awaitModel { navigation.backStack.last() is Destination.Playback }
val destination = navigation.backStack.last() as Destination.Playback
assertEquals(id, destination.itemId)
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
assertTrue(destination.fullScreen)
navigation.goBack()
assertTrue(navigation.backStack.single() is Destination.Home)
assertSame(view, shell.view)
assertEquals(0, shell.view.cards["hero"])
assertNull(model.toast)
}
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
val otherUser = user.copy(name = "Justin")
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
awaitModel { model.toast != null }
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
assertTrue(navigation.backStack.single() is Destination.Home)
coVerify(exactly = 0) { facade.playableId(any(), any()) }
}
private fun awaitModel(condition: () -> Boolean) {
rule.waitUntil(5000) {
shadowOf(Looper.getMainLooper()).idle()
condition()
}
}
}

View file

@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.justinvisser.wolfskin.conductarr.* import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.conductarr.model.LibraryAudience
import org.justinvisser.wolfskin.conductarr.model.Options import org.justinvisser.wolfskin.conductarr.model.Options
import org.justinvisser.wolfskin.conductarr.model.Page import org.justinvisser.wolfskin.conductarr.model.Page
import org.justinvisser.wolfskin.design.* import org.justinvisser.wolfskin.design.*
@ -22,7 +23,7 @@ internal data class CatalogueLaneQuery(
) )
/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ /** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>) { internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>, private val audiences: () -> List<LibraryAudience>) {
private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean)
private val sources = mutableMapOf<String, SourcePage>() private val sources = mutableMapOf<String, SourcePage>()
private val visited = mutableSetOf<String>() private val visited = mutableSetOf<String>()
@ -44,7 +45,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
} }
suspend fun more(id: String): List<DiscoverLane> = mutex.withLock { suspend fun more(id: String): List<DiscoverLane> = mutex.withLock {
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.FORCE, true); visited.add(it.id) } queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) }
lanes() lanes()
} }
@ -55,10 +56,10 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
if (next && previous?.last == true && !previous.failed) return@async key to previous if (next && previous?.last == true && !previous.failed) return@async key to previous
val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1 val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1
val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort) val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort)
val result = client.read(call, trigger).lastOrNull() val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull()
val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null
val items = saved?.items.orEmpty().map { it.discoverTitle() } val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) }
val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items
key to SourcePage(if (saved != null) page else previous?.number ?: 1, key to SourcePage(if (saved != null) page else previous?.number ?: 1,
saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(), saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(),
@ -78,7 +79,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore
pages.any { !it.last } -> DiscoverTail.SeeAll pages.any { !it.last } -> DiscoverTail.SeeAll
else -> DiscoverTail.None else -> DiscoverTail.None
}, pending = pending) }, pending = pending, hasMore = pages.any { !it.last })
} }
} }

View file

@ -14,13 +14,15 @@ internal fun Candidate.discoverTitle(genres: Map<Long, String>): DiscoverTitle =
catalogueImage(posterPath), catalogueImage(backdropPath), score = score, catalogueImage(posterPath), catalogueImage(backdropPath), score = score,
) )
internal fun Title.discoverTitle(): DiscoverTitle = DiscoverTitle( internal fun Title.discoverTitle(audiences: List<LibraryAudience>): DiscoverTitle = DiscoverTitle(
tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview,
poster = posterUrl, backdrop = backdropUrl, poster = posterUrl, backdrop = backdropUrl,
heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() },
) )
internal fun DiscoverSnapshot.homeRows(): List<HomeRow> { internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut)) if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut))
if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true))
if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable)) if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable))
val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) -> val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) ->
picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items -> picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items ->
@ -28,8 +30,9 @@ internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
} }
} }
return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane -> return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane ->
lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title, if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true)
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) } else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title,
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) }
} + HomeRow("explore", HomeRowKind.Explore) } + HomeRow("explore", HomeRowKind.Explore)
} }

View file

@ -19,6 +19,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
private var catalogue: ConductarrCatalogue? = null private var catalogue: ConductarrCatalogue? = null
private var options = emptyList<Options>() private var options = emptyList<Options>()
private val services = mutableMapOf<String, ConductarrCatalogue>() private val services = mutableMapOf<String, ConductarrCatalogue>()
@Volatile private var libraryAudiences = emptyList<LibraryAudience>()
fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow { fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow {
val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY) val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY)
if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved) if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved)
@ -34,7 +35,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
val options = optionsJobs.mapNotNull { it.await() } val options = optionsJobs.mapNotNull { it.await() }
this@ConductarrProvider.options = options this@ConductarrProvider.options = options
val queries = catalogueQueries(options) val queries = catalogueQueries(options)
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries).also { catalogue = it } val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE) val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
@ -66,7 +67,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> { suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> {
val key = "${service.id}:$type" val key = "${service.id}:$type"
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) } val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } }
return if (more != null) loader.more(more) else loader.through(through) return if (more != null) loader.more(more) else loader.through(through)
} }
@ -75,21 +76,28 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
val reads = SnapshotReads(client, trigger) val reads = SnapshotReads(client, trigger)
val feed = reads.read(TvRoutes.activity()) val feed = reads.read(TvRoutes.activity())
if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue
val artReads = SnapshotReads(client, trigger)
val items = coroutineScope { val items = coroutineScope {
feed?.items.orEmpty().map { item -> async { feed?.items.orEmpty().map { item -> async {
val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV
val art = reads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE) val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE)
item.comingIn().copy(poster = art?.posterUrl) item.comingIn().copy(poster = art?.posterUrl)
} }.map { it.await() } } }.map { it.await() }
} }
emit(ComingInSnapshot(items, emit(ComingInSnapshot(items,
if (feed == null) ComingInStatus.Unavailable else ComingInStatus.Ready, if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready,
partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && feed != null)) partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null,
savedAt = reads.savedTime()))
} }
} }
suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles()) suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())
suspend fun audiences(): List<LibraryAudience> = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() suspend fun audiences(): List<LibraryAudience> {
libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
return libraryAudiences
}
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy
} }
/** A batch tracks source failure without replacing saved bodies with empty success. */ /** A batch tracks source failure without replacing saved bodies with empty success. */

View file

@ -9,15 +9,16 @@ import org.justinvisser.wolfskin.design.ScreenRatings
/** Failed reads are not cached as absence; saved observations survive offline. */ /** Failed reads are not cached as absence; saved observations survive offline. */
class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings { class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings {
override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List<LibraryRating>? { override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List<LibraryRating>? {
val media = if (type == "movie") MediaType.MOVIE else MediaType.TV val media = if (type == "movie") MediaType.MOVIE else MediaType.TV
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media)).lastOrNull()) { val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) {
is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type) is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type)
is ReadResult.Failure -> result.saved?.value?.ratings?.screenRatings(type) ?: return null is ReadResult.Failure -> result.saved?.value?.ratings?.screenRatings(type) ?: return null
null -> return null null -> return null
} }
if (type != "episode" || season == null || episode == null) return ratings if (type != "episode" || season == null || episode == null) return ratings
val result = client.read(TvRoutes.season(tmdb, season), ReadTrigger.IF_STALE).lastOrNull() val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull()
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null } val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() } val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() }
val score = vote?.voteAverage?.takeIf { it.isFinite() && it > 0 && it <= 10 && (vote.voteCount ?: 0) > 0 } ?: return ratings val score = vote?.voteAverage?.takeIf { it.isFinite() && it > 0 && it <= 10 && (vote.voteCount ?: 0) > 0 } ?: return ratings

View file

@ -28,7 +28,7 @@ internal fun rememberDiscoverState(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() } val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
val refresh: suspend () -> DiscoverSnapshot = { val refresh: suspend () -> DiscoverSnapshot = {
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull() ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
else { retrySignIn(); DiscoverSnapshot() } else { retrySignIn(); DiscoverSnapshot() }
} }
val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) { val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) {
@ -59,7 +59,7 @@ internal fun rememberDiscoverState(
fixtureState = loaded fixtureState = loaded
if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1) if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
} else if (identity?.signedIn == true) { } else if (identity?.signedIn == true) {
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it) } identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) }
} }
} }
LaunchedEffect(state.service?.id, state.filter, state) { LaunchedEffect(state.service?.id, state.filter, state) {
@ -75,12 +75,16 @@ internal fun rememberDiscoverState(
if (service == null) { if (service == null) {
state.updateLanes(identity.provider.readAhead(lane)) state.updateLanes(identity.provider.readAhead(lane))
val focused = state.focusedLane val focused = state.focusedLane
if (focused != null && focused.tail == DiscoverTail.SeeAll && column >= focused.titles.size - WolfskinTokens.Behaviour.nextPageCards) { if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
state.updateLanes(identity.provider.more(focused.id)) state.updateLanes(identity.provider.more(focused.id))
} }
} else { } else {
val index = state.lanes.indexOfFirst { it.id == lane } val index = state.lanes.indexOfFirst { it.id == lane }
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2) servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2)
val focused = state.focusedLane
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id)
}
state.invalidateLanes() state.invalidateLanes()
} }
} }
@ -93,7 +97,7 @@ internal fun rememberDiscoverState(
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value } val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy( if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(), runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size)) seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
} }
} }
} }

View file

@ -1,15 +1,12 @@
package org.justinvisser.wolfskin.tv package org.justinvisser.wolfskin.tv
import android.content.Context import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -40,11 +37,16 @@ class WolfskinHomeViewModel @Inject constructor(
private val conductarr: ConductarrSession, private val conductarr: ConductarrSession,
) : ViewModel() { ) : ViewModel() {
val conductarrState get() = conductarr.identity val conductarrState get() = conductarr.identity
val audiences get() = home.audiences
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
private set private set
var toast by mutableStateOf<String?>(null) var toast by mutableStateOf<String?>(null)
private set private set
private var catalogueJob: kotlinx.coroutines.Job? = null private var catalogueJob: kotlinx.coroutines.Job? = null
private var sourceScope: String? = null
private var loadRevision = 0L
private var catalogueSnapshot = DiscoverSnapshot()
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
catalogueJob?.cancel() catalogueJob?.cancel()
@ -53,65 +55,110 @@ class WolfskinHomeViewModel @Inject constructor(
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
conductarr.authenticate() conductarr.authenticate()
} }
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut)) loadLibrary(server, user, person, retry)
if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) { }
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
/** The authenticated read path is shared by release and offline fixture-driven tests. */
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
val revision = ++loadRevision
if (person != user.name.orEmpty().lowercase() && !(conductarr.identity.value?.person == "${server.id}:${user.id}" && person == conductarr.identity.value?.operator?.requester)) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false)
return return
} }
val scope = "${server.id}:${user.id}" val scope = "${server.id}:${user.id}"
val saved = withContext(Dispatchers.IO) { cache.read(scope) } if (sourceScope != scope) {
sourceScope = scope
toast = null
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
}
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
if (revision != loadRevision) return
var libraryCopy = saved?.first ?: HomeLibrary() var libraryCopy = saved?.first ?: HomeLibrary()
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation)) var libraryAvailable = true
var librarySavedAt: String? = null
val identity = conductarr.identity.value
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
fun publish() {
if (revision != loadRevision || sourceScope != scope) return
val status = when (catalogueCopy.status) {
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
DiscoverStatus.Loading -> HomeAvailability.Loading
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
}
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
}
catalogueSnapshot = catalogueCopy
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
if (saved != null) publish()
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
}
}
try { try {
val library = withContext(Dispatchers.IO) { home.read(user.id) } val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis() val savedAt = System.currentTimeMillis()
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* A disk failure must not turn a reachable source into an outage. */ } }
libraryCopy = library libraryCopy = library
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) } publish()
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second))) if (revision != loadRevision) return
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) libraryAvailable = false
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.") librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
} publish()
val identity = conductarr.identity.value if (retry) showToast(RecoveryMessage.StillUnreachable)
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
if (conductarr.identity.value?.client !== identity.client) return@collect
val status = when (catalogue.status) {
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
else -> if (catalogue.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
}
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogue.homeRows(), libraryAvailable = snapshot.jellyfinAvailable),
status, snapshot.jellyfinAvailable, catalogue.savedAt ?: snapshot.savedAt)
}
} }
} }
suspend fun readAhead(lane: String?) {
val identity = conductarr.identity.value ?: return
if (!identity.signedIn || ConductarrPreference.fixtures) return
val revision = loadRevision
val lanes = identity.provider.readAhead(lane)
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes))
}
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) { fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
when (action) { when (action) {
HomeAction.Discover -> openRoot(shell, "discover") HomeAction.Discover -> openRoot(shell, "discover")
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) } HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation) is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}")) is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}"))
is HomeAction.Play -> viewModelScope.launch { is HomeAction.Play -> viewModelScope.launch {
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) { val selectedId = shell.person.jellyfinUserId
showToast("Can’t reach Jellyfin to play. Check the network and try again.") val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
if (!snapshot.jellyfinAvailable || !matchesUser) {
showToast(RecoveryMessage.PlaybackUnavailable)
return@launch return@launch
} }
try { try {
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.") if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
showToast("Can’t reach Jellyfin to play. Check the network and try again.") showToast(RecoveryMessage.PlaybackUnavailable)
} }
} }
} }
} }
private fun openCatalogueGrid(row: HomeRow, shell: ShellState) {
val identity = conductarr.identity.value
val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id }
val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ ->
if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed")
val next = identity.provider.more(lane.id).first { it.id == lane.id }
if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable")
LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore)
} else null
seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore)
}
private fun openRoot(shell: ShellState, route: String) { private fun openRoot(shell: ShellState, route: String) {
shell.enterDestination(route) shell.enterDestination(route)
shell.focusNav(shell.tabs.indexOf(route)) shell.focusNav(shell.tabs.indexOf(route))
@ -147,12 +194,15 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
@Composable @Composable
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
val identity by model.conductarrState.collectAsState() val identity by model.conductarrState.collectAsState()
LaunchedEffect(shell.person.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) } LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person.key) }
val view = shell.rememberedView("home")
LaunchedEffect(view.row, model.snapshot.rows) {
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
model.readAhead(lane)
}
Box(modifier) { Box(modifier) {
val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot
HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
model.toast?.let { message -> model.toast?.let { RecoveryToast(it) }
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
}
} }
} }

View file

@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens
@Composable @Composable
internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) { internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) {
val owner = LocalLifecycleOwner.current val owner = LocalLifecycleOwner.current
LaunchedEffect(state, owner) { LaunchedEffect(state, state.person.key, owner) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
refreshRequestsWhileStarted(state, refresh) refreshRequestsWhileStarted(state, refresh)
} }

View file

@ -137,18 +137,26 @@ class WolfskinShellViewModel @Inject constructor(
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
/** Requests are person-scoped; a failed refresh retains the provider's saved observations. */ /** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */
fun refreshRequests() { fun refreshRequests() = refreshRequests(retry = false)
private fun refreshRequests(retry: Boolean) {
val shell = session.state ?: return
val target = shell.requests
val identity = conductarr.identity.value
viewModelScope.launch { viewModelScope.launch {
if (ConductarrPreference.fixtures) { if (ConductarrPreference.fixtures) {
session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot }) target.update(withContext(Dispatchers.IO) { requestsSnapshot })
} else { } else if (identity?.signedIn != true) {
val identity = conductarr.identity.value target.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) } else identity.provider.requests().collect { snapshot ->
else identity.provider.requests().collect { snapshot -> if (conductarr.identity.value?.client === identity.client) {
if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot) target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot())
} }
} }
if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
}
} }
} }
@ -161,7 +169,7 @@ class WolfskinShellViewModel @Inject constructor(
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
ShellAction.RetryRequests -> viewModelScope.launch { ShellAction.RetryRequests -> viewModelScope.launch {
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
refreshRequests() refreshRequests(retry = true)
} }
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}")) is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
} }
@ -267,7 +275,7 @@ fun WolfskinApp(
ShellSurface(state, handlesContentKeys = upstream == null, ShellSurface(state, handlesContentKeys = upstream == null,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when { when {
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") -> route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") ->

View file

@ -51,7 +51,8 @@ fun WolfskinLibraryContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val view = shell.rememberedView(route) val view = shell.rememberedView(route)
val fixture by produceState<LibraryFixture?>(null, context) { val fixture by produceState<LibraryFixture?>(null, context, ConductarrPreference.fixtures) {
value = null
if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) {
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
} }
@ -62,7 +63,7 @@ fun WolfskinLibraryContent(
return return
} }
val grid = remember(shell.person.key, route, view) { val grid = remember(shell.person.key, route, view) {
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/")) else LibraryGridState( view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(), title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
onTitle = { navigation.navigateTo(Destination.Wolfskin("library-title/${it.id}")) }, onTitle = { navigation.navigateTo(Destination.Wolfskin("library-title/${it.id}")) },
onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) }, onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) },
@ -73,7 +74,7 @@ fun WolfskinLibraryContent(
ShellPlaceholder("See all list unavailable", modifier) ShellPlaceholder("See all list unavailable", modifier)
return return
} }
LaunchedEffect(grid, grid.refreshRevision, fixture) { LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) { if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
val wasLoading = grid.status == LibraryStatus.Loading val wasLoading = grid.status == LibraryStatus.Loading
if (fixture != null) { if (fixture != null) {
@ -84,6 +85,8 @@ fun WolfskinLibraryContent(
grid.source = snapshot.items grid.source = snapshot.items
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
} }
} }
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false

View file

@ -22,6 +22,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */ /** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) { class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
val audiences get() = libraries.audiences
suspend fun read(userId: UUID): HomeLibrary { suspend fun read(userId: UUID): HomeLibrary {
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items

View file

@ -1,5 +1,9 @@
package org.justinvisser.wolfskin.tv.read package org.justinvisser.wolfskin.tv.read
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -18,7 +22,7 @@ import javax.inject.Inject
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */ /** SDK reads are confined here so shell and screen state never depend on SDK responses. */
@javax.inject.Singleton @javax.inject.Singleton
class LibraryReadFacade @Inject constructor(private val api: ApiClient) { class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
var audiences: List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>? = null var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null)
fun destination(name: String, collection: CollectionType?): String? { fun destination(name: String, collection: CollectionType?): String? {
audiences?.let { known -> audiences?.let { known ->

View file

@ -92,10 +92,10 @@ Settings list. Native Up traversal stays in content until the first focusable ro
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
Debug people, tabs and initials come from the shell-only projection of the locked Saved Jellyfin identities use the existing upstream session. Conductarr supplies
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources household labels, requester capabilities and library audiences after sign-in.
do not package it. Saved Jellyfin identities use existing upstream services. The The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
shell makes no Conductarr calls and does not add Conductarr sign-in. only when the Advanced debug fixture switch is enabled; release excludes it.
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
@ -104,13 +104,12 @@ request and pick actions. Unheld titles have no trailer control until the pinned
Conductarr title contract supplies a trailer. Conductarr title contract supplies a trailer.
The debug-only fixture and local art live in The debug-only fixture and local art live in
`app/src/debug/assets/wolfskin-discover/`. Release builds remain on the explicit `app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
Conductarr sign-in state. The fixture host cannot construct a network client: network-free fixture host; real providers are the default in debug and release.
debug request outcomes and dismissals stay in memory. The real mutation adapter The mutation adapter accepts the selected person's `ConductarrClient` and uses
accepts an injected `ConductarrClient` and uses only its typed routes; it is not only typed routes. Request submission first reads title receipts. Unknown outcomes
wired to a live server. Unknown request outcomes force a fresh title-requests read are checked, never blindly re-sent, including after closing a sheet or choosing
instead of blindly submitting again, including after closing a sheet or choosing the same uncertain recipient again. Whole-series requests require season metadata.
the same uncertain recipient from another chooser. Whole-series requests require every season.
Discover hero and Restore text use explicit per-line CSS allocations so native Discover hero and Restore text use explicit per-line CSS allocations so native
paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles
@ -205,15 +204,15 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
stays visible when Jellyfin is unreachable. Playback resolves an actual film or stays visible when Jellyfin is unreachable. Playback resolves an actual film or
episode and checks Jellyfin before opening upstream's player. No season, episode and checks Jellyfin before opening upstream's player. No season,
recommendation stub or different person's session is promoted to playback. recommendation stub or different person's session is promoted to playback.
An unresolved person switch shows the same sign-in guard for Home as for native An unresolved person switch guards Home and both owned/upstream library content
library content, before mounting Home's source reads. before mounting any source read with the prior person's Jellyfin session.
Debug variants additionally package the locked fixture and local artwork under Debug variants additionally package the locked fixture and local artwork under
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
The fixture provider supplies the approved household data and Home states without The explicit fixture provider supplies approved household data and Home states
network reads in tests. Conductarr sign-in is not implemented here: real signed-out without network reads in tests. Real signed-out Home hides every rating and shows
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is "Discover needs sign-in"; signed-in Home reads the Conductarr continuation and
used. Catalogue data and ratings in debug captures are fixture-backed. ratings-only endpoint. No Seerr fallback is used.
Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`; Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`;
those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters, those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters,
@ -251,13 +250,12 @@ for Back, and closing the sheet restores its opener. Selecting a root or changin
person clears the title-return link. Shared's authenticated actor always reads person clears the title-return link. Shared's authenticated actor always reads
“by Shared”, never “by you”. “by Shared”, never “by you”.
The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
its eight local posters, projected from the locked prototype fixture. Six stage its eight local posters. Shared and focused-rating records also feed the native
rows feed the running debug shell; the Shared and focused-rating records feed acceptance states; the identical projection is in design-test resources.
native acceptance states. The identical projection is in design-test resources. No “sample” or “example stages” pill is drawn. The default provider instead maps
No “sample” or “example stages” pill is drawn. Release builds package none of Conductarr household activity, authenticated actors and display artwork into
these assets and show Requests unavailable until Conductarr sign-in supplies the same sheet. Release excludes all fixture assets. There is no Seerr fallback.
the generated-client data path. There is no Seerr fallback or handwritten HTTP.
Foreground refreshes once; an open sheet or unsettled requests refresh every Foreground refreshes once; an open sheet or unsettled requests refresh every
30 seconds while started. The immutable debug projection is loaded once, not 30 seconds while started. The immutable debug projection is loaded once, not
decoded again on every poll. decoded again on every poll.
@ -299,8 +297,7 @@ uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py
`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080, `RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080,
density 2.0. Title selection currently uses an explicitly labelled density 2.0. Title selection currently uses an explicitly labelled
`request-title/...` destination until the independent title screen is integrated; `request-title/...` destination until the independent title screen is integrated;
this slice does not claim to implement title pages, TV sign-in, release or device title pages, release and device deployment remain outside the Requests component.
deployment.
## Libraries and See all ## Libraries and See all
@ -323,8 +320,8 @@ The debug-only `wolfskin-library.json` and `library/assets/` contain the locked
prototype's titles and local poster art. Release libraries read through prototype's titles and local poster art. Release libraries read through
`LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person `LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person
snapshots using the existing atomic disk cache. Saved data is painted before a snapshots using the existing atomic disk cache. Saved data is painted before a
quiet refresh; a failed refresh keeps it. Production ratings remain hidden until quiet refresh; a failed refresh keeps it. Production ratings remain hidden before
Conductarr sign-in exists. Conductarr sign-in and use the shared ratings-only provider afterwards.
Home/Discover shelves open a full list through Home/Discover shelves open a full list through
`WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`. `WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`.
@ -340,6 +337,109 @@ the title-screen implementation; this slice does not implement title pages.
failure/retry, stale copies, and person/server isolation. Library captures are written failure/retry, stale copies, and person/server isolation. Library captures are written
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
## Offline states and recovery
Owned screens share `RecoveryToast`: the warning glyph, 760 px maximum width,
22/28 px padding, 14 px icon gap, 22 px corners and CSS-sized body line boxes.
Network failures never expose transport exception text. Playback failure reads
“Can’t reach Jellyfin to play. Check the network and try again.” and leaves the
originating card focused; no simulated player hand-off panel is shipped. Retry
failure reads “Still can’t reach it. The last saved view stays on screen.”
Library headers and the nav publish the same saved-copy timestamp. Failed library
refreshes retain cards and expose a reachable Try again tail. Discover retains its
current catalogue after a failed refresh, adds a recovery tail and disables request
and dismissal writes until recovery; a revoked sign-in discards that saved projection.
Previously observed plates can remain visible offline, but moving to an uncached
card cannot start a ratings read. `SavedPill` shares the compact cached-header
treatment across Home, Libraries and Requests; the nav retains its larger wifi chip.
Requests focus, observed ratings and retained rows are partitioned by selected person.
A failed authenticated read preserves the last rows and their timestamp; Down from
the final row reaches Try again and Up returns to the row. Signed-out data is not
retained as an authenticated copy. Returning to a foreground person triggers that
person's refresh rather than waiting for the prior person's polling timer.
Home disk snapshots remain server/user scoped. A new identity starts with its own
skeleton or saved copy, never the previous person's projection; late responses from
an older read cannot overwrite the current identity. A disk-write failure does not
turn a successful Jellyfin read into an outage. Skeleton-only Home, Library and
Discover screens keep focus in the nav even when Down is pressed repeatedly.
Library/Discover posters use the same title/glyph/160-degree gradient fallback as
Home, including an actual Coil read failure. Discover hero image failures use the
shared ambient background; missing artwork is not a successful data refresh.
`RecoveryStateTest`, `RecoveryCaptureTest`, `HomeRecoveryTest` and `PlayerHandoffTest`
exercise state transitions, person isolation, native recovery focus/geometry and the
real upstream playback destination with offline fixtures. New native captures and
rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score
captures configure the Android resource locale as well as the JVM locale.
Conductarr transport and identity live outside the state components. A revoked
sign-in discards the current person's authenticated projections; an outage keeps
last-known-good (LKG) data and its Saved timestamp. No Seerr fallback or offline
request queue is used. Title-family screen adoption remains a separate integration.
## Conductarr production wiring
The default endpoint is `BuildConfig.CONDUCTARR_BASE_URL`:
`https://wolfskin-tv.justinvisser.org`. Override **Conductarr server URL** beside
**Wolfskin screens** in Advanced settings; blank restores the build default.
HTTPS is required except for loopback fake servers. **Wolfskin preview fixtures**
is a debug-only, off-by-default switch; it never sends HTTP mutations.
`ConductarrSession` borrows the currently selected user's existing Jellyfin access
token. It sends only `Authorization: MediaBrowser Token="<token>"`, with no cookie,
API key or second credential store. Person/server/token/endpoint changes discard
the old client and in-memory providers. LKG files are isolated by person and client
identity in app-private, backup-excluded storage. A 401 clears that identity's
cache and returns to sign-in states; Try again reconstructs and verifies the client.
| Screen seam | Production supplier |
| --- | --- |
| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins |
| Home continuation | Recommendation feed, catalogue options/discovery; Coming soon stays Discover-only |
| Discover | Feed/contributions, services, New on, Trending, genres, Coming soon, parameterized service shelves, dismissal reads/writes |
| Catalogue paging | Two-shelf read-ahead, distinct title identities, per-parameter disk pages, retry/duplicate-only tails; no page counters |
| Request sheet | Capability-limited requesters, season metadata, POST receipt, receipt lookup before any resubmit; actor comes only from the token |
| Requests | Household activity, stage precedence, actor/requester evidence and display posters; foreground/30-second refresh |
| Ratings | Ratings-only read after 350 ms card dwell; immediate hero/title/Coming-in reads; per-type six-hour/300-entry cache; episode season votes |
| Additive title provider | Detail, cast, related, season/votes, performer, filmography, enrichment, display, title activity and uncached title receipts |
The pinned client exposes exactly the 22 TV operations, including ratings and
display. Conditional reads send ETags and reuse the saved body on 304. The fake
server tests use representative wire JSON, never a live Conductarr or Jellyfin.
`ConductarrSessionTest`, `ConductarrProjectionTest` and `ConductarrSurfaceTest`
cover identity/cache isolation, 401/retry, saved offline data, actor-safe requests,
ratings timing and a native real-provider Discover surface. The existing fixture
screen tests remain in the normal `make test` gate.
Not done here: adopting the new provider inside the independently reviewed title
screen family; deploying the dedicated hostname or widening server authority;
hardware installation/playback verification; releases, signing or version changes.
The existing title integration destinations remain explicit rather than being
silently replaced with invented catalogue/player data.
### Manual smoke after endpoint deployment (no build command)
Use an already authorized test build and a sandbox that accepts its selected
Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin.
1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared,
then another saved person; verify their operator/requester and independent views.
2. Open Home, Discover, a service, Hidden picks and Requests. Check real catalogue
text, stage/actor labels, card dwell ratings and continuous See all loading.
3. Make the sandbox return 503 after a successful read. Reopen: saved content and
Saved time remain, requests cannot submit, and Try again performs another read.
4. Return 401, then restore authentication. Verify ratings/request actions disappear;
Try again restores data without changing Jellyfin login or copying a token.
5. Have the sandbox accept a Shared-on-behalf request but lose its POST response.
Use Check again: the receipt says Shared, and the sandbox records one POST.
6. Restore the blank/default URL when the dedicated TV hostname and server's
22-operation authority are deployed. This checklist is manual, not a live gate.
## Toolchain and disk use ## Toolchain and disk use
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools

View file

@ -282,6 +282,11 @@
"dp": 380.0, "dp": 380.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
}, },
"toastPaddingY": { "$type": "dimension", "px": 22, "dp": 11, "source": "locked index.html:270" },
"toastGap": { "$type": "dimension", "px": 14, "dp": 7, "source": "locked index.html:270" },
"toastShadowBlur": { "$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:270" },
"toastShadowY": { "$type": "dimension", "px": 20, "dp": 10, "source": "locked index.html:270" },
"toastShadowOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:270" },
"cardWashInset": { "cardWashInset": {
"$type": "dimension", "$type": "dimension",
"px": 30, "px": 30,

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Text import androidx.tv.material3.Text
import java.util.Locale import java.util.Locale
@ -31,6 +32,8 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
val mark = when (rating.mark) { val mark = when (rating.mark) {
"fresh" -> R.drawable.coming_in_rating_fresh "fresh" -> R.drawable.coming_in_rating_fresh
"tomato" -> R.drawable.coming_in_rating_fresh
"popcorn" -> R.drawable.coming_in_rating_upright
"rotten" -> R.drawable.coming_in_rating_rotten "rotten" -> R.drawable.coming_in_rating_rotten
"upright" -> R.drawable.coming_in_rating_upright "upright" -> R.drawable.coming_in_rating_upright
"spilled" -> R.drawable.coming_in_rating_spilled "spilled" -> R.drawable.coming_in_rating_spilled
@ -40,7 +43,8 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
val wide = rating.mark == "imdb" || rating.mark == "tmdb" val wide = rating.mark == "imdb" || rating.mark == "tmdb"
Image(painterResource(mark), contentDescription = rating.mark, Image(painterResource(mark), contentDescription = rating.mark,
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f) modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3), .clip(ratingMarkShape).background(WolfskinTokens.Color.surface3)
.graphicsLayer { alpha = if (rating.mark == "tomato" || rating.mark == "popcorn") WolfskinTokens.Library.unknownCriticOpacity else 1f },
contentScale = ContentScale.Fit) contentScale = ContentScale.Fit)
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
} }

View file

@ -39,10 +39,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val state = shell.requests val state = shell.requests
val data = state.snapshot val data = state.snapshot
val ratingsReader = LocalScreenRatings.current val ratingsReader = LocalScreenRatings.current
LaunchedEffect(state.focusedId, data.signedIn, data.status) { LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
if (ratingsReader == null) state.awaitFocusedRatings() if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item -> else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null)?.let { values -> ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
state.updateRatings(item.id, values.map { rating -> state.updateRatings(item.id, values.map { rating ->
val mapped = rating.homeRating() val mapped = rating.homeRating()
ComingInRating(mapped.mark, mapped.score, mapped.percentage) ComingInRating(mapped.mark, mapped.score, mapped.percentage)
@ -90,27 +90,30 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
} }
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted) data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> { else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) { val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
} }
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) { LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
itemsIndexed(visibleRows, itemsIndexed(visibleRows,
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId, ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId, state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
} }
} }
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
} }
} }
} }
} }
@Composable @Composable
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) { private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) } Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
} }
@Composable @Composable

View file

@ -51,6 +51,7 @@ data class ComingInSnapshot(
val status: ComingInStatus = ComingInStatus.Unavailable, val status: ComingInStatus = ComingInStatus.Unavailable,
val partial: Boolean = false, val partial: Boolean = false,
val signedIn: Boolean = false, val signedIn: Boolean = false,
val savedAt: String? = null,
) { ) {
val ordered = items.sortedBy { it.stage.rank } val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
@ -66,6 +67,8 @@ class ComingInState {
private set private set
var ratingsVisible by mutableStateOf(false) var ratingsVisible by mutableStateOf(false)
private set private set
var recoveryFocused by mutableStateOf(false)
private set
private val cachedRatings = mutableSetOf<String>() private val cachedRatings = mutableSetOf<String>()
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0) val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index) val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
@ -73,14 +76,18 @@ class ComingInState {
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0)) val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
fun update(value: ComingInSnapshot) { fun update(value: ComingInSnapshot) {
snapshot = value snapshot = if (value.status != ComingInStatus.Ready && value.signedIn && value.items.isEmpty() && snapshot.signedIn) {
if (!value.signedIn) cachedRatings.clear() snapshot.copy(status = value.status)
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id) } else value
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings if (!snapshot.signedIn) cachedRatings.clear()
if (snapshot.ordered.none { it.id == focusedId }) focus(snapshot.ordered.firstOrNull()?.id)
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
if (snapshot.status == ComingInStatus.Ready) recoveryFocused = false
} }
fun focus(id: String?) { fun focus(id: String?) {
focusedId = id focusedId = id
recoveryFocused = false
expanded = false expanded = false
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
} }
@ -103,9 +110,14 @@ class ComingInState {
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */ /** Return true only when a row requests a title; every direction remains trapped in the sheet. */
fun key(key: ShellKey): Boolean { fun key(key: ShellKey): Boolean {
if (recoveryFocused) {
if (key == ShellKey.Up) recoveryFocused = false
return false
}
when (key) { when (key) {
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id) ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id) ShellKey.Down -> if (snapshot.status == ComingInStatus.Unavailable && index == snapshot.ordered.lastIndex) recoveryFocused = true
else focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
ShellKey.Right -> if (focused != null) expanded = true ShellKey.Right -> if (focused != null) expanded = true
ShellKey.Left -> expanded = false ShellKey.Left -> expanded = false
ShellKey.Ok -> return focused != null ShellKey.Ok -> return focused != null

View file

@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -40,7 +41,7 @@ val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null }
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */ /** Decode requests use the actual art slot, with RGB565 only for upright posters. */
@Composable @Composable
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) { fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false, onError: () -> Unit = {}) {
if (path == null) return if (path == null) return
val bitmap = LocalDiscoverArt.current?.invoke(path) val bitmap = LocalDiscoverArt.current?.invoke(path)
val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) } val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) }
@ -57,18 +58,21 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
model = request, model = request,
imageLoader = WolfskinImages.loader(context), imageLoader = WolfskinImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter, contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
onError = { onError() },
) )
} }
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */ /** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
@Composable @Composable
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) { fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) }
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) { Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
if (title.posterOnly) { if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
else if (title.posterOnly) {
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset) DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
.wrapContentSize(Alignment.TopStart, unbounded = true) .wrapContentSize(Alignment.TopStart, unbounded = true)
.size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2) .size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2)
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true) .graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true, onError = { failed.value = true })
DiscoverArtwork(title.poster, Modifier.offset( DiscoverArtwork(title.poster, Modifier.offset(
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth, WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
if (detail) L.navBand else D.heroSheetTop, if (detail) L.navBand else D.heroSheetTop,
@ -76,8 +80,8 @@ fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Mo
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow( .dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity), radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY))) offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true) .clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true, onError = { failed.value = true })
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize()) } else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize(), onError = { failed.value = true })
Box(Modifier.fillMaxSize().drawWithCache { Box(Modifier.fillMaxSize().drawWithCache {
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width) val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
onDrawBehind { drawRect(scrim) } onDrawBehind { drawRect(scrim) }

View file

@ -29,9 +29,15 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
snapshot.lanes.forEach { lane -> snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden } val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt()) val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.pending || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { val paged = lane.hasMore
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size, val tail = when {
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None) lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore -> lane.tail
paged && items.isEmpty() -> DiscoverTail.ShowMore
paged || (!lane.id.startsWith("new-") && source.size > items.size) -> DiscoverTail.SeeAll
else -> DiscoverTail.None
}
if (items.isNotEmpty() || lane.pending || tail == DiscoverTail.Retry || tail == DiscoverTail.ShowMore) {
result += lane.copy(titles = items, fullTitles = source, total = if (paged) lane.total else if (lane.id.startsWith("new-")) items.size else source.size, tail = tail)
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) } if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
} }
} }

View file

@ -60,6 +60,7 @@ data class DiscoverLane(
val hidden: Boolean = false, val hidden: Boolean = false,
val fullTitles: List<DiscoverTitle> = titles, val fullTitles: List<DiscoverTitle> = titles,
val pending: Boolean = false, val pending: Boolean = false,
val hasMore: Boolean = false,
) )
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */ /** Caller identity is deliberately absent: the authenticated client supplies the actor. */

View file

@ -67,11 +67,7 @@ fun DiscoverOverlay(state: DiscoverState) {
} }
} }
} }
state.toast?.let { text -> state.toast?.let { RecoveryToast(it) }
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
Text(text, color = C.fg, style = DiscoverType.body)
}
}
} }
@Composable @Composable

View file

@ -1,6 +1,9 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -46,21 +49,22 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
shell.contentFocus = focus shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
} }
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
val ratingsReader = LocalScreenRatings.current val ratingsReader = LocalScreenRatings.current
LaunchedEffect(focusedTitle?.key, state.contentFocused) { LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt, state.hero?.key, state.title?.key, state.isSignedIn) {
state.plateKey = null state.plateKey = null
if (state.contentFocused && focusedTitle != null) { coroutineScope {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) if (state.isSignedIn && ratingsReader != null) launch {
if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null)?.let { state.updateRatings(focusedTitle, it) } val prominent = state.title ?: state.hero
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key if (prominent != null) ratingsReader.read(prominent.tmdb, prominent.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(prominent, it) }
} }
} if (state.title == null && state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings || ratingsReader != null)) launch {
LaunchedEffect(state.hero?.key, state.title?.key, state.isSignedIn) { if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (state.isSignedIn && ratingsReader != null) { if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(focusedTitle, it) }
val title = state.title ?: state.hero state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
if (title != null) ratingsReader.read(title.tmdb, title.type, null, null)?.let { state.updateRatings(title, it) } }
} }
} }
if (state.title != null) { DiscoverTitleDestination(state, modifier); return } if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
@ -76,11 +80,11 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
val density = LocalDensity.current val density = LocalDensity.current
val list = shell.view.listState val list = shell.view.listState
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) { val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
buildList { buildList {
add(if (state.hasHero) L.row_phero else L.row_lhead) add(if (state.hasHero) L.row_phero else L.row_lhead)
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero) add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.canRequest) add(L.row_state) else { if (!state.hasCatalogue) add(L.row_state) else {
if (note) add(L.row_note) if (note) add(L.row_note)
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
} }

View file

@ -21,9 +21,11 @@ class DiscoverState(
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() }, private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
private val requesters: List<ShellPerson> = shell.people, private val requesters: List<ShellPerson> = shell.people,
private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null, private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null,
private val nowMillis: () -> Long = System::currentTimeMillis,
) : ShellContentFocus { ) : ShellContentFocus {
var snapshot by mutableStateOf(initial) var snapshot by mutableStateOf(initial)
private set private set
private var savedTime = initial.savedAt ?: formatSavedTime()
private var serviceFilter by mutableStateOf("movie") private var serviceFilter by mutableStateOf("movie")
var filter: String var filter: String
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0] get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
@ -60,12 +62,15 @@ class DiscoverState(
private var cachedHeroes = emptyList<DiscoverTitle>() private var cachedHeroes = emptyList<DiscoverTitle>()
private val dismissalMutex = Mutex() private val dismissalMutex = Mutex()
private val dismissalVersions = mutableMapOf<String, Int>() private val dismissalVersions = mutableMapOf<String, Int>()
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
private var rootFilter = "all" private var rootFilter = "all"
fun updateSnapshot(value: DiscoverSnapshot) { fun updateSnapshot(value: DiscoverSnapshot) {
snapshot = value snapshot = value
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
hidden.clear() hidden.clear()
value.hidden.forEach { hidden[it.key] = it } value.hidden.forEach { hidden[it.key] = it }
pendingDismissals.forEach { (key, item) -> if (item == null) hidden.remove(key) else hidden[key] = item }
revision++ revision++
} }
@ -73,8 +78,9 @@ class DiscoverState(
fun updateLanes(value: List<DiscoverLane>) { snapshot = snapshot.copy(lanes = value); revision++ } fun updateLanes(value: List<DiscoverLane>) { snapshot = snapshot.copy(lanes = value); revision++ }
private suspend fun loadLane(id: String) { private suspend fun loadLane(id: String) {
if (id == "offline-recovery") { reload(); return }
val loaded = onMore?.invoke(id) val loaded = onMore?.invoke(id)
if (loaded != null) updateLanes(loaded) else { snapshot = operations.refresh(); revision++ } if (loaded != null) updateLanes(loaded) else reload()
} }
fun updateTitle(value: DiscoverTitle) { fun updateTitle(value: DiscoverTitle) {
@ -94,13 +100,14 @@ class DiscoverState(
revision++ revision++
} }
override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val canRequest: Boolean get() = when (snapshot.status) { val hasCatalogue: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false else -> false
} }
val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null && snapshot.reachable && snapshot.requestsAllowed
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue
val heroes: List<DiscoverTitle> get() { val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty() val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
@ -165,6 +172,7 @@ class DiscoverState(
scope.launch { scope.launch {
loadLane(lane.id) loadLane(lane.id)
if (collection !== grid) return@launch if (collection !== grid) return@launch
grid.savedAt = snapshot.savedAt
val fresh = lanes.firstOrNull { it.id == lane.id } val fresh = lanes.firstOrNull { it.id == lane.id }
if (canRequest && fresh != null) { if (canRequest && fresh != null) {
titles = fresh.fullTitles.associateBy { it.key } titles = fresh.fullTitles.associateBy { it.key }
@ -176,9 +184,10 @@ class DiscoverState(
} else grid.tail = GridTail.Retry } else grid.tail = GridTail.Retry
} }
}).apply { }).apply {
savedAt = snapshot.savedAt
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
} }
if (onMore != null && lane.tail != DiscoverTail.None) grid.pagination = LibraryPagination(grid) { if (onMore != null && lane.hasMore) grid.pagination = LibraryPagination(grid) {
loadLane(lane.id) loadLane(lane.id)
val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable") val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable")
if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable") if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable")
@ -186,7 +195,7 @@ class DiscoverState(
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply { collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) } titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
} }
LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.tail != DiscoverTail.None) LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.hasMore)
} }
collection = grid collection = grid
onPush("discover-see-all/${lane.id}") onPush("discover-see-all/${lane.id}")
@ -231,7 +240,7 @@ class DiscoverState(
return true return true
} }
if (title != null) { titleKey(key); return true } if (title != null) { titleKey(key); return true }
if (!canRequest) { if (!hasCatalogue) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return true return true
} }
@ -289,7 +298,6 @@ class DiscoverState(
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
if (!canRequest) { refresh(); return } if (!canRequest) { refresh(); return }
if (!requestsEnabled) { toast = "Can’t request right now. Try again."; return }
val choice = RequestSheetState(item, shell.person, requesters, other, phase) val choice = RequestSheetState(item, shell.person, requesters, other, phase)
val previous = requestSheets[item.key to choice.selectedPerson.key] val previous = requestSheets[item.key to choice.selectedPerson.key]
overlay = DiscoverOverlayState.Request( overlay = DiscoverOverlayState.Request(
@ -301,8 +309,8 @@ class DiscoverState(
when (val sheet = overlay) { when (val sheet = overlay) {
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) { is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
scope.launch { scope.launch {
snapshot = operations.refresh(); revision++ reload()
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
sheet.state.focus = sheet.state.buttonIndex sheet.state.focus = sheet.state.buttonIndex
} }
} }
@ -323,7 +331,7 @@ class DiscoverState(
} }
private fun send(sheet: RequestSheetState, check: Boolean) { private fun send(sheet: RequestSheetState, check: Boolean) {
if (!requestsEnabled || (!check && !sheet.canSubmit)) return if (!canRequest || (!check && !sheet.canSubmit)) return
if (!check) { if (!check) {
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key] val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) { if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
@ -354,8 +362,10 @@ class DiscoverState(
val profile = shell.person.key val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1 val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version dismissalVersions[item.key] = version
pendingDismissals[item.key] = item
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.hide(profile, item) } val success = dismissalMutex.withLock { operations.hide(profile, item) }
if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key)
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." } if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." }
} }
} }
@ -367,18 +377,33 @@ class DiscoverState(
val profile = shell.person.key val profile = shell.person.key
val version = (dismissalVersions[item.key] ?: 0) + 1 val version = (dismissalVersions[item.key] ?: 0) + 1
dismissalVersions[item.key] = version dismissalVersions[item.key] = version
pendingDismissals[item.key] = null
scope.launch { scope.launch {
val success = dismissalMutex.withLock { operations.restore(profile, item) } val success = dismissalMutex.withLock { operations.restore(profile, item) }
if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key)
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." } if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." }
} }
} }
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } } fun refresh() { scope.launch { reload() } }
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun buildLanes(): List<DiscoverLane> = service?.let { private suspend fun reload() {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } val refreshed = operations.refresh()
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) val failed = refreshed.status == DiscoverStatus.Unavailable
snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed
if (!failed && hasCatalogue) savedTime = formatSavedTime()
revision++
toast = if (failed) RecoveryMessage.StillUnreachable else null
}
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
private fun buildLanes(): List<DiscoverLane> {
val lanes = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes
}
} }
private fun Int?.orZero(): Int = this ?: 0 private fun Int?.orZero(): Int = this ?: 0

View file

@ -9,6 +9,7 @@ class HomeFocus(
private val onAction: (HomeAction) -> Unit, private val onAction: (HomeAction) -> Unit,
) : ShellContentFocus { ) : ShellContentFocus {
private val view = shell.rememberedView("home") private val view = shell.rememberedView("home")
override val hasContent: Boolean get() = rows.any { it.focusable }
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null } override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
val row: HomeRow? get() = rows.getOrNull(view.row) val row: HomeRow? get() = rows.getOrNull(view.row)
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0 val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0

View file

@ -69,11 +69,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
Text("Discover", color = C.fg, style = T.sectionTitle) Text("Discover", color = C.fg, style = T.sectionTitle)
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader) Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body) Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
if (savedAt != null) Box(Modifier.height(WolfskinTokens.Shell.settingsPillHeight) savedAt?.let { SavedPill(it) }
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = WolfskinTokens.Shell.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
Text("Saved $savedAt", color = C.warn, style = T.caption.copy(fontWeight = T.label.fontWeight))
}
} }
} }
} }

View file

@ -41,12 +41,13 @@ fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fon
val resource = when (rating.mark) { val resource = when (rating.mark) {
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten "rotten" -> R.drawable.ic_rotten_tomatoes_rotten
"upright" -> R.drawable.ic_rt_upright "upright" -> R.drawable.ic_rt_upright
"popcorn" -> R.drawable.ic_rt_upright
"spilled" -> R.drawable.ic_rt_spilled "spilled" -> R.drawable.ic_rt_spilled
"imdb" -> R.drawable.ic_rating_imdb "imdb" -> R.drawable.ic_rating_imdb
"tmdb" -> R.drawable.ic_rating_tmdb "tmdb" -> R.drawable.ic_rating_tmdb
else -> R.drawable.ic_rotten_tomatoes_fresh else -> R.drawable.ic_rotten_tomatoes_fresh
} }
val muted = rating.mark == "tomato" val muted = rating.mark == "tomato" || rating.mark == "popcorn"
Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null) Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1) if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1)
Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false) Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false)

View file

@ -15,6 +15,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -87,15 +89,18 @@ fun HomeScreen(
var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) } var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) }
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) } var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) { LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) { val available = snapshot.conductarr == HomeAvailability.Ready || snapshot.conductarr == HomeAvailability.Saved
val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) } coroutineScope {
if (values != null) { heroRatings = values; cache.save(hero, values) } if (hero != null && available) launch {
} val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) }
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) { if (values != null) { heroRatings = values; cache.save(hero, values) }
val saved = cache.read(title) }
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong()) if (title != null && row?.kind != HomeRowKind.Hero && !shell.navFocused && !shell.pickerVisible && available) launch {
val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode)?.map(LibraryRating::homeRating) } val saved = cache.read(title)
if (values != null) { focusedRatings = values; cache.save(title, values) } if (saved == null) delay(WolfskinTokens.Motion.ratingDwell.toLong())
val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) }
if (values != null) { focusedRatings = values; cache.save(title, values) }
}
} }
} }
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut val showRatings = snapshot.conductarr != HomeAvailability.SignedOut

View file

@ -88,6 +88,7 @@ class LibraryGridState(
} }
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
override val hasContent: Boolean get() = status != LibraryStatus.Loading
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail" override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index) val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)

View file

@ -17,14 +17,12 @@ import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
@ -41,11 +39,9 @@ fun LibraryPoster(
TitleCard(focused, modifier.testTag("poster-${title.id}")) { TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } } val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (title.poster != null && offlineArt == null) { else if (offlineArt == null) TitleArtwork(title.title, title.type, title.poster,
val context = LocalContext.current WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
val request = remember(title.poster, context) { WolfskinImages.request(context, title.poster, (WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt(), true) } else TitleArtwork(title.title, title.type, null, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
AsyncImage(request, title.title, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else MissingLibraryArt(title)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
if (title.progress > 0f && title.progress < 1f) { if (title.progress > 0f && title.progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
@ -66,17 +62,6 @@ fun LibraryPoster(
} }
} }
@Composable
private fun MissingLibraryArt(title: LibraryTitle) {
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
}
}
/** A measured prefix stays on one line, shrinking only to the approved type floor. */ /** A measured prefix stays on one line, shrinking only to the approved type floor. */
@Composable @Composable

View file

@ -82,6 +82,7 @@ fun LibraryScreen(
plateKey = focused.id plateKey = focused.id
} }
} }
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
LaunchedEffect(grid.status) { LaunchedEffect(grid.status) {
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab) if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
} }
@ -175,7 +176,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle) Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle) if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) } savedAt?.let { SavedPill(it) }
} }
} }
} }

View file

@ -100,10 +100,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
} }
} }
} }
state.view.savedAt?.let { time -> state.navSavedAt?.let { time ->
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2) Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2)
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius)) .height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius))
.padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) { .padding(horizontal = H.savedPadding + H.hairline), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
Glyph("wifi", C.warn, Modifier.size(H.savedIcon)) Glyph("wifi", C.warn, Modifier.size(H.savedIcon))
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption) Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
} }

View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.DpOffset
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Recovery messages remain transient and never take focus from the action that failed. */
@Composable
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize()) {
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
.widthIn(max = H.toastWidth)
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
.background(C.surface2, shape)
.testTag("recovery-toast")
.padding(horizontal = H.toastPadding, vertical = H.toastPaddingY),
horizontalArrangement = Arrangement.spacedBy(H.toastGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, Modifier.weight(1f, fill = false).exactLineBox(HomeType.body), color = C.fg, style = HomeType.body)
}
}
}
/** Shared wording is independent of transport details and never exposes exception text. */
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."
}

View file

@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
/** Headers identify a retained copy without becoming a separate focus target. */
@Composable
fun SavedPill(time: String, modifier: Modifier = Modifier) {
Box(modifier.height(S.settingsPillHeight)
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = S.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
Text("Saved $time", color = C.warn, style = HomeType.caption.copy(fontWeight = HomeType.label.fontWeight))
}
}

View file

@ -4,14 +4,14 @@ package org.justinvisser.wolfskin.design
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */ /** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */
fun interface ScreenRatings { interface ScreenRatings {
suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List<LibraryRating>? suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean = false): List<LibraryRating>?
} }
val LocalScreenRatings = compositionLocalOf<ScreenRatings?> { null } val LocalScreenRatings = compositionLocalOf<ScreenRatings?> { null }
/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */ /** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */
fun LibraryRating.homeRating(): HomeRating = HomeRating( fun LibraryRating.homeRating(): HomeRating = HomeRating(
mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } }, mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; "episode" -> "tmdb"; else -> source } },
score = score, percentage = source.startsWith("rt"), episode = source == "episode", score = score, percentage = source.startsWith("rt"), episode = source == "episode",
) )

View file

@ -26,6 +26,7 @@ enum class PeopleStatus { Ready, Loading, Unavailable }
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */ /** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
interface ShellContentFocus { interface ShellContentFocus {
val hasContent: Boolean get() = true
val cardKey: String? val cardKey: String?
fun key(key: ShellKey): Boolean fun key(key: ShellKey): Boolean
} }
@ -70,7 +71,9 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready) var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0) var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false) var requestsVisible by mutableStateOf(false)
val requests = ComingInState() var toast by mutableStateOf<String?>(null)
private val personRequests = mutableMapOf<String, ComingInState>()
val requests: ComingInState get() = personRequests.getOrPut(person.key) { ComingInState() }
private var requestReturnRoute: String? = null private var requestReturnRoute: String? = null
var handOff by mutableStateOf<String?>(null) var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0) var settingsRow by mutableStateOf(0)
@ -85,6 +88,7 @@ class ShellState(
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = rememberedView(route) val view: RememberedView get() = rememberedView(route)
val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt
/** Screen options share the same per-person memory as their root's focus and scroller. */ /** Screen options share the same per-person memory as their root's focus and scroller. */
fun rememberedView(destination: String): RememberedView = fun rememberedView(destination: String): RememberedView =
@ -129,7 +133,7 @@ class ShellState(
if (requestsVisible) { if (requestsVisible) {
when { when {
key == ShellKey.Back -> closeRequests() key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests() key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests() key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest() requests.key(key) -> activateRequest()
} }
@ -195,7 +199,7 @@ class ShellState(
when (key) { when (key) {
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1) ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1) ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
ShellKey.Down -> navFocused = false ShellKey.Down -> if (contentFocus?.hasContent != false) navFocused = false
ShellKey.Ok -> activate() ShellKey.Ok -> activate()
else -> Unit else -> Unit
} }

View file

@ -61,6 +61,13 @@ fun ShellSurface(
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
LaunchedEffect(state.toast) {
val message = state.toast
if (message != null) {
kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong())
if (state.toast == message) state.toast = null
}
}
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus() else contentFocus.requestFocus()
@ -88,6 +95,7 @@ fun ShellSurface(
if (state.requestsVisible) ComingInSheet(state) if (state.requestsVisible) ComingInSheet(state)
overlay() overlay()
} }
state.toast?.let { RecoveryToast(it) }
} }
} }

View file

@ -443,6 +443,11 @@ object WolfskinTokens {
val toastPadding = 14.0.dp val toastPadding = 14.0.dp
val toastTop = 75.0.dp val toastTop = 75.0.dp
val toastWidth = 380.0.dp val toastWidth = 380.0.dp
val toastPaddingY = 11.0.dp
val toastGap = 7.0.dp
val toastShadowBlur = 30.0.dp
val toastShadowY = 10.0.dp
const val toastShadowOpacity = 0.5f
val cardWashInset = 15.0.dp val cardWashInset = 15.0.dp
val heroWashInset = 40.0.dp val heroWashInset = 40.0.dp
val noteTop = 7.0.dp val noteTop = 7.0.dp

View file

@ -0,0 +1,197 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.After
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native state proof uses only packaged fixtures and records the actual text/font/layout surface. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RecoveryCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val previousLocale = Locale.getDefault()
@Before fun fullscreen() {
Locale.setDefault(Locale.US)
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
}
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
private fun shell(route: String = "home") = ShellState(fixturePeople(), "mindel").apply { pickerVisible = false; enterDestination(route) }
private fun mount(shell: ShellState, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit) {
rule.setContent { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = overlay, content = content)
} }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun capture(name: String) {
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
val text = JSONArray()
val forbidden = Regex("Recs Movies|Recs Shows|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page [0-9]")
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
val words = layout.layoutInput.text.text
val style = layout.layoutInput.style
assertTrue("Text smaller than 12sp: $words", style.fontSize.value >= 12f)
assertFalse("Internal wording: $words", forbidden.containsMatchIn(words))
text.put(JSONObject().put("text", words).put("fontSizeSp", style.fontSize.value).put("weight", style.fontWeight?.weight)
.put("lineCount", layout.lineCount).put("lineHeightSp", style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
}
File(directory, "$name-layout.json").writeText(text.toString(2))
}
@Test fun st090_offlinePlayToastRetainsHomeAndFocusedResume() {
val shell = shell()
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
shell.toast = RecoveryMessage.PlaybackUnavailable
mount(shell) { HomeScreen(shell, snapshot, it) {} }
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
rule.onNodeWithText("Resume S4 E9").assertIsDisplayed()
assertFalse(shell.navFocused)
val toast = rule.onNodeWithTag("recovery-toast").fetchSemanticsNode().boundsInRoot
val message = rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).fetchSemanticsNode().boundsInRoot
assertEquals(1064f, toast.left, 1f)
assertEquals(150f, toast.top, 1f)
assertEquals(760f, toast.width, 1f)
assertEquals(1136f, message.left, 1f)
capture("st-090")
}
@Test fun savedLibraryHasNavAndHeaderChipAndAReachableRetryTail() {
var retried = false
val shell = shell("movies")
val grid = LibraryGridState("Movies", listOf(LibraryTitle("saved", "A saved film", "movie")), onRecover = { retried = true }).apply {
savedAt = "20:42"; tail = GridTail.Retry
}
mount(shell) { LibraryScreen(shell, grid, it) }
assertEquals(2, rule.onAllNodesWithText("Saved 20:42").fetchSemanticsNodes().size)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
assertTrue(retried)
capture("library-saved")
}
@Test fun libraryWithoutSavedDataOffersRecovery() {
var retried = false
val shell = shell("shows")
val grid = LibraryGridState("Shows", emptyList(), onRecover = { retried = true }).apply { status = LibraryStatus.Unavailable }
mount(shell) { LibraryScreen(shell, grid, it) }
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
assertTrue(retried)
capture("library-unavailable")
}
@Test fun savedDiscoverKeepsHeroAndRecoveryWithoutAllowingRequests() {
val shell = shell("discover")
val snapshot = discoverTestFixture().snapshot("mindel").copy(savedAt = "20:42")
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.Unavailable), "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
rule.onNodeWithText("Saved 20:42").assertIsDisplayed()
assertTrue(state.hasHero)
assertFalse(state.canRequest)
rule.runOnIdle { state.focus(state.rowCount - 1) }
rule.mainClock.advanceTimeBy(1000)
rule.onNodeWithText("More titles couldn’t load").assertIsDisplayed()
capture("discover-saved")
}
@Test fun unavailableDiscoverRetryShowsExactFailureToast() {
val shell = shell("discover")
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText(RecoveryMessage.StillUnreachable).assertIsDisplayed()
capture("discover-unavailable-retry")
}
@Test fun savedRequestsKeepTheirRowsAndRecoveryAfterFailure() {
val shell = shell("settings")
val item = ComingInRequest("saved", "movie", 1, "A saved request", "mindel", "Mindel", "shared", "Shared", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
shell.openRequests()
mount(shell) { SettingsList(shell, it) }
rule.onNodeWithText("A saved request").assertIsDisplayed()
rule.onNodeWithText("Try again").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
assertTrue(shell.requests.recoveryFocused)
capture("requests-saved")
}
@Test fun failedPosterReadUsesTheSameMissingArtFallbackAsHome() {
val title = LibraryTitle("missing", "A missing film", "movie", poster = "file:///missing-offline-poster.jpg")
mount(shell("movies")) { LibraryScreen(shell("movies"), LibraryGridState("Movies", listOf(title)), it) }
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().size >= 2 }
capture("library-missing-art")
}
@Test fun dutchLibraryScoreRendersCommaWithoutBreakingThePlate() {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val title = LibraryTitle("dutch", "A film", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
val shell = shell("movies")
mount(shell) { LibraryScreen(shell, LibraryGridState("Movies", listOf(title)), it, ratingsSignedIn = true) }
rule.onNodeWithText("7,8").assertIsDisplayed()
capture("library-dutch-score")
}
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
@Test fun dutchDiscoverHeroScoreRendersComma() {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val title = DiscoverTitle(1, "movie", "A film", ratings = listOf(DiscoverRating("tmdb", 7.8)))
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
val shell = shell("discover")
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell) { DiscoverScreen(state, it) }
rule.onNodeWithText("7,8").assertIsDisplayed()
capture("discover-dutch-score")
}
@Test fun failedDiscoverBackdropUsesTheSharedAmbientFallback() {
val shell = shell("discover")
val title = DiscoverTitle(1, "movie", "A film", backdrop = "file:///missing-offline-backdrop.jpg")
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
mount(shell) { DiscoverScreen(state, it) }
rule.waitUntil(5000) { rule.onAllNodesWithTag("discover-missing-art").fetchSemanticsNodes().isNotEmpty() }
rule.onNodeWithTag("discover-missing-art").assertIsDisplayed()
capture("discover-missing-art")
}
}

View file

@ -0,0 +1,117 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Failed refresh, loading focus and identity changes must never discard or leak a saved view. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class RecoveryStateTest {
@Test fun failedDiscoverRefreshKeepsTitlesAndFocusButDisablesWritesUntilRecovery() = runTest {
val title = DiscoverTitle(1, "movie", "Saved film")
val initial = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
var response = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
var writes = 0
val operations = object : DiscoverOperations {
override suspend fun refresh() = response
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { writes++; return DiscoverRequestResult(RequestPhase.Receipt) }
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
}
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
val state = DiscoverState(shell, initial, operations, backgroundScope, nowMillis = { 1000L })
state.focus(0)
state.refresh()
testScheduler.runCurrent()
assertEquals(listOf(title), state.heroes)
assertEquals(0, state.focusedRow)
assertNotNull(state.snapshot.savedAt)
assertFalse(state.canRequest)
assertEquals(RecoveryMessage.StillUnreachable, state.toast)
state.hide(title)
testScheduler.runCurrent()
assertEquals(0, writes)
val recovery = state.lanes.last()
state.focus(state.lanes.indexOf(recovery) + state.firstShelf)
assertEquals(DiscoverTail.Retry, state.focusedLane?.tail)
response = initial
state.key(ShellKey.Ok)
testScheduler.runCurrent()
assertTrue(state.canRequest)
assertNull(state.snapshot.savedAt)
assertNull(state.toast)
}
@Test fun revokedDiscoverIdentityDropsSavedTitlesInsteadOfPaintingThemWhileSignedOut() = runTest {
val initial = discoverTestFixture().snapshot("justin")
val operations = RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn), "Justin")
val state = DiscoverState(ShellState(fixturePeople(), "justin"), initial, operations, backgroundScope)
state.refresh()
testScheduler.runCurrent()
assertFalse(state.isSignedIn)
assertTrue(state.heroes.isEmpty())
assertTrue(state.lanes.isEmpty())
}
@Test fun DownCannotLeaveNavForSkeletonOnlyHomeLibraryOrDiscover() = runTest {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
val loading = LibraryGridState("Movies", emptyList()).apply { status = LibraryStatus.Loading }
val home = HomeFocus(shell, buildHomeRows(HomeLibrary(), emptyList(), loading = true)) {}
val discover = DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), RecordingDiscoverOperations(DiscoverSnapshot(), "Justin"), backgroundScope)
for (content in listOf(loading, home, discover)) {
shell.contentFocus = content
shell.focusNav(shell.currentTab)
shell.key(ShellKey.Down)
assertTrue("Skeleton-only content must retain nav focus", shell.navFocused)
}
loading.status = LibraryStatus.Unavailable
shell.contentFocus = loading
shell.key(ShellKey.Down)
assertFalse("A recovery button is reachable once loading fails", shell.navFocused)
}
@Test fun requestsKeepSavedRowsOnFailureAndRecoveryNeverOpensTheLastTitle() {
var retried = false
var opened = false
val shell = ShellState(fixturePeople(), "justin") {
if (it == ShellAction.RetryRequests) retried = true
if (it is ShellAction.RequestTitle) opened = true
}.apply { pickerVisible = false; enterDestination("settings") }
val request = ComingInRequest("a", "movie", 1, "Saved film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
shell.openRequests()
assertEquals(listOf(request), shell.requests.snapshot.items)
assertEquals("20:42", shell.navSavedAt)
shell.key(ShellKey.Down)
assertTrue(shell.requests.recoveryFocused)
shell.key(ShellKey.Ok)
assertTrue(retried)
assertFalse(opened)
shell.key(ShellKey.Up)
assertFalse(shell.requests.recoveryFocused)
shell.key(ShellKey.Back)
assertFalse(shell.requestsVisible)
}
@Test fun switchingPeopleNeverShowsAnotherPersonsCachedRequestsOrSavedChip() {
val shell = ShellState(fixturePeople(), "justin")
val item = ComingInRequest("private", "movie", 1, "Person A film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
val original = shell.requests
shell.pickerIndex = 1
shell.key(ShellKey.Ok)
shell.openRequests()
assertEquals("mindel", shell.person.key)
assertTrue(shell.requests.snapshot.items.isEmpty())
assertNull(shell.navSavedAt)
assertNotSame(original, shell.requests)
}
}

View file

@ -304,6 +304,10 @@
--home-toastPadding: 28.0px; --home-toastPadding: 28.0px;
--home-toastTop: 150.0px; --home-toastTop: 150.0px;
--home-toastWidth: 760.0px; --home-toastWidth: 760.0px;
--home-toastPaddingY: 22.0px;
--home-toastGap: 14.0px;
--home-toastShadowBlur: 60.0px;
--home-toastShadowY: 20.0px;
--home-cardWashInset: 30.0px; --home-cardWashInset: 30.0px;
--home-heroWashInset: 80.0px; --home-heroWashInset: 80.0px;
--home-noteTop: 14.0px; --home-noteTop: 14.0px;

View file

@ -1,7 +1,7 @@
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json 1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
a934faf6ce6972371455b51c18be7278e4f3d9daf6e1c32e230542a09ddb94c6 generate_tokens.py a934faf6ce6972371455b51c18be7278e4f3d9daf6e1c32e230542a09ddb94c6 generate_tokens.py
9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
1dcf1136efaf0eb0cda4ab93266abe32d68325c63d7a967d7edb66f6ccd64f29 shell-tokens.json 1dcf1136efaf0eb0cda4ab93266abe32d68325c63d7a967d7edb66f6ccd64f29 shell-tokens.json
cc7ad4add4a0e8b2eb3286ac25c75c3ba18189f5c076101f562d00ed141d4d83 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 9bf7d34cd27f4156d7adc10911f0b28a5d77e41a1adc1618ee0dd7b302c86ca3 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
3b675f3e71e9e1e0e5713b40b8600d5a7fcb5b19f5c4c447f778ec8f1ccfa791 tokens.css 22c2a522d892e0751c8bc1bad7364cc3c2e19d3b73cc523dbaa8087d54727b6c tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json