diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt index 608728aa..4bb35f9f 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt @@ -63,6 +63,7 @@ class ConductarrSurfaceTest { rule.mainClock.advanceTimeBy(500) rule.waitUntil(10_000) { rule.mainClock.advanceTimeByFrame() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" } } rule.waitForIdle() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt new file mode 100644 index 00000000..37b665ce --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeRecoveryTest.kt @@ -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() + 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() + val response = CompletableDeferred() + val facade = mockk() + 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() + val cache = HomeLibraryCache(context) + cache.write("${server.id}:${personA.id}", library, 1000L) + val startedA = CompletableDeferred() + val responseA = CompletableDeferred() + 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() + coEvery { facade.read(personA.id) } returns library + val cache = mockk() + 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) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt new file mode 100644 index 00000000..92c0a248 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PlayerHandoffTest.kt @@ -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() + 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() + 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() + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt index b0b918ae..00420a95 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrCatalogue.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.lastOrNull import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock 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.Page import org.justinvisser.wolfskin.design.* @@ -22,7 +23,7 @@ internal data class CatalogueLaneQuery( ) /** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */ -internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List) { +internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List, private val audiences: () -> List) { private data class SourcePage(val number: Int, val last: Boolean, val titles: List, val failed: Boolean, val total: Int, val duplicateOnly: Boolean) private val sources = mutableMapOf() private val visited = mutableSetOf() @@ -44,7 +45,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que } suspend fun more(id: String): List = 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() } @@ -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 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 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 } 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 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(), @@ -78,7 +79,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore pages.any { !it.last } -> DiscoverTail.SeeAll else -> DiscoverTail.None - }, pending = pending) + }, pending = pending, hasMore = pages.any { !it.last }) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt index b4a819a4..1718dc44 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProjection.kt @@ -14,13 +14,15 @@ internal fun Candidate.discoverTitle(genres: Map): DiscoverTitle = catalogueImage(posterPath), catalogueImage(backdropPath), score = score, ) -internal fun Title.discoverTitle(): DiscoverTitle = DiscoverTitle( +internal fun Title.discoverTitle(audiences: List): DiscoverTitle = DiscoverTitle( tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview, 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 { 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)) 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 -> @@ -28,8 +30,9 @@ internal fun DiscoverSnapshot.homeRows(): List { } } return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane -> - 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 = items.size, allCards = items.map { it.homeTitle() }) } + if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true) + 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) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt index 1713b965..0178dac2 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -19,6 +19,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider private var catalogue: ConductarrCatalogue? = null private var options = emptyList() private val services = mutableMapOf() + @Volatile private var libraryAudiences = emptyList() fun discover(profile: String, force: Boolean = false): Flow = flow { val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY) 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() } this@ConductarrProvider.options = 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 genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } 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 { 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) } @@ -75,21 +76,28 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider val reads = SnapshotReads(client, trigger) val feed = reads.read(TvRoutes.activity()) if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue + val artReads = SnapshotReads(client, trigger) val items = coroutineScope { feed?.items.orEmpty().map { item -> async { 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) } }.map { it.await() } } emit(ComingInSnapshot(items, - if (feed == null) ComingInStatus.Unavailable else ComingInStatus.Ready, - partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && feed != null)) + if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready, + 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 audiences(): List = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() + suspend fun audiences(): List { + libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() + return libraryAudiences + } + + fun holders(title: Title): Set = title.discoverTitle(libraryAudiences).heldBy } /** A batch tracks source failure without replacing saved bodies with empty success. */ diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt index 6e15b7b0..cf67b13b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrRatings.kt @@ -9,15 +9,16 @@ import org.justinvisser.wolfskin.design.ScreenRatings /** Failed reads are not cached as absence; saved observations survive offline. */ class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings { - override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List? { + override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List? { 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.Failure -> result.saved?.value?.ratings?.screenRatings(type) ?: return null null -> return null } 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 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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index 7db7870c..d418cee1 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -28,7 +28,7 @@ internal fun rememberDiscoverState( val scope = rememberCoroutineScope() val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf>() } 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() } } val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) { @@ -59,7 +59,7 @@ internal fun rememberDiscoverState( fixtureState = loaded 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) { - 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) { @@ -75,12 +75,16 @@ internal fun rememberDiscoverState( if (service == null) { state.updateLanes(identity.provider.readAhead(lane)) 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)) } } else { val index = state.lanes.indexOfFirst { it.id == lane } 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() } } @@ -93,7 +97,7 @@ internal fun rememberDiscoverState( 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( 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))) } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index 9a9c04ac..0d18bfee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -1,15 +1,12 @@ package org.justinvisser.wolfskin.tv import android.content.Context -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.services.NavigationManager @@ -40,11 +37,16 @@ class WolfskinHomeViewModel @Inject constructor( private val conductarr: ConductarrSession, ) : ViewModel() { val conductarrState get() = conductarr.identity + val audiences get() = home.audiences var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)) private set var toast by mutableStateOf(null) private set 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) { catalogueJob?.cancel() @@ -53,65 +55,110 @@ class WolfskinHomeViewModel @Inject constructor( conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) conductarr.authenticate() } - val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut)) - if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) { - snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) + loadLibrary(server, user, person, retry) + } + + /** 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 } 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() - 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 { val library = withContext(Dispatchers.IO) { home.read(user.id) } 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 - withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) } - snapshot = HomeSnapshot(buildHomeRows(library, continuation)) + publish() } catch (exception: 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))) - else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) - if (retry) showToast("Still can’t reach it. The last saved view stays on screen.") - } - val identity = conductarr.identity.value - 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) - } + if (revision != loadRevision) return + libraryAvailable = false + librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) } + publish() + if (retry) showToast(RecoveryMessage.StillUnreachable) } } + 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) { when (action) { HomeAction.Discover -> openRoot(shell, "discover") 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.Play -> viewModelScope.launch { - if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) { - showToast("Can’t reach Jellyfin to play. Check the network and try again.") + val selectedId = shell.person.jellyfinUserId + 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 } try { 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)) } catch (exception: 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) { shell.enterDestination(route) shell.focusNav(shell.tabs.indexOf(route)) @@ -147,12 +194,15 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle( @Composable fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) { 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) { 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) } - model.toast?.let { message -> - 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)) - } + model.toast?.let { RecoveryToast(it) } } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt index 50397a67..2c88cc9b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/RequestsRefresh.kt @@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens @Composable internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) { val owner = LocalLifecycleOwner.current - LaunchedEffect(state, owner) { + LaunchedEffect(state, state.person.key, owner) { owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { refreshRequestsWhileStarted(state, refresh) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index d927b035..fe6a86bb 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -137,18 +137,26 @@ class WolfskinShellViewModel @Inject constructor( fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } - /** Requests are person-scoped; a failed refresh retains the provider's saved observations. */ - fun refreshRequests() { + /** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */ + 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 { if (ConductarrPreference.fixtures) { - session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot }) - } else { - val identity = conductarr.identity.value - if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) - else identity.provider.requests().collect { snapshot -> - if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot) + target.update(withContext(Dispatchers.IO) { requestsSnapshot }) + } else if (identity?.signedIn != true) { + target.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) + } else identity.provider.requests().collect { snapshot -> + if (conductarr.identity.value?.client === identity.client) { + 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.RetryRequests -> viewModelScope.launch { 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}")) } @@ -267,7 +275,7 @@ fun WolfskinApp( ShellSurface(state, handlesContentKeys = upstream == null, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> 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) destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") -> diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt index 3f51a1ba..e0f9bd3d 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -51,7 +51,8 @@ fun WolfskinLibraryContent( modifier: Modifier = Modifier, ) { val view = shell.rememberedView(route) - val fixture by produceState(null, context) { + val fixture by produceState(null, context, ConductarrPreference.fixtures) { + value = null if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) { context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } } @@ -62,7 +63,7 @@ fun WolfskinLibraryContent( return } 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(), onTitle = { navigation.navigateTo(Destination.Wolfskin("library-title/${it.id}")) }, onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) }, @@ -73,7 +74,7 @@ fun WolfskinLibraryContent( ShellPlaceholder("See all list unavailable", modifier) 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)) { val wasLoading = grid.status == LibraryStatus.Loading if (fixture != null) { @@ -84,6 +85,8 @@ fun WolfskinLibraryContent( grid.source = snapshot.items 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.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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index 47bf457d..1867ae60 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -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. */ 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 { 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 diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt index 7b12c908..2f176581 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/LibraryReadFacade.kt @@ -1,5 +1,9 @@ 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 org.jellyfin.sdk.api.client.ApiClient 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. */ @javax.inject.Singleton class LibraryReadFacade @Inject constructor(private val api: ApiClient) { - var audiences: List? = null + var audiences by mutableStateOf?>(null) fun destination(name: String, collection: CollectionType?): String? { audiences?.let { known -> diff --git a/docker/README.md b/docker/README.md index 7bf29dcb..3b662304 100644 --- a/docker/README.md +++ b/docker/README.md @@ -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. 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 -prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources -do not package it. Saved Jellyfin identities use existing upstream services. The -shell makes no Conductarr calls and does not add Conductarr sign-in. +Saved Jellyfin identities use the existing upstream session. Conductarr supplies +household labels, requester capabilities and library audiences after sign-in. +The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used +only when the Advanced debug fixture switch is enabled; release excludes it. Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous 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. The debug-only fixture and local art live in -`app/src/debug/assets/wolfskin-discover/`. Release builds remain on the explicit -Conductarr sign-in state. The fixture host cannot construct a network client: -debug request outcomes and dismissals stay in memory. The real mutation adapter -accepts an injected `ConductarrClient` and uses only its typed routes; it is not -wired to a live server. Unknown request outcomes force a fresh title-requests read -instead of blindly submitting again, including after closing a sheet or choosing -the same uncertain recipient from another chooser. Whole-series requests require every season. +`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a +network-free fixture host; real providers are the default in debug and release. +The mutation adapter accepts the selected person's `ConductarrClient` and uses +only typed routes. Request submission first reads title receipts. Unknown outcomes +are checked, never blindly re-sent, including after closing a sheet or choosing +the same uncertain recipient again. Whole-series requests require season metadata. Discover hero and Restore text use explicit per-line CSS allocations so native 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 episode and checks Jellyfin before opening upstream's player. No season, 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 -library content, before mounting Home's source reads. +An unresolved person switch guards Home and both owned/upstream library content +before mounting any source read with the prior person's Jellyfin session. Debug variants additionally package the locked fixture and local artwork under `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. -The fixture provider supplies the approved household data and Home states without -network reads in tests. Conductarr sign-in is not implemented here: real signed-out -Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is -used. Catalogue data and ratings in debug captures are fixture-backed. +The explicit fixture provider supplies approved household data and Home states +without network reads in tests. Real signed-out Home hides every rating and shows +"Discover needs sign-in"; signed-in Home reads the Conductarr continuation and +ratings-only endpoint. No Seerr fallback is used. Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`; 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 “by Shared”, never “by you”. -The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and -its eight local posters, projected from the locked prototype fixture. Six stage -rows feed the running debug shell; the Shared and focused-rating records feed -native acceptance states. The identical projection is in design-test resources. -No “sample” or “example stages” pill is drawn. Release builds package none of -these assets and show Requests unavailable until Conductarr sign-in supplies -the generated-client data path. There is no Seerr fallback or handwritten HTTP. +The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and +its eight local posters. Shared and focused-rating records also feed the native +acceptance states; the identical projection is in design-test resources. +No “sample” or “example stages” pill is drawn. The default provider instead maps +Conductarr household activity, authenticated actors and display artwork into +the same sheet. Release excludes all fixture assets. There is no Seerr fallback. Foreground refreshes once; an open sheet or unsettled requests refresh every 30 seconds while started. The immutable debug projection is loaded once, not 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, density 2.0. Title selection currently uses an explicitly labelled `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 -deployment. +title pages, release and device deployment remain outside the Requests component. ## 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 `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 -quiet refresh; a failed refresh keeps it. Production ratings remain hidden until -Conductarr sign-in exists. +quiet refresh; a failed refresh keeps it. Production ratings remain hidden before +Conductarr sign-in and use the shared ratings-only provider afterwards. Home/Discover shelves open a full list through `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 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=""`, 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 `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 1f82d724..a574a31d 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -282,6 +282,11 @@ "dp": 380.0, "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": { "$type": "dimension", "px": 30, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt index 3f2b0da1..bd990e32 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInRatings.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.foundation.background import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.tv.material3.Text import java.util.Locale @@ -31,6 +32,8 @@ fun ComingInRatings(ratings: List, color: Color) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) { val mark = when (rating.mark) { "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 "upright" -> R.drawable.coming_in_rating_upright "spilled" -> R.drawable.coming_in_rating_spilled @@ -40,7 +43,8 @@ fun ComingInRatings(ratings: List, color: Color) { val wide = rating.mark == "imdb" || rating.mark == "tmdb" Image(painterResource(mark), contentDescription = rating.mark, 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) Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 691541e1..23c5c596 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt @@ -39,10 +39,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) { val state = shell.requests val data = state.snapshot val ratingsReader = LocalScreenRatings.current - LaunchedEffect(state.focusedId, data.signedIn, data.status) { + LaunchedEffect(state, state.focusedId, data.signedIn, data.status) { if (ratingsReader == null) state.awaitFocusedRatings() 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 -> val mapped = rating.homeRating() 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) 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) val start = state.windowStart val visibleRows = remember(data, start, state.windowSize) { 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, 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, 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 -private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) { - Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) } +private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) { + Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) } } @Composable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt index 42d4c93e..f8aa27df 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInState.kt @@ -51,6 +51,7 @@ data class ComingInSnapshot( val status: ComingInStatus = ComingInStatus.Unavailable, val partial: Boolean = false, val signedIn: Boolean = false, + val savedAt: String? = null, ) { val ordered = items.sortedBy { it.stage.rank } val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } @@ -66,6 +67,8 @@ class ComingInState { private set var ratingsVisible by mutableStateOf(false) private set + var recoveryFocused by mutableStateOf(false) + private set private val cachedRatings = mutableSetOf() val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0) 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)) fun update(value: ComingInSnapshot) { - snapshot = value - if (!value.signedIn) cachedRatings.clear() - if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id) - ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings + snapshot = if (value.status != ComingInStatus.Ready && value.signedIn && value.items.isEmpty() && snapshot.signedIn) { + snapshot.copy(status = value.status) + } else value + 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?) { focusedId = id + recoveryFocused = false expanded = false 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. */ fun key(key: ShellKey): Boolean { + if (recoveryFocused) { + if (key == ShellKey.Up) recoveryFocused = false + return false + } when (key) { 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.Left -> expanded = false ShellKey.Ok -> return focused != null diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt index e91789d9..92e63ad9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverArtwork.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import coil3.compose.AsyncImage import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C 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. */ @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 val bitmap = LocalDiscoverArt.current?.invoke(path) val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) } @@ -57,18 +58,21 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, model = request, imageLoader = WolfskinImages.loader(context), 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. */ @Composable 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)) { - 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) .wrapContentSize(Alignment.TopStart, unbounded = true) .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( WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth, 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( radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity), offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY))) - .clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true) - } else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize()) + .clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true, onError = { failed.value = true }) + } else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize(), onError = { failed.value = true }) 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) onDrawBehind { drawRect(scrim) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt index 0c587c58..cd66673f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverLaneProjection.kt @@ -29,9 +29,15 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List 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()) - if (items.isNotEmpty() || lane.pending || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) { - result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size, - 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) + val paged = lane.hasMore + val tail = when { + 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) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt index 080ba943..92573f16 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverModel.kt @@ -60,6 +60,7 @@ data class DiscoverLane( val hidden: Boolean = false, val fullTitles: List = titles, val pending: Boolean = false, + val hasMore: Boolean = false, ) /** Caller identity is deliberately absent: the authenticated client supplies the actor. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index 83cacb79..caf98275 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -67,11 +67,7 @@ fun DiscoverOverlay(state: DiscoverState) { } } } - state.toast?.let { text -> - 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) - } - } + state.toast?.let { RecoveryToast(it) } } @Composable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 1c2525a9..0d836c4f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -1,6 +1,9 @@ // SPDX-License-Identifier: GPL-2.0-only package org.justinvisser.wolfskin.design +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -46,21 +49,22 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { shell.contentFocus = focus 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 } } val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) 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 - if (state.contentFocused && focusedTitle != null) { - if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) - if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null)?.let { state.updateRatings(focusedTitle, it) } - state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key - } - } - LaunchedEffect(state.hero?.key, state.title?.key, state.isSignedIn) { - if (state.isSignedIn && ratingsReader != null) { - val title = state.title ?: state.hero - if (title != null) ratingsReader.read(title.tmdb, title.type, null, null)?.let { state.updateRatings(title, it) } + coroutineScope { + if (state.isSignedIn && ratingsReader != null) launch { + val prominent = state.title ?: state.hero + 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 { + if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) + if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(focusedTitle, it) } + state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key + } } } if (state.title != null) { DiscoverTitleDestination(state, modifier); return } @@ -76,11 +80,11 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { val density = LocalDensity.current val list = shell.view.listState 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 { add(if (state.hasHero) L.row_phero else L.row_lhead) - add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero) - if (!state.canRequest) add(L.row_state) else { + add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero) + if (!state.hasCatalogue) add(L.row_state) else { 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) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 05f98b44..482687b9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -21,9 +21,11 @@ class DiscoverState( private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, private val requesters: List = shell.people, private val onMore: (suspend (String) -> List?)? = null, + private val nowMillis: () -> Long = System::currentTimeMillis, ) : ShellContentFocus { var snapshot by mutableStateOf(initial) private set + private var savedTime = initial.savedAt ?: formatSavedTime() private var serviceFilter by mutableStateOf("movie") var filter: String get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0] @@ -60,12 +62,15 @@ class DiscoverState( private var cachedHeroes = emptyList() private val dismissalMutex = Mutex() private val dismissalVersions = mutableMapOf() + private val pendingDismissals = mutableMapOf() private var rootFilter = "all" fun updateSnapshot(value: DiscoverSnapshot) { snapshot = value + if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime() hidden.clear() value.hidden.forEach { hidden[it.key] = it } + pendingDismissals.forEach { (key, item) -> if (item == null) hidden.remove(key) else hidden[key] = item } revision++ } @@ -73,8 +78,9 @@ class DiscoverState( fun updateLanes(value: List) { snapshot = snapshot.copy(lanes = value); revision++ } private suspend fun loadLane(id: String) { + if (id == "offline-recovery") { reload(); return } 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) { @@ -94,13 +100,14 @@ class DiscoverState( revision++ } + override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading 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 else -> false } - val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed - val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest + val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null && snapshot.reachable && snapshot.requestsAllowed + val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue val heroes: List get() { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { 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 { loadLane(lane.id) if (collection !== grid) return@launch + grid.savedAt = snapshot.savedAt val fresh = lanes.firstOrNull { it.id == lane.id } if (canRequest && fresh != null) { titles = fresh.fullTitles.associateBy { it.key } @@ -176,9 +184,10 @@ class DiscoverState( } else grid.tail = GridTail.Retry } }).apply { + savedAt = snapshot.savedAt 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) val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable") if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable") @@ -186,7 +195,7 @@ class DiscoverState( collectionCards = LinkedHashMap(titles.size).apply { 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 onPush("discover-see-all/${lane.id}") @@ -231,7 +240,7 @@ class DiscoverState( 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() return true } @@ -289,7 +298,6 @@ class DiscoverState( fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) { 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 previous = requestSheets[item.key to choice.selectedPerson.key] overlay = DiscoverOverlayState.Request( @@ -301,8 +309,8 @@ class DiscoverState( when (val sheet = overlay) { is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) { scope.launch { - snapshot = operations.refresh(); revision++ - sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable + reload() + sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable sheet.state.focus = sheet.state.buttonIndex } } @@ -323,7 +331,7 @@ class DiscoverState( } private fun send(sheet: RequestSheetState, check: Boolean) { - if (!requestsEnabled || (!check && !sheet.canSubmit)) return + if (!canRequest || (!check && !sheet.canSubmit)) return if (!check) { val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key] if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) { @@ -354,8 +362,10 @@ class DiscoverState( val profile = shell.person.key val version = (dismissalVersions[item.key] ?: 0) + 1 dismissalVersions[item.key] = version + pendingDismissals[item.key] = item scope.launch { 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." } } } @@ -367,18 +377,33 @@ class DiscoverState( val profile = shell.person.key val version = (dismissalVersions[item.key] ?: 0) + 1 dismissalVersions[item.key] = version + pendingDismissals[item.key] = null scope.launch { 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." } } } - 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 } } - private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter + fun refresh() { scope.launch { reload() } } - private fun buildLanes(): List = service?.let { - serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } - } ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) + private suspend fun reload() { + val refreshed = operations.refresh() + 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 { + 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index 4ee1fafa..e91b22cd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -9,6 +9,7 @@ class HomeFocus( private val onAction: (HomeAction) -> Unit, ) : ShellContentFocus { 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 } val row: HomeRow? get() = rows.getOrNull(view.row) val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 931c358d..eb487f62 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -69,11 +69,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) { Text("Discover", color = C.fg, style = T.sectionTitle) Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader) 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) - .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)) - } + savedAt?.let { SavedPill(it) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt index 11ea2f3f..669ea07c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRatings.kt @@ -41,12 +41,13 @@ fun HomeRatingsRow(ratings: List, modifier: Modifier = Modifier, fon val resource = when (rating.mark) { "rotten" -> R.drawable.ic_rotten_tomatoes_rotten "upright" -> R.drawable.ic_rt_upright + "popcorn" -> R.drawable.ic_rt_upright "spilled" -> R.drawable.ic_rt_spilled "imdb" -> R.drawable.ic_rating_imdb "tmdb" -> R.drawable.ic_rating_tmdb 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) 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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index edb0b636..84766c0e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -15,6 +15,8 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.tv.material3.Text 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.Home as H 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 focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) } LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) { - if (hero != null && snapshot.conductarr == HomeAvailability.Ready) { - val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) } - if (values != null) { heroRatings = values; cache.save(hero, values) } - } - if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) { - val saved = cache.read(title) - if (saved == null && row.kind != HomeRowKind.Hero) 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)?.map(LibraryRating::homeRating) } - if (values != null) { focusedRatings = values; cache.save(title, values) } + val available = snapshot.conductarr == HomeAvailability.Ready || snapshot.conductarr == HomeAvailability.Saved + coroutineScope { + 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 (values != null) { heroRatings = values; cache.save(hero, values) } + } + if (title != null && row?.kind != HomeRowKind.Hero && !shell.navFocused && !shell.pickerVisible && available) launch { + val saved = cache.read(title) + 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index f2ab0d07..1a1f9a7a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -88,6 +88,7 @@ class LibraryGridState( } 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 + 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" val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index 92c7d2aa..f6435e63 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -17,14 +17,12 @@ import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import androidx.tv.material3.Text -import coil3.compose.AsyncImage import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L @@ -41,11 +39,9 @@ fun LibraryPoster( TitleCard(focused, modifier.testTag("poster-${title.id}")) { val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } } if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - else if (title.poster != null && offlineArt == null) { - val context = LocalContext.current - val request = remember(title.poster, context) { WolfskinImages.request(context, title.poster, (WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt(), true) } - AsyncImage(request, title.title, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - } else MissingLibraryArt(title) + else if (offlineArt == null) TitleArtwork(title.title, title.type, title.poster, + WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize()) + else TitleArtwork(title.title, title.type, null, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize()) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) if (title.progress > 0f && title.progress < 1f) { 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. */ @Composable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index c8eb77fd..9b5bf22e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -82,6 +82,7 @@ fun LibraryScreen( plateKey = focused.id } } + LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt } LaunchedEffect(grid.status) { 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) 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) - savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) } + savedAt?.let { SavedPill(it) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index b316f2df..970feb56 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -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) .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)) Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt new file mode 100644 index 00000000..3e8dd23c --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -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." +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt new file mode 100644 index 00000000..48cea4fa --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SavedPill.kt @@ -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)) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt index 65e8ebe1..e1eaa747 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenRatings.kt @@ -4,14 +4,14 @@ package org.justinvisser.wolfskin.design import androidx.compose.runtime.compositionLocalOf /** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */ -fun interface ScreenRatings { - suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List? +interface ScreenRatings { + suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean = false): List? } val LocalScreenRatings = compositionLocalOf { null } /** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */ 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", ) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 819d0936..3e82a286 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -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. */ interface ShellContentFocus { + val hasContent: Boolean get() = true val cardKey: String? fun key(key: ShellKey): Boolean } @@ -70,7 +71,9 @@ class ShellState( var peopleStatus by mutableStateOf(PeopleStatus.Ready) var recoveryIndex by mutableStateOf(0) var requestsVisible by mutableStateOf(false) - val requests = ComingInState() + var toast by mutableStateOf(null) + private val personRequests = mutableMapOf() + val requests: ComingInState get() = personRequests.getOrPut(person.key) { ComingInState() } private var requestReturnRoute: String? = null var handOff by mutableStateOf(null) var settingsRow by mutableStateOf(0) @@ -85,6 +88,7 @@ class ShellState( val tabs: List get() = if (person.hasAnime) ANIME_TABS else BASE_TABS val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0) 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. */ fun rememberedView(destination: String): RememberedView = @@ -129,7 +133,7 @@ class ShellState( if (requestsVisible) { when { 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() requests.key(key) -> activateRequest() } @@ -195,7 +199,7 @@ class ShellState( when (key) { ShellKey.Left -> navIndex = (navIndex + tabs.size) % (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() else -> Unit } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 945b608d..708d1e3c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -61,6 +61,13 @@ fun ShellSurface( val focus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() } 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) { if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() else contentFocus.requestFocus() @@ -88,6 +95,7 @@ fun ShellSurface( if (state.requestsVisible) ComingInSheet(state) overlay() } + state.toast?.let { RecoveryToast(it) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 546d7e8c..77fc5766 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -443,6 +443,11 @@ object WolfskinTokens { val toastPadding = 14.0.dp val toastTop = 75.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 heroWashInset = 40.0.dp val noteTop = 7.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt new file mode 100644 index 00000000..f61d97e1 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryCaptureTest.kt @@ -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() + 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() + 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") + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt new file mode 100644 index 00000000..d0a98595 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/RecoveryStateTest.kt @@ -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) + } +} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 51ea0d33..4eb2ef47 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -304,6 +304,10 @@ --home-toastPadding: 28.0px; --home-toastTop: 150.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-heroWashInset: 80.0px; --home-noteTop: 14.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 9bdfe655..d3bc9970 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,7 +1,7 @@ 1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json a934faf6ce6972371455b51c18be7278e4f3d9daf6e1c32e230542a09ddb94c6 generate_tokens.py -9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json +25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json 1dcf1136efaf0eb0cda4ab93266abe32d68325c63d7a967d7edb66f6ccd64f29 shell-tokens.json -cc7ad4add4a0e8b2eb3286ac25c75c3ba18189f5c076101f562d00ed141d4d83 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -3b675f3e71e9e1e0e5713b40b8600d5a7fcb5b19f5c4c447f778ec8f1ccfa791 tokens.css +9bf7d34cd27f4156d7adc10911f0b28a5d77e41a1adc1618ee0dd7b302c86ca3 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +22c2a522d892e0751c8bc1bad7364cc3c2e19d3b73cc523dbaa8087d54727b6c tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json