Add TV offline recovery and per-person state isolation
All checks were successful
review/design-claude Claude Sonnet cross-family review: pass, no blockers
All checks were successful
review/design-claude Claude Sonnet cross-family review: pass, no blockers
This commit is contained in:
parent
9dd321f07e
commit
a01c0a78fb
29 changed files with 783 additions and 86 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -42,27 +39,43 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private set
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private set
|
||||
private var sourceScope: String? = null
|
||||
private var loadRevision = 0L
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
|
||||
loadLibrary(server, user, person, retry)
|
||||
}
|
||||
|
||||
/** The authenticated read path is shared by release and offline fixture-driven tests. */
|
||||
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
val revision = ++loadRevision
|
||||
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||
if (person != user.name.orEmpty().lowercase()) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||
return
|
||||
}
|
||||
val scope = "${server.id}:${user.id}"
|
||||
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
|
||||
if (sourceScope != scope) {
|
||||
sourceScope = scope
|
||||
toast = null
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||
}
|
||||
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
if (revision != loadRevision) return
|
||||
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Local storage failure must not turn a reachable library into an outage. */ } }
|
||||
if (revision != loadRevision) return
|
||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (revision != loadRevision) return
|
||||
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
|
||||
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,16 +87,16 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}"))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,8 +139,6 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
|
|||
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
|
||||
Box(modifier) {
|
||||
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||
model.toast?.let { message ->
|
||||
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
|
||||
}
|
||||
model.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens
|
|||
@Composable
|
||||
internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) {
|
||||
val owner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(state, owner) {
|
||||
LaunchedEffect(state, state.person.key, owner) {
|
||||
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
refreshRequestsWhileStarted(state, refresh)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,10 +102,17 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
||||
|
||||
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
|
||||
fun refreshRequests() {
|
||||
fun refreshRequests() = refreshRequests(retry = false)
|
||||
|
||||
private fun refreshRequests(retry: Boolean) {
|
||||
val shell = session.state ?: return
|
||||
val target = shell.requests
|
||||
viewModelScope.launch {
|
||||
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
|
||||
session.state?.requests?.update(snapshot)
|
||||
target.update(snapshot)
|
||||
if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
||||
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +123,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
|
||||
ShellAction.RetryRequests -> refreshRequests()
|
||||
ShellAction.RetryRequests -> refreshRequests(retry = true)
|
||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
|
||||
}
|
||||
}
|
||||
|
|
@ -206,7 +213,7 @@ fun WolfskinApp(
|
|||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
when {
|
||||
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") ->
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ fun WolfskinLibraryContent(
|
|||
grid.source = snapshot.items
|
||||
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
||||
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
||||
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
|
|||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||
episode and checks Jellyfin before opening upstream's player. No season,
|
||||
recommendation stub or different person's session is promoted to playback.
|
||||
An unresolved person switch shows the same sign-in guard for Home as for native
|
||||
library content, before mounting Home's source reads.
|
||||
An unresolved person switch guards Home and both owned/upstream library content
|
||||
before mounting any source read with the prior person's Jellyfin session.
|
||||
|
||||
Debug variants additionally package the locked fixture and local artwork under
|
||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||
|
|
@ -340,6 +340,51 @@ the title-screen implementation; this slice does not implement title pages.
|
|||
failure/retry, stale copies, and person/server isolation. Library captures are written
|
||||
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
|
||||
|
||||
## Offline states and recovery
|
||||
|
||||
Owned screens share `RecoveryToast`: the warning glyph, 760 px maximum width,
|
||||
22/28 px padding, 14 px icon gap, 22 px corners and CSS-sized body line boxes.
|
||||
Network failures never expose transport exception text. Playback failure reads
|
||||
“Can’t reach Jellyfin to play. Check the network and try again.” and leaves the
|
||||
originating card focused; no simulated player hand-off panel is shipped. Retry
|
||||
failure reads “Still can’t reach it. The last saved view stays on screen.”
|
||||
|
||||
Library headers and the nav publish the same saved-copy timestamp. Failed library
|
||||
refreshes retain cards and expose a reachable Try again tail. Discover retains its
|
||||
current catalogue after a failed refresh, adds a recovery tail and disables request
|
||||
and dismissal writes until recovery; a revoked sign-in discards that saved projection.
|
||||
Previously observed plates can remain visible offline, but moving to an uncached
|
||||
card cannot start a ratings read. `SavedPill` shares the compact cached-header
|
||||
treatment across Home, Libraries and Requests; the nav retains its larger wifi chip.
|
||||
|
||||
Requests focus, observed ratings and retained rows are partitioned by selected person.
|
||||
A failed authenticated read preserves the last rows and their timestamp; Down from
|
||||
the final row reaches Try again and Up returns to the row. Signed-out data is not
|
||||
retained as an authenticated copy. Returning to a foreground person triggers that
|
||||
person's refresh rather than waiting for the prior person's polling timer.
|
||||
|
||||
Home disk snapshots remain server/user scoped. A new identity starts with its own
|
||||
skeleton or saved copy, never the previous person's projection; late responses from
|
||||
an older read cannot overwrite the current identity. A disk-write failure does not
|
||||
turn a successful Jellyfin read into an outage. Skeleton-only Home, Library and
|
||||
Discover screens keep focus in the nav even when Down is pressed repeatedly.
|
||||
|
||||
Library/Discover posters use the same title/glyph/160-degree gradient fallback as
|
||||
Home, including an actual Coil read failure. Discover hero image failures use the
|
||||
shared ambient background; missing artwork is not a successful data refresh.
|
||||
|
||||
`RecoveryStateTest`, `RecoveryCaptureTest`, `HomeRecoveryTest` and `PlayerHandoffTest`
|
||||
exercise state transitions, person isolation, native recovery focus/geometry and the
|
||||
real upstream playback destination with offline fixtures. New native captures and
|
||||
rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score
|
||||
captures configure the Android resource locale as well as the JVM locale.
|
||||
|
||||
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-family state integration and captures follow its independent title-screen merge.
|
||||
|
||||
## Toolchain and disk use
|
||||
|
||||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
||||
|
|
|
|||
|
|
@ -282,6 +282,11 @@
|
|||
"dp": 380.0,
|
||||
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
|
||||
},
|
||||
"toastPaddingY": { "$type": "dimension", "px": 22, "dp": 11, "source": "locked index.html:270" },
|
||||
"toastGap": { "$type": "dimension", "px": 14, "dp": 7, "source": "locked index.html:270" },
|
||||
"toastShadowBlur": { "$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:270" },
|
||||
"toastShadowY": { "$type": "dimension", "px": 20, "dp": 10, "source": "locked index.html:270" },
|
||||
"toastShadowOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:270" },
|
||||
"cardWashInset": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
|
|
|
|||
|
|
@ -81,27 +81,30 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
|
||||
else -> {
|
||||
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
|
||||
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
|
||||
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
|
||||
val start = state.windowStart
|
||||
val visibleRows = remember(data, start, state.windowSize) {
|
||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
||||
}
|
||||
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
itemsIndexed(visibleRows,
|
||||
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
|
||||
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
||||
}
|
||||
}
|
||||
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) }
|
||||
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ data class ComingInSnapshot(
|
|||
val status: ComingInStatus = ComingInStatus.Unavailable,
|
||||
val partial: Boolean = false,
|
||||
val signedIn: Boolean = false,
|
||||
val savedAt: String? = null,
|
||||
) {
|
||||
val ordered = items.sortedBy { it.stage.rank }
|
||||
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
|
||||
|
|
@ -66,6 +67,8 @@ class ComingInState {
|
|||
private set
|
||||
var ratingsVisible by mutableStateOf(false)
|
||||
private set
|
||||
var recoveryFocused by mutableStateOf(false)
|
||||
private set
|
||||
private val cachedRatings = mutableSetOf<String>()
|
||||
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
|
||||
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
|
||||
|
|
@ -73,14 +76,18 @@ class ComingInState {
|
|||
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
|
||||
|
||||
fun update(value: ComingInSnapshot) {
|
||||
snapshot = value
|
||||
if (!value.signedIn) cachedRatings.clear()
|
||||
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id)
|
||||
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings
|
||||
snapshot = if (value.status != ComingInStatus.Ready && value.signedIn && value.items.isEmpty() && snapshot.signedIn) {
|
||||
snapshot.copy(status = value.status)
|
||||
} else value
|
||||
if (!snapshot.signedIn) cachedRatings.clear()
|
||||
if (snapshot.ordered.none { it.id == focusedId }) focus(snapshot.ordered.firstOrNull()?.id)
|
||||
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
||||
if (snapshot.status == ComingInStatus.Ready) recoveryFocused = false
|
||||
}
|
||||
|
||||
fun focus(id: String?) {
|
||||
focusedId = id
|
||||
recoveryFocused = false
|
||||
expanded = false
|
||||
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
|
||||
}
|
||||
|
|
@ -97,9 +104,14 @@ class ComingInState {
|
|||
|
||||
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
|
||||
fun key(key: ShellKey): Boolean {
|
||||
if (recoveryFocused) {
|
||||
if (key == ShellKey.Up) recoveryFocused = false
|
||||
return false
|
||||
}
|
||||
when (key) {
|
||||
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
|
||||
ShellKey.Down -> if (snapshot.status == ComingInStatus.Unavailable && index == snapshot.ordered.lastIndex) recoveryFocused = true
|
||||
else focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
|
||||
ShellKey.Right -> if (focused != null) expanded = true
|
||||
ShellKey.Left -> expanded = false
|
||||
ShellKey.Ok -> return focused != null
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
|
||||
|
|
@ -40,7 +41,7 @@ val LocalDiscoverArt = compositionLocalOf<((String) -> ImageBitmap?)?> { null }
|
|||
|
||||
/** Decode requests use the actual art slot, with RGB565 only for upright posters. */
|
||||
@Composable
|
||||
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false) {
|
||||
fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false, dim: Boolean = false, onError: () -> Unit = {}) {
|
||||
if (path == null) return
|
||||
val bitmap = LocalDiscoverArt.current?.invoke(path)
|
||||
val painter = remember(bitmap) { bitmap?.let(::BitmapPainter) }
|
||||
|
|
@ -57,18 +58,21 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
|
|||
model = request,
|
||||
imageLoader = WolfskinImages.loader(context),
|
||||
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = filter,
|
||||
onError = { onError() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Poster-only titles retain the one-sheet over a blurred wash rather than stretching the poster. */
|
||||
@Composable
|
||||
fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Modifier = Modifier) {
|
||||
val failed = remember(title.poster, title.backdrop) { androidx.compose.runtime.mutableStateOf(false) }
|
||||
Box(modifier.size(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).clipToBounds().background(C.surface3)) {
|
||||
if (title.posterOnly) {
|
||||
if (failed.value || (title.poster == null && title.backdrop == null)) HomeAmbient(Modifier.fillMaxSize().testTag("discover-missing-art"))
|
||||
else if (title.posterOnly) {
|
||||
DiscoverArtwork(title.poster, Modifier.offset(-D.ambientInset, -D.ambientInset)
|
||||
.wrapContentSize(Alignment.TopStart, unbounded = true)
|
||||
.size(WolfskinTokens.Shell.stageWidth + D.ambientInset * 2, WolfskinTokens.Shell.stageHeight + D.ambientInset * 2)
|
||||
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true)
|
||||
.graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }.blur(D.ambientBlur), dim = true, onError = { failed.value = true })
|
||||
DiscoverArtwork(title.poster, Modifier.offset(
|
||||
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
|
||||
if (detail) L.navBand else D.heroSheetTop,
|
||||
|
|
@ -76,8 +80,8 @@ fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Mo
|
|||
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
|
||||
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
|
||||
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
|
||||
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true, onError = { failed.value = true })
|
||||
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize(), onError = { failed.value = true })
|
||||
Box(Modifier.fillMaxSize().drawWithCache {
|
||||
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)
|
||||
onDrawBehind { drawRect(scrim) }
|
||||
|
|
|
|||
|
|
@ -67,11 +67,7 @@ fun DiscoverOverlay(state: DiscoverState) {
|
|||
}
|
||||
}
|
||||
}
|
||||
state.toast?.let { text ->
|
||||
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
|
||||
Text(text, color = C.fg, style = DiscoverType.body)
|
||||
}
|
||||
}
|
||||
state.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -46,11 +46,12 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
||||
}
|
||||
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
||||
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
|
||||
LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt) {
|
||||
state.plateKey = null
|
||||
if (state.contentFocused && focusedTitle != null) {
|
||||
if (state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings)) {
|
||||
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
|
||||
}
|
||||
|
|
@ -68,11 +69,11 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
|
||||
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) {
|
||||
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
|
||||
buildList {
|
||||
add(if (state.hasHero) L.row_phero else L.row_lhead)
|
||||
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero)
|
||||
if (!state.canRequest) add(L.row_state) else {
|
||||
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
||||
if (!state.hasCatalogue) add(L.row_state) else {
|
||||
if (note) add(L.row_note)
|
||||
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,11 @@ class DiscoverState(
|
|||
private val onPush: (String) -> Unit = {},
|
||||
private val onPop: () -> Unit = {},
|
||||
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
) : ShellContentFocus {
|
||||
var snapshot by mutableStateOf(initial)
|
||||
private set
|
||||
private var savedTime = initial.savedAt ?: formatSavedTime()
|
||||
private var serviceFilter by mutableStateOf("movie")
|
||||
var filter: String
|
||||
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
|
||||
|
|
@ -59,12 +61,14 @@ class DiscoverState(
|
|||
private val dismissalVersions = mutableMapOf<String, Int>()
|
||||
private var rootFilter = "all"
|
||||
|
||||
override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading
|
||||
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
|
||||
val canRequest: Boolean get() = when (snapshot.status) {
|
||||
val hasCatalogue: Boolean get() = when (snapshot.status) {
|
||||
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
|
||||
else -> false
|
||||
}
|
||||
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
|
||||
val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null
|
||||
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue
|
||||
val heroes: List<DiscoverTitle> get() {
|
||||
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
|
||||
val source = if (snapshot.status == DiscoverStatus.Ready) snapshot.picks else snapshot.lanes.firstOrNull { it.id == "trending" }?.titles.orEmpty()
|
||||
|
|
@ -127,8 +131,9 @@ class DiscoverState(
|
|||
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
|
||||
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
|
||||
scope.launch {
|
||||
snapshot = operations.refresh(); revision++
|
||||
reload()
|
||||
if (collection !== grid) return@launch
|
||||
grid.savedAt = snapshot.savedAt
|
||||
val fresh = lanes.firstOrNull { it.id == lane.id }
|
||||
if (canRequest && fresh != null) {
|
||||
titles = fresh.fullTitles.associateBy { it.key }
|
||||
|
|
@ -140,6 +145,7 @@ class DiscoverState(
|
|||
} else grid.tail = GridTail.Retry
|
||||
}
|
||||
}).apply {
|
||||
savedAt = snapshot.savedAt
|
||||
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
|
||||
}
|
||||
collection = grid
|
||||
|
|
@ -185,7 +191,7 @@ class DiscoverState(
|
|||
return true
|
||||
}
|
||||
if (title != null) { titleKey(key); return true }
|
||||
if (!canRequest) {
|
||||
if (!hasCatalogue) {
|
||||
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
|
||||
return true
|
||||
}
|
||||
|
|
@ -254,8 +260,8 @@ class DiscoverState(
|
|||
when (val sheet = overlay) {
|
||||
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
|
||||
scope.launch {
|
||||
snapshot = operations.refresh(); revision++
|
||||
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
|
||||
reload()
|
||||
sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
|
||||
sheet.state.focus = sheet.state.buttonIndex
|
||||
}
|
||||
}
|
||||
|
|
@ -326,12 +332,25 @@ class DiscoverState(
|
|||
}
|
||||
}
|
||||
|
||||
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
|
||||
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
|
||||
fun refresh() { scope.launch { reload() } }
|
||||
|
||||
private fun buildLanes(): List<DiscoverLane> = service?.let {
|
||||
private suspend fun reload() {
|
||||
val refreshed = operations.refresh()
|
||||
val failed = refreshed.status == DiscoverStatus.Unavailable
|
||||
snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed
|
||||
if (!failed && hasCatalogue) savedTime = formatSavedTime()
|
||||
revision++
|
||||
toast = if (failed) RecoveryMessage.StillUnreachable else null
|
||||
}
|
||||
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
|
||||
private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
|
||||
|
||||
private fun buildLanes(): List<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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class HomeFocus(
|
|||
private val onAction: (HomeAction) -> Unit,
|
||||
) : ShellContentFocus {
|
||||
private val view = shell.rememberedView("home")
|
||||
override val hasContent: Boolean get() = rows.any { it.focusable }
|
||||
override val cardKey: String? get() = row?.let { if (it.kind == HomeRowKind.Shelf) it.cards.getOrNull(column)?.id ?: it.id else null }
|
||||
val row: HomeRow? get() = rows.getOrNull(view.row)
|
||||
val column: Int get() = row?.let { view.cards[it.id] ?: 0 } ?: 0
|
||||
|
|
|
|||
|
|
@ -69,11 +69,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
|
|||
Text("Discover", color = C.fg, style = T.sectionTitle)
|
||||
Avatar(person, H.sectionAvatar, T.avatarLetterSectionHeader)
|
||||
Text("Picks for you first, then what’s out there", color = C.muted, style = T.body)
|
||||
if (savedAt != null) Box(Modifier.height(WolfskinTokens.Shell.settingsPillHeight)
|
||||
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
|
||||
.padding(horizontal = WolfskinTokens.Shell.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
|
||||
Text("Saved $savedAt", color = C.warn, style = T.caption.copy(fontWeight = T.label.fontWeight))
|
||||
}
|
||||
savedAt?.let { SavedPill(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ class LibraryGridState(
|
|||
}
|
||||
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
|
||||
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
|
||||
override val hasContent: Boolean get() = status != LibraryStatus.Loading
|
||||
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
|
||||
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,14 +17,12 @@ import androidx.compose.ui.graphics.ColorMatrix
|
|||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
|
||||
|
||||
|
|
@ -41,11 +39,9 @@ fun LibraryPoster(
|
|||
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
|
||||
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
|
||||
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else if (title.poster != null && offlineArt == null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(title.poster, context) { WolfskinImages.request(context, title.poster, (WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt(), true) }
|
||||
AsyncImage(request, title.title, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} else MissingLibraryArt(title)
|
||||
else if (offlineArt == null) TitleArtwork(title.title, title.type, title.poster,
|
||||
WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
|
||||
else TitleArtwork(title.title, title.type, null, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
|
||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||
if (title.progress > 0f && title.progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||
|
|
@ -66,17 +62,6 @@ fun LibraryPoster(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingLibraryArt(title: LibraryTitle) {
|
||||
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
|
||||
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
|
||||
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
|
||||
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
|
||||
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
|
||||
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ fun LibraryScreen(
|
|||
plateKey = focused.id
|
||||
}
|
||||
}
|
||||
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
|
||||
LaunchedEffect(grid.status) {
|
||||
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
}
|
||||
|
|
@ -169,7 +170,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
|||
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
||||
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
|
||||
savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
|
||||
savedAt?.let { SavedPill(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,10 +100,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
}
|
||||
state.view.savedAt?.let { time ->
|
||||
state.navSavedAt?.let { time ->
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2)
|
||||
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius))
|
||||
.padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
|
||||
.padding(horizontal = H.savedPadding + H.hairline), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
|
||||
Glyph("wifi", C.warn, Modifier.size(H.savedIcon))
|
||||
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ enum class PeopleStatus { Ready, Loading, Unavailable }
|
|||
|
||||
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
|
||||
interface ShellContentFocus {
|
||||
val hasContent: Boolean get() = true
|
||||
val cardKey: String?
|
||||
fun key(key: ShellKey): Boolean
|
||||
}
|
||||
|
|
@ -59,7 +60,9 @@ class ShellState(
|
|||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||
var recoveryIndex by mutableStateOf(0)
|
||||
var requestsVisible by mutableStateOf(false)
|
||||
val requests = ComingInState()
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private val personRequests = mutableMapOf<String, ComingInState>()
|
||||
val requests: ComingInState get() = personRequests.getOrPut(person.key) { ComingInState() }
|
||||
private var requestReturnRoute: String? = null
|
||||
var handOff by mutableStateOf<String?>(null)
|
||||
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 currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
|
||||
val view: RememberedView get() = rememberedView(route)
|
||||
val navSavedAt: String? get() = if (requestsVisible && requests.snapshot.status == ComingInStatus.Unavailable) requests.snapshot.savedAt ?: view.savedAt else view.savedAt
|
||||
|
||||
/** Screen options share the same per-person memory as their root's focus and scroller. */
|
||||
fun rememberedView(destination: String): RememberedView =
|
||||
|
|
@ -118,7 +122,7 @@ class ShellState(
|
|||
if (requestsVisible) {
|
||||
when {
|
||||
key == ShellKey.Back -> closeRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
||||
requests.key(key) -> activateRequest()
|
||||
}
|
||||
|
|
@ -184,7 +188,7 @@ class ShellState(
|
|||
when (key) {
|
||||
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
|
||||
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
|
||||
ShellKey.Down -> navFocused = false
|
||||
ShellKey.Down -> if (contentFocus?.hasContent != false) navFocused = false
|
||||
ShellKey.Ok -> activate()
|
||||
else -> Unit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ fun ShellSurface(
|
|||
val focus = remember { FocusRequester() }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(state.toast) {
|
||||
val message = state.toast
|
||||
if (message != null) {
|
||||
kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong())
|
||||
if (state.toast == message) state.toast = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||
else contentFocus.requestFocus()
|
||||
|
|
@ -88,6 +95,7 @@ fun ShellSurface(
|
|||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
}
|
||||
state.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -443,6 +443,11 @@ object WolfskinTokens {
|
|||
val toastPadding = 14.0.dp
|
||||
val toastTop = 75.0.dp
|
||||
val toastWidth = 380.0.dp
|
||||
val toastPaddingY = 11.0.dp
|
||||
val toastGap = 7.0.dp
|
||||
val toastShadowBlur = 30.0.dp
|
||||
val toastShadowY = 10.0.dp
|
||||
const val toastShadowOpacity = 0.5f
|
||||
val cardWashInset = 15.0.dp
|
||||
val heroWashInset = 40.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -304,6 +304,10 @@
|
|||
--home-toastPadding: 28.0px;
|
||||
--home-toastTop: 150.0px;
|
||||
--home-toastWidth: 760.0px;
|
||||
--home-toastPaddingY: 22.0px;
|
||||
--home-toastGap: 14.0px;
|
||||
--home-toastShadowBlur: 60.0px;
|
||||
--home-toastShadowY: 20.0px;
|
||||
--home-cardWashInset: 30.0px;
|
||||
--home-heroWashInset: 80.0px;
|
||||
--home-noteTop: 14.0px;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||
a934faf6ce6972371455b51c18be7278e4f3d9daf6e1c32e230542a09ddb94c6 generate_tokens.py
|
||||
9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||
1dcf1136efaf0eb0cda4ab93266abe32d68325c63d7a967d7edb66f6ccd64f29 shell-tokens.json
|
||||
cc7ad4add4a0e8b2eb3286ac25c75c3ba18189f5c076101f562d00ed141d4d83 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
3b675f3e71e9e1e0e5713b40b8600d5a7fcb5b19f5c4c447f778ec8f1ccfa791 tokens.css
|
||||
9bf7d34cd27f4156d7adc10911f0b28a5d77e41a1adc1618ee0dd7b302c86ca3 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
22c2a522d892e0751c8bc1bad7364cc3c2e19d3b73cc523dbaa8087d54727b6c tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue