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..864c1743 --- /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()) + 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()) + 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()) + 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..69fbe6ba --- /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()) + } + + @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/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt index d14f4a95..8316538b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -4,6 +4,8 @@ import android.app.Application import androidx.activity.ComponentActivity import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey @@ -21,6 +23,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.RecoveryMessage +import org.justinvisser.wolfskin.design.TitleConnection import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.TitleItem import org.justinvisser.wolfskin.design.TitlePageData @@ -122,5 +126,40 @@ class TitleAsyncFocusTest { assertFalse(shell.navFocused) } + @Test fun failedRetryKeepsThePersonsSavedTitleAndShowsSharedRecovery() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000014") + val reads = java.util.concurrent.atomic.AtomicInteger() + val saved = TitlePageData(TitleItem(id.toString(), "Saved title", "movie"), + connection = TitleConnection.Offline, savedAt = "20:42") + val facade = mockk() + coEvery { facade.readSaved(id, any()) } returns saved + coEvery { facade.read(id, any(), any()) } coAnswers { + reads.incrementAndGet() + throw java.io.IOException("offline") + } + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true)) + val viewer = ShellPerson("justin", "Justin", "J", false) + val route = titleRoute(id.toString()) + val shell = ShellState(listOf(viewer), viewer.key).apply { + pickerVisible = false; pickerLaunch = false; enterDestination(route) + } + rule.setContent { WolfskinTheme { + TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel) + } } + rule.waitUntil(timeoutMillis = 5000) { reads.get() == 1 && viewModel.title?.toast != null } + rule.waitForIdle() + assertEquals("20:42", shell.navSavedAt) + assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id) + rule.runOnIdle { viewModel.title!!.toast = null } + press(Key.DirectionCenter) + rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null } + rule.waitForIdle() + assertEquals("Saved title", viewModel.title!!.data.item.name) + assertEquals("20:42", shell.navSavedAt) + assertEquals(RecoveryMessage.StillUnreachable, viewModel.title!!.toast) + assertFalse(shell.navFocused) + rule.onNodeWithTag("recovery-toast").assertIsDisplayed() + } + private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index 296041b3..1a672f67 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -141,6 +142,28 @@ class TitleReadFacadeTest { assertEquals("A real file", saved.credits.single().name) } + @Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest { + val userA = mockk { every { id } returns firstId } + val userB = mockk { every { id } returns secondId } + val serverA = mockk { every { id } returns seriesId } + val serverB = mockk { every { id } returns seasonId } + every { repository.currentUser } returns userA + every { repository.currentServer } returns serverA + coEvery { library.getItem(firstId) } returns successResponse( + BaseItemDto(id = firstId, type = BaseItemKind.PERSON, name = "Saved actor", overview = "Private biography")) + coEvery { items.getItems(any()) } returns successQueryResult(listOf(file(secondId))) + facade.readPerson(firstId, 0) + every { repository.currentUser } returns userB + assertNull(facade.readPersonSaved(firstId)) + every { repository.currentUser } returns userA + every { repository.currentServer } returns serverB + assertNull(facade.readPersonSaved(firstId)) + every { repository.currentServer } returns serverA + val saved = requireNotNull(facade.readPersonSaved(firstId)) + assertEquals("Private biography", saved.person.biography) + assertEquals(secondId.toString(), saved.credits.single().id) + } + @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest { val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1) 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 2f68c192..e2ac7a91 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 @@ -42,27 +39,43 @@ class WolfskinHomeViewModel @Inject constructor( private set var toast by mutableStateOf(null) private set + private var sourceScope: String? = null + private var loadRevision = 0L suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } + 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 val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut)) if (person != user.name.orEmpty().lowercase()) { snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, 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 if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation)) try { val library = withContext(Dispatchers.IO) { home.read(user.id) } val savedAt = System.currentTimeMillis() - withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) } + withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Local storage failure must not turn a reachable library into an outage. */ } } + if (revision != loadRevision) return snapshot = HomeSnapshot(buildHomeRows(library, continuation)) } catch (exception: Exception) { if (exception is CancellationException) throw exception + if (revision != loadRevision) return 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.") + if (retry) showToast(RecoveryMessage.StillUnreachable) } } @@ -74,16 +87,16 @@ class WolfskinHomeViewModel @Inject constructor( is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(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.") + 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) } } } @@ -126,8 +139,6 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) } Box(modifier) { HomeScreen(shell, model.snapshot, 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/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 2223c3ee..913f51e7 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.justinvisser.wolfskin.design.PersonState import org.justinvisser.wolfskin.design.PersonSurface +import org.justinvisser.wolfskin.design.RecoveryMessage import org.justinvisser.wolfskin.design.ShellPerson import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.TitleAction @@ -102,7 +103,7 @@ class WolfskinTitleViewModel @Inject constructor( } } - private suspend fun readTitle(id: String) { + private suspend fun readTitle(id: String, retry: Boolean = false) { val uuid = id.toUuidOrNull() ?: return val current = title ?: return try { @@ -113,12 +114,12 @@ class WolfskinTitleViewModel @Inject constructor( if (exception is CancellationException) throw exception if (current.data.status == TitleReadStatus.Ready) { if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() - current.toast = "Still can’t reach it. The last saved view stays on screen." } else current.update(current.data.copy(status = TitleReadStatus.Unavailable)) + if (current.data.status == TitleReadStatus.Ready || retry) current.toast = RecoveryMessage.StillUnreachable } } - private suspend fun readPerson(id: String) { + private suspend fun readPerson(id: String, retry: Boolean = false) { if (personReadPending || personReadFinished) return val uuid = id.toUuidOrNull() ?: return val owner = viewer.key @@ -137,8 +138,11 @@ class WolfskinTitleViewModel @Inject constructor( } catch (exception: Exception) { if (exception is CancellationException) throw exception if (owner == viewer.key) { - person?.toast = "Still can’t reach it. The last saved view stays on screen." - if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) + person?.toast = RecoveryMessage.StillUnreachable + if (person == null) { + title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) + if (retry) title?.toast = RecoveryMessage.StillUnreachable + } } } finally { if (owner == viewer.key) personReadPending = false } } @@ -175,9 +179,9 @@ class WolfskinTitleViewModel @Inject constructor( } TitleAction.Retry -> viewModelScope.launch { if (fixture == null) { - if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/'))) else readTitle(Uri.decode(route.substringAfter('/'))) + if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true) } - else title?.toast = "Still can’t reach it. The last saved view stays on screen." + else title?.toast = RecoveryMessage.StillUnreachable } is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr." is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply() @@ -192,7 +196,7 @@ class WolfskinTitleViewModel @Inject constructor( navigation.navigateTo(Destination.Playback(id, position)) } catch (exception: Exception) { if (exception is CancellationException) throw exception - title?.toast = "Can’t reach Jellyfin to play. Check the network and try again." + title?.toast = RecoveryMessage.PlaybackUnavailable } } } @@ -231,11 +235,11 @@ class WolfskinTitleViewModel @Inject constructor( @Composable fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel()) { LaunchedEffect(route, shell.person.key) { viewModel.load(route, shell.person, preferences) } - val title = viewModel.title + val title = viewModel.title?.takeIf { it.person.key == shell.person.key } LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } - val person = viewModel.person + val person = viewModel.person.takeIf { title != null } BackHandler(enabled = title != null) { title?.back(shell) } 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 7233be5d..63e29f9b 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -41,6 +41,7 @@ import javax.inject.Singleton import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import kotlinx.coroutines.withContext import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.SettingsList @@ -102,10 +103,17 @@ class WolfskinShellViewModel @Inject constructor( fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } /** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ - fun refreshRequests() { + fun refreshRequests() = refreshRequests(retry = false) + + private fun refreshRequests(retry: Boolean) { + val shell = session.state ?: return + val target = shell.requests viewModelScope.launch { val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } - session.state?.requests?.update(snapshot) + target.update(snapshot) + if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) { + shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable + } } } @@ -116,7 +124,7 @@ class WolfskinShellViewModel @Inject constructor( is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server, user) ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) } - ShellAction.RetryRequests -> refreshRequests() + ShellAction.RetryRequests -> refreshRequests(retry = true) is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}"))) } } @@ -205,14 +213,15 @@ fun WolfskinApp( BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { - if (destination is Destination.MediaItem && state.person.key != viewModel.signedInPerson) { + val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null + if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) { ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) } } else TitleDestination(route, state, preferences) } else { 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/") -> 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 953d967f..0a9a56e5 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinLibrary.kt @@ -82,6 +82,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/docker/README.md b/docker/README.md index 3f15d0c1..cc822873 100644 --- a/docker/README.md +++ b/docker/README.md @@ -205,8 +205,8 @@ 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. @@ -401,6 +401,56 @@ The shorter 1/5/6/2 title-entry replays remain as focused regressions. SDK cache exact-episode, track-index, identity, locale-layout and trailer-handler tests run offline with in-process service replies and local art. +## 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. + +Production Conductarr TV sign-in/provider wiring remains outside these state +components: release Discover is signed out and release Requests is unavailable. +The generated client's identity-scoped disk cache is the Conductarr cache boundary; +no Seerr fallback, live-test server, handwritten HTTP or offline request queue is added. +Title and person surfaces publish saved timestamps through the same per-person +`RememberedView` partition and nav Saved indicator. Their timed notices render +the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage` +without transport details. A selected-person mismatch never renders the former +person's title/person projection. Title and performer disk copies remain isolated +by both server and authenticated user; native retry and cache-isolation regressions +exercise these boundaries without a live service. ## Toolchain and disk use 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/ComingInSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ComingInSheet.kt index 87c358a2..40ed8f3c 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 @@ -81,27 +81,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 86d6c5c3..1325cc0f 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 } @@ -97,9 +104,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/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt index 58302e28..4f1d281a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp import kotlin.math.roundToInt internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier { @@ -41,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier { /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ @Composable -internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = 0.dp, content: @Composable () -> Unit) { +internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { val exact = remember { CssColumnHeight() } Layout(content, modifier.then(exact)) { children, constraints -> val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } 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/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 68ac7436..548e04d6 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 @@ -46,11 +46,12 @@ 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) - LaunchedEffect(focusedTitle?.key, state.contentFocused) { + LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt) { state.plateKey = null - if (state.contentFocused && focusedTitle != null) { + if (state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings)) { if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong()) state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key } @@ -68,11 +69,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 23b11148..b157e831 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 @@ -19,9 +19,11 @@ class DiscoverState( private val onPush: (String) -> Unit = {}, private val onPop: () -> Unit = {}, private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, + 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] @@ -59,12 +61,14 @@ class DiscoverState( private val dismissalVersions = mutableMapOf() private var rootFilter = "all" + 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 hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest + val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null + 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() @@ -127,8 +131,9 @@ class DiscoverState( grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings, onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = { scope.launch { - snapshot = operations.refresh(); revision++ + reload() 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 } @@ -140,6 +145,7 @@ 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 } } collection = grid @@ -185,7 +191,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 } @@ -254,8 +260,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 } } @@ -326,12 +332,25 @@ class DiscoverState( } } - 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/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index 9be8b1f1..bd36f97f 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 @@ -104,6 +104,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 612f2232..27374461 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 @@ -59,3 +59,4 @@ fun LibraryPoster( } } + 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 42cede9a..5394ddf0 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 @@ -76,6 +76,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) } @@ -169,7 +170,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 36c79b2c..9042e3fe 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 @@ -33,7 +33,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp 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 import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V @@ -57,7 +56,7 @@ fun navBoxes(tabs: List): List { /** There is one blob, including when focus wraps around the nav avatar. */ @Composable -fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) { +fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } val labelFade = remember { tween(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } val index = if (state.navFocused) state.navIndex else state.currentTab @@ -101,7 +100,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, } } } - (savedAt ?: state.view.savedAt)?.let { time -> + state.navSavedAt?.let { time -> Row(Modifier.align(Alignment.TopEnd) .offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 0ea4e763..011a1d70 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -86,11 +86,13 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List, p /** Portrait, biography and the full continuous grid use the shell's remembered frame. */ @Composable fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { + val view = shell.view + LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt } val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } LaunchedEffect(state) { shell.navFocused = false } Box(modifier.fillMaxSize().background(C.bg)) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { - ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) } + ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) } } if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } } 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/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index 58aa4c95..3ea05c55 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 } @@ -59,7 +60,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) @@ -74,6 +77,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 = @@ -118,7 +122,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() } @@ -184,7 +188,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 f3aa275f..5eca15da 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 @@ -56,13 +56,19 @@ fun ShellSurface( modifier: Modifier = Modifier, handlesContentKeys: Boolean = true, settingsOpenRequests: Int? = null, - savedAt: String? = null, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, ) { 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() @@ -86,10 +92,11 @@ fun ShellSurface( WhosWatching(state) } else { if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) - NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt) + NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) if (state.requestsVisible) ComingInSheet(state) overlay() } + state.toast?.let { RecoveryToast(it) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt index c556bc4e..b21c4dcb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TextLineBox.kt @@ -4,8 +4,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp /** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */ -fun Modifier.exactLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier = +fun Modifier.exactLineBox(style: TextStyle, top: Dp = WolfskinTokens.Shell.zero, bottom: Dp = WolfskinTokens.Shell.zero): Modifier = if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index e725f6db..85f847f5 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import kotlinx.coroutines.delay import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape @@ -52,6 +51,8 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** The shared shell remains authoritative; title sheets overlay it and trap all input. */ @Composable fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { + val view = shell.view + LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> // No content target exists during an initial load; nav Down must not enter a skeleton. if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false @@ -66,7 +67,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi }) { val focusOwner = remember(shell) { { shell.navFocused = false } } androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { - ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) } + ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) } } if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell) if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } @@ -79,14 +80,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism LaunchedEffect(message) { if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } } - if (message != null) { - Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth) - .background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop), - horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - Glyph("alert", C.warn, Modifier.size(L.buttonIcon)) - Text(message, color = C.fg, style = WolfskinTokens.Type.body) - } - } + if (message != null) RecoveryToast(message, modifier) } /** One keyed vertical frame renders the exact header, season, cast and related order. */ 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 a3bc1acf..9cf06f03 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 @@ -590,6 +590,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 123e0e89..4d3f3488 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -420,6 +420,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 2d6c901a..ba5f6201 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,8 @@ 1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py -9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json +25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -ccdd0134fc2ea069c3d8bd7717657d53544fe8393696edc282bb40e142069e97 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json -8b351ce603c6ca39c72d41e012831ef84c082f51132b53dd45b3068ad2dc68b2 tokens.css +3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json