Integrate shared offline recovery and saved-state isolation
All checks were successful
review/design-claude 13 fixes passed Sonnet re-review at cd625b42; merged head 84453040 = main merge + shared RecoveryToast reuse: 33/34 pairs byte-identical, st-048 adopts the shared toast, 830 tests green (firstmate check)

This commit is contained in:
justinzeus 2026-11-05 22:59:45 +01:00
commit 844530404d
36 changed files with 875 additions and 97 deletions

View file

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

View file

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

View file

@ -4,6 +4,8 @@ import android.app.Application
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule 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.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
@ -21,6 +23,8 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson 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.ShellState
import org.justinvisser.wolfskin.design.TitleItem import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.TitlePageData
@ -122,5 +126,40 @@ class TitleAsyncFocusTest {
assertFalse(shell.navFocused) 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<TitleReadFacade>()
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<TitleFixtureProvider>(), mockk<NavigationManager>(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) } } private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
} }

View file

@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser 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.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -141,6 +142,28 @@ class TitleReadFacadeTest {
assertEquals("A real file", saved.credits.single().name) assertEquals("A real file", saved.credits.single().name)
} }
@Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest {
val userA = mockk<JellyfinUser> { every { id } returns firstId }
val userB = mockk<JellyfinUser> { every { id } returns secondId }
val serverA = mockk<JellyfinServer> { every { id } returns seriesId }
val serverB = mockk<JellyfinServer> { 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<GetItemsRequest>()) } 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 { @Test fun explicitEpisodeStaysThePlayAndWatchTargetAcrossDifferentSeasonSelection() = runTest {
val episode = file(firstId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 4, indexNumber = 9) 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) val other = file(secondId, BaseItemKind.EPISODE).copy(seriesId = seriesId, parentIndexNumber = 2, indexNumber = 1)

View file

@ -1,15 +1,12 @@
package org.justinvisser.wolfskin.tv package org.justinvisser.wolfskin.tv
import android.content.Context import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -42,27 +39,43 @@ class WolfskinHomeViewModel @Inject constructor(
private set private set
var toast by mutableStateOf<String?>(null) var toast by mutableStateOf<String?>(null)
private set private set
private var sourceScope: String? = null
private var loadRevision = 0L
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) { suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } 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)) val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
if (person != user.name.orEmpty().lowercase()) { if (person != user.name.orEmpty().lowercase()) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false) snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
return return
} }
val scope = "${server.id}:${user.id}" val scope = "${server.id}:${user.id}"
val saved = withContext(Dispatchers.IO) { cache.read(scope) } if (sourceScope != scope) {
sourceScope = scope
toast = null
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
}
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
if (revision != loadRevision) return
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation)) if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
try { try {
val library = withContext(Dispatchers.IO) { home.read(user.id) } val library = withContext(Dispatchers.IO) { home.read(user.id) }
val savedAt = System.currentTimeMillis() val savedAt = System.currentTimeMillis()
withContext(Dispatchers.IO) { 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)) snapshot = HomeSnapshot(buildHomeRows(library, continuation))
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw 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))) 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) 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.Details -> navigation.navigateTo(Destination.Wolfskin(titleRoute(action.title.id)))
is HomeAction.Play -> viewModelScope.launch { is HomeAction.Play -> viewModelScope.launch {
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) { 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 return@launch
} }
try { try {
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) } val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.") if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L)) else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
showToast("Can’t reach Jellyfin to play. Check the network and try again.") showToast(RecoveryMessage.PlaybackUnavailable)
} }
} }
} }
@ -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) } LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
Box(modifier) { Box(modifier) {
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) } HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
model.toast?.let { message -> model.toast?.let { RecoveryToast(it) }
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
}
} }
} }

View file

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

View file

@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PersonState import org.justinvisser.wolfskin.design.PersonState
import org.justinvisser.wolfskin.design.PersonSurface import org.justinvisser.wolfskin.design.PersonSurface
import org.justinvisser.wolfskin.design.RecoveryMessage
import org.justinvisser.wolfskin.design.ShellPerson import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleAction 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 uuid = id.toUuidOrNull() ?: return
val current = title ?: return val current = title ?: return
try { try {
@ -113,12 +114,12 @@ class WolfskinTitleViewModel @Inject constructor(
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
if (current.data.status == TitleReadStatus.Ready) { if (current.data.status == TitleReadStatus.Ready) {
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason() 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)) } 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 if (personReadPending || personReadFinished) return
val uuid = id.toUuidOrNull() ?: return val uuid = id.toUuidOrNull() ?: return
val owner = viewer.key val owner = viewer.key
@ -137,8 +138,11 @@ class WolfskinTitleViewModel @Inject constructor(
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw exception if (exception is CancellationException) throw exception
if (owner == viewer.key) { if (owner == viewer.key) {
person?.toast = "Still can’t reach it. The last saved view stays on screen." person?.toast = RecoveryMessage.StillUnreachable
if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable)) if (person == null) {
title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
if (retry) title?.toast = RecoveryMessage.StillUnreachable
}
} }
} finally { if (owner == viewer.key) personReadPending = false } } finally { if (owner == viewer.key) personReadPending = false }
} }
@ -175,9 +179,9 @@ class WolfskinTitleViewModel @Inject constructor(
} }
TitleAction.Retry -> viewModelScope.launch { TitleAction.Retry -> viewModelScope.launch {
if (fixture == null) { 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.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() 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)) navigation.navigateTo(Destination.Playback(id, position))
} catch (exception: Exception) { } catch (exception: Exception) {
if (exception is CancellationException) throw 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 @Composable
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel()) { 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) } 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) { LaunchedEffect(title?.data?.status) {
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
} }
val person = viewModel.person val person = viewModel.person.takeIf { title != null }
BackHandler(enabled = title != null) { BackHandler(enabled = title != null) {
title?.back(shell) title?.back(shell)
} }

View file

@ -41,6 +41,7 @@ import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PeopleStatus import org.justinvisser.wolfskin.design.PeopleStatus
import org.justinvisser.wolfskin.design.SettingsList import org.justinvisser.wolfskin.design.SettingsList
@ -102,10 +103,17 @@ class WolfskinShellViewModel @Inject constructor(
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } } fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */ /** 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 { viewModelScope.launch {
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot } 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.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user) is ShellAction.Person -> selectPerson(action.person, server, user)
ShellAction.RetryPeople -> viewModelScope.launch { load(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}"))) 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) } BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { 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) } ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) }
} else TitleDestination(route, state, preferences) } else TitleDestination(route, state, preferences)
} else { } else {
ShellSurface(state, handlesContentKeys = upstream == null, ShellSurface(state, handlesContentKeys = upstream == null,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when { when {
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->

View file

@ -82,6 +82,8 @@ fun WolfskinLibraryContent(
grid.source = snapshot.items grid.source = snapshot.items
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
} }
} }
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false

View file

@ -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 stays visible when Jellyfin is unreachable. Playback resolves an actual film or
episode and checks Jellyfin before opening upstream's player. No season, episode and checks Jellyfin before opening upstream's player. No season,
recommendation stub or different person's session is promoted to playback. recommendation stub or different person's session is promoted to playback.
An unresolved person switch shows the same sign-in guard for Home as for native An unresolved person switch guards Home and both owned/upstream library content
library content, before mounting Home's source reads. before mounting any source read with the prior person's Jellyfin session.
Debug variants additionally package the locked fixture and local artwork under Debug variants additionally package the locked fixture and local artwork under
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
@ -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 exact-episode, track-index, identity, locale-layout and trailer-handler tests
run offline with in-process service replies and local art. 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 ## Toolchain and disk use

View file

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

View file

@ -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) data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> { else -> {
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn) if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) { val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size)) data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
} }
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) { LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
itemsIndexed(visibleRows, itemsIndexed(visibleRows,
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item -> key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId, ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId, state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() }) onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
} }
} }
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
} }
} }
} }
} }
@Composable @Composable
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) { private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) } Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
} }
@Composable @Composable

View file

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

View file

@ -14,7 +14,6 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt import kotlin.math.roundToInt
internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier { 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. */ /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable @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() } val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints -> Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }

View file

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

View file

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

View file

@ -46,11 +46,12 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
shell.contentFocus = focus shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
} }
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
LaunchedEffect(focusedTitle?.key, state.contentFocused) { LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt) {
state.plateKey = null 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()) if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key 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 density = LocalDensity.current
val list = shell.view.listState val list = shell.view.listState
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) { val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
buildList { buildList {
add(if (state.hasHero) L.row_phero else L.row_lhead) add(if (state.hasHero) L.row_phero else L.row_lhead)
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero) add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.canRequest) add(L.row_state) else { if (!state.hasCatalogue) add(L.row_state) else {
if (note) add(L.row_note) if (note) add(L.row_note)
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
} }

View file

@ -19,9 +19,11 @@ class DiscoverState(
private val onPush: (String) -> Unit = {}, private val onPush: (String) -> Unit = {},
private val onPop: () -> Unit = {}, private val onPop: () -> Unit = {},
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() }, private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
private val nowMillis: () -> Long = System::currentTimeMillis,
) : ShellContentFocus { ) : ShellContentFocus {
var snapshot by mutableStateOf(initial) var snapshot by mutableStateOf(initial)
private set private set
private var savedTime = initial.savedAt ?: formatSavedTime()
private var serviceFilter by mutableStateOf("movie") private var serviceFilter by mutableStateOf("movie")
var filter: String var filter: String
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0] get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
@ -59,12 +61,14 @@ class DiscoverState(
private val dismissalVersions = mutableMapOf<String, Int>() private val dismissalVersions = mutableMapOf<String, Int>()
private var rootFilter = "all" private var rootFilter = "all"
override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val canRequest: Boolean get() = when (snapshot.status) { val hasCatalogue: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false else -> false
} }
val 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<DiscoverTitle> get() { val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) { if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty() val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
@ -127,8 +131,9 @@ class DiscoverState(
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings, 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 = { onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
scope.launch { scope.launch {
snapshot = operations.refresh(); revision++ reload()
if (collection !== grid) return@launch if (collection !== grid) return@launch
grid.savedAt = snapshot.savedAt
val fresh = lanes.firstOrNull { it.id == lane.id } val fresh = lanes.firstOrNull { it.id == lane.id }
if (canRequest && fresh != null) { if (canRequest && fresh != null) {
titles = fresh.fullTitles.associateBy { it.key } titles = fresh.fullTitles.associateBy { it.key }
@ -140,6 +145,7 @@ class DiscoverState(
} else grid.tail = GridTail.Retry } else grid.tail = GridTail.Retry
} }
}).apply { }).apply {
savedAt = snapshot.savedAt
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None } tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
} }
collection = grid collection = grid
@ -185,7 +191,7 @@ class DiscoverState(
return true return true
} }
if (title != null) { titleKey(key); return true } if (title != null) { titleKey(key); return true }
if (!canRequest) { if (!hasCatalogue) {
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh() if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
return true return true
} }
@ -254,8 +260,8 @@ class DiscoverState(
when (val sheet = overlay) { when (val sheet = overlay) {
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) { is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
scope.launch { scope.launch {
snapshot = operations.refresh(); revision++ reload()
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
sheet.state.focus = sheet.state.buttonIndex sheet.state.focus = sheet.state.buttonIndex
} }
} }
@ -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 } } fun refresh() { scope.launch { reload() } }
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun buildLanes(): List<DiscoverLane> = service?.let { private suspend fun reload() {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() } val refreshed = operations.refresh()
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden) val failed = refreshed.status == DiscoverStatus.Unavailable
snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed
if (!failed && hasCatalogue) savedTime = formatSavedTime()
revision++
toast = if (failed) RecoveryMessage.StillUnreachable else null
}
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
private fun buildLanes(): List<DiscoverLane> {
val lanes = service?.let {
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
return if (snapshot.savedAt != null) lanes + DiscoverLane("offline-recovery", "Try again", emptyList(), tail = DiscoverTail.Retry) else lanes
}
} }
private fun Int?.orZero(): Int = this ?: 0 private fun Int?.orZero(): Int = this ?: 0

View file

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

View file

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

View file

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

View file

@ -59,3 +59,4 @@ fun LibraryPoster(
} }
} }

View file

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

View file

@ -33,7 +33,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
@ -57,7 +56,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
/** There is one blob, including when focus wraps around the nav avatar. */ /** There is one blob, including when focus wraps around the nav avatar. */
@Composable @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 boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) } val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val index = if (state.navFocused) state.navIndex else state.currentTab 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) Row(Modifier.align(Alignment.TopEnd)
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) .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)) .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))

View file

@ -86,11 +86,13 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List<TitleItem>, p
/** Portrait, biography and the full continuous grid use the shell's remembered frame. */ /** Portrait, biography and the full continuous grid use the shell's remembered frame. */
@Composable @Composable
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { 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 } } val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
LaunchedEffect(state) { shell.navFocused = false } LaunchedEffect(state) { shell.navFocused = false }
Box(modifier.fillMaxSize().background(C.bg)) { Box(modifier.fillMaxSize().background(C.bg)) {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { 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 } if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
} }

View file

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

View file

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

View file

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

View file

@ -56,13 +56,19 @@ fun ShellSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true, handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null, settingsOpenRequests: Int? = null,
savedAt: String? = null,
overlay: @Composable () -> Unit = {}, overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) { ) {
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
LaunchedEffect(state.toast) {
val message = state.toast
if (message != null) {
kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong())
if (state.toast == message) state.toast = null
}
}
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus() else contentFocus.requestFocus()
@ -86,10 +92,11 @@ fun ShellSurface(
WhosWatching(state) WhosWatching(state)
} else { } else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) 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) if (state.requestsVisible) ComingInSheet(state)
overlay() overlay()
} }
state.toast?.let { RecoveryToast(it) }
} }
} }

View file

@ -4,8 +4,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit 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. */ /** 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)) if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom))

View file

@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape 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. */ /** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable @Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { 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 -> Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
// No content target exists during an initial load; nav Down must not enter a skeleton. // 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 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 } } val focusOwner = remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { 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 && state.sheet != null) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null } 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) { LaunchedEffect(message) {
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() } if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
} }
if (message != null) { if (message != null) RecoveryToast(message, modifier)
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)
}
}
} }
/** One keyed vertical frame renders the exact header, season, cast and related order. */ /** One keyed vertical frame renders the exact header, season, cast and related order. */

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json 1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-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 7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
8b351ce603c6ca39c72d41e012831ef84c082f51132b53dd45b3068ad2dc68b2 tokens.css 3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json