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)
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:
commit
844530404d
36 changed files with 875 additions and 97 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import android.app.Application
|
|||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
|
|
@ -21,6 +23,8 @@ import org.junit.Rule
|
|||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.RecoveryMessage
|
||||
import org.justinvisser.wolfskin.design.TitleConnection
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleItem
|
||||
import org.justinvisser.wolfskin.design.TitlePageData
|
||||
|
|
@ -122,5 +126,40 @@ class TitleAsyncFocusTest {
|
|||
assertFalse(shell.navFocused)
|
||||
}
|
||||
|
||||
@Test fun failedRetryKeepsThePersonsSavedTitleAndShowsSharedRecovery() {
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000014")
|
||||
val reads = java.util.concurrent.atomic.AtomicInteger()
|
||||
val saved = TitlePageData(TitleItem(id.toString(), "Saved title", "movie"),
|
||||
connection = TitleConnection.Offline, savedAt = "20:42")
|
||||
val facade = mockk<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) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import com.github.damontecres.wholphin.WholphinApplication
|
|||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
|
|
@ -141,6 +142,28 @@ class TitleReadFacadeTest {
|
|||
assertEquals("A real file", saved.credits.single().name)
|
||||
}
|
||||
|
||||
@Test fun personSavedCopiesNeverCrossServerOrViewerIdentity() = runTest {
|
||||
val userA = mockk<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 {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(titleRoute(action.title.id)))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,8 +139,6 @@ fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUse
|
|||
LaunchedEffect(shell.person.key, server.id, user.id) { model.load(server, user, shell.person.key) }
|
||||
Box(modifier) {
|
||||
HomeScreen(shell, model.snapshot, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||
model.toast?.let { message ->
|
||||
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
|
||||
}
|
||||
model.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import org.justinvisser.wolfskin.design.PersonState
|
||||
import org.justinvisser.wolfskin.design.PersonSurface
|
||||
import org.justinvisser.wolfskin.design.RecoveryMessage
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.TitleAction
|
||||
|
|
@ -102,7 +103,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun readTitle(id: String) {
|
||||
private suspend fun readTitle(id: String, retry: Boolean = false) {
|
||||
val uuid = id.toUuidOrNull() ?: return
|
||||
val current = title ?: return
|
||||
try {
|
||||
|
|
@ -113,12 +114,12 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
if (exception is CancellationException) throw exception
|
||||
if (current.data.status == TitleReadStatus.Ready) {
|
||||
if (current.data.episodesStatus == TitleReadStatus.Loading) current.restoreSeason()
|
||||
current.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
} else current.update(current.data.copy(status = TitleReadStatus.Unavailable))
|
||||
if (current.data.status == TitleReadStatus.Ready || retry) current.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readPerson(id: String) {
|
||||
private suspend fun readPerson(id: String, retry: Boolean = false) {
|
||||
if (personReadPending || personReadFinished) return
|
||||
val uuid = id.toUuidOrNull() ?: return
|
||||
val owner = viewer.key
|
||||
|
|
@ -137,8 +138,11 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
if (owner == viewer.key) {
|
||||
person?.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
if (person == null) title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
|
||||
person?.toast = RecoveryMessage.StillUnreachable
|
||||
if (person == null) {
|
||||
title?.update(title!!.data.copy(status = TitleReadStatus.Unavailable))
|
||||
if (retry) title?.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
} finally { if (owner == viewer.key) personReadPending = false }
|
||||
}
|
||||
|
|
@ -175,9 +179,9 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
}
|
||||
TitleAction.Retry -> viewModelScope.launch {
|
||||
if (fixture == null) {
|
||||
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/'))) else readTitle(Uri.decode(route.substringAfter('/')))
|
||||
if (route.startsWith("person/")) readPerson(Uri.decode(route.substringAfter('/')), retry = true) else readTitle(Uri.decode(route.substringAfter('/')), retry = true)
|
||||
}
|
||||
else title?.toast = "Still can’t reach it. The last saved view stays on screen."
|
||||
else title?.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
is TitleAction.Request, is TitleAction.Requests -> title?.toast = "Requests need this TV to be signed in to Conductarr."
|
||||
is TitleAction.Hide -> if (fixture != null) fixtureSelections.edit().putBoolean("${viewer.key}:${action.item.id}:hidden", action.hidden).apply()
|
||||
|
|
@ -192,7 +196,7 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
navigation.navigateTo(Destination.Playback(id, position))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
title?.toast = "Can’t reach Jellyfin to play. Check the network and try again."
|
||||
title?.toast = RecoveryMessage.PlaybackUnavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -231,11 +235,11 @@ class WolfskinTitleViewModel @Inject constructor(
|
|||
@Composable
|
||||
fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferences, modifier: Modifier = Modifier, viewModel: WolfskinTitleViewModel = hiltViewModel()) {
|
||||
LaunchedEffect(route, shell.person.key) { viewModel.load(route, shell.person, preferences) }
|
||||
val title = viewModel.title
|
||||
val title = viewModel.title?.takeIf { it.person.key == shell.person.key }
|
||||
LaunchedEffect(title?.data?.status) {
|
||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||
}
|
||||
val person = viewModel.person
|
||||
val person = viewModel.person.takeIf { title != null }
|
||||
BackHandler(enabled = title != null) {
|
||||
title?.back(shell)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import javax.inject.Singleton
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.justinvisser.wolfskin.design.PeopleStatus
|
||||
import org.justinvisser.wolfskin.design.SettingsList
|
||||
|
|
@ -102,10 +103,17 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
||||
|
||||
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
|
||||
fun refreshRequests() {
|
||||
fun refreshRequests() = refreshRequests(retry = false)
|
||||
|
||||
private fun refreshRequests(retry: Boolean) {
|
||||
val shell = session.state ?: return
|
||||
val target = shell.requests
|
||||
viewModelScope.launch {
|
||||
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
|
||||
session.state?.requests?.update(snapshot)
|
||||
target.update(snapshot)
|
||||
if (retry && snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
||||
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,7 +124,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
|
||||
is ShellAction.Person -> selectPerson(action.person, server, user)
|
||||
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
|
||||
ShellAction.RetryRequests -> refreshRequests()
|
||||
ShellAction.RetryRequests -> refreshRequests(retry = true)
|
||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin(titleRoute("${action.request.mediaType}:${action.request.tmdbId}")))
|
||||
}
|
||||
}
|
||||
|
|
@ -205,14 +213,15 @@ fun WolfskinApp(
|
|||
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
|
||||
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
|
||||
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
|
||||
if (destination is Destination.MediaItem && state.person.key != viewModel.signedInPerson) {
|
||||
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
|
||||
if (jellyfinIdentity && state.person.key != viewModel.signedInPerson) {
|
||||
ShellSurface(state, handlesContentKeys = false) { ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", it) }
|
||||
} else TitleDestination(route, state, preferences)
|
||||
} else {
|
||||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
when {
|
||||
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ fun WolfskinLibraryContent(
|
|||
grid.source = snapshot.items
|
||||
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
||||
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
||||
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
|
|
|
|||
|
|
@ -205,8 +205,8 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
|
|||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||
episode and checks Jellyfin before opening upstream's player. No season,
|
||||
recommendation stub or different person's session is promoted to playback.
|
||||
An unresolved person switch shows the same sign-in guard for Home as for native
|
||||
library content, before mounting Home's source reads.
|
||||
An unresolved person switch guards Home and both owned/upstream library content
|
||||
before mounting any source read with the prior person's Jellyfin session.
|
||||
|
||||
Debug variants additionally package the locked fixture and local artwork under
|
||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||
|
|
@ -401,6 +401,56 @@ The shorter 1/5/6/2 title-entry replays remain as focused regressions. SDK cache
|
|||
exact-episode, track-index, identity, locale-layout and trailer-handler tests
|
||||
run offline with in-process service replies and local art.
|
||||
|
||||
## Offline states and recovery
|
||||
|
||||
Owned screens share `RecoveryToast`: the warning glyph, 760 px maximum width,
|
||||
22/28 px padding, 14 px icon gap, 22 px corners and CSS-sized body line boxes.
|
||||
Network failures never expose transport exception text. Playback failure reads
|
||||
“Can’t reach Jellyfin to play. Check the network and try again.” and leaves the
|
||||
originating card focused; no simulated player hand-off panel is shipped. Retry
|
||||
failure reads “Still can’t reach it. The last saved view stays on screen.”
|
||||
|
||||
Library headers and the nav publish the same saved-copy timestamp. Failed library
|
||||
refreshes retain cards and expose a reachable Try again tail. Discover retains its
|
||||
current catalogue after a failed refresh, adds a recovery tail and disables request
|
||||
and dismissal writes until recovery; a revoked sign-in discards that saved projection.
|
||||
Previously observed plates can remain visible offline, but moving to an uncached
|
||||
card cannot start a ratings read. `SavedPill` shares the compact cached-header
|
||||
treatment across Home, Libraries and Requests; the nav retains its larger wifi chip.
|
||||
|
||||
Requests focus, observed ratings and retained rows are partitioned by selected person.
|
||||
A failed authenticated read preserves the last rows and their timestamp; Down from
|
||||
the final row reaches Try again and Up returns to the row. Signed-out data is not
|
||||
retained as an authenticated copy. Returning to a foreground person triggers that
|
||||
person's refresh rather than waiting for the prior person's polling timer.
|
||||
|
||||
Home disk snapshots remain server/user scoped. A new identity starts with its own
|
||||
skeleton or saved copy, never the previous person's projection; late responses from
|
||||
an older read cannot overwrite the current identity. A disk-write failure does not
|
||||
turn a successful Jellyfin read into an outage. Skeleton-only Home, Library and
|
||||
Discover screens keep focus in the nav even when Down is pressed repeatedly.
|
||||
|
||||
Library/Discover posters use the same title/glyph/160-degree gradient fallback as
|
||||
Home, including an actual Coil read failure. Discover hero image failures use the
|
||||
shared ambient background; missing artwork is not a successful data refresh.
|
||||
|
||||
`RecoveryStateTest`, `RecoveryCaptureTest`, `HomeRecoveryTest` and `PlayerHandoffTest`
|
||||
exercise state transitions, person isolation, native recovery focus/geometry and the
|
||||
real upstream playback destination with offline fixtures. New native captures and
|
||||
rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score
|
||||
captures configure the Android resource locale as well as the JVM locale.
|
||||
|
||||
Production Conductarr TV sign-in/provider wiring remains outside these state
|
||||
components: release Discover is signed out and release Requests is unavailable.
|
||||
The generated client's identity-scoped disk cache is the Conductarr cache boundary;
|
||||
no Seerr fallback, live-test server, handwritten HTTP or offline request queue is added.
|
||||
Title and person surfaces publish saved timestamps through the same per-person
|
||||
`RememberedView` partition and nav Saved indicator. Their timed notices render
|
||||
the shared `RecoveryToast`; failed Try again and playback use `RecoveryMessage`
|
||||
without transport details. A selected-person mismatch never renders the former
|
||||
person's title/person projection. Title and performer disk copies remain isolated
|
||||
by both server and authenticated user; native retry and cache-isolation regressions
|
||||
exercise these boundaries without a live service.
|
||||
|
||||
## Toolchain and disk use
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import androidx.compose.ui.unit.Constraints
|
|||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
internal data class CssLineBox(val lineHeight: TextUnit, val top: Dp, val bottom: Dp) : LayoutModifier, ParentDataModifier {
|
||||
|
|
@ -41,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
|
|||
|
||||
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
||||
@Composable
|
||||
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = 0.dp, content: @Composable () -> Unit) {
|
||||
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
|
||||
val exact = remember { CssColumnHeight() }
|
||||
Layout(content, modifier.then(exact)) { children, constraints ->
|
||||
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
serviceLanes(it, filter).filter { lane -> lane.titles.isNotEmpty() }
|
||||
} ?: projectDiscoverLanes(snapshot, heroes, filter, hidden)
|
||||
private suspend fun reload() {
|
||||
val refreshed = operations.refresh()
|
||||
val failed = refreshed.status == DiscoverStatus.Unavailable
|
||||
snapshot = if (failed && hasCatalogue) snapshot.copy(savedAt = savedTime) else refreshed
|
||||
if (!failed && hasCatalogue) savedTime = formatSavedTime()
|
||||
revision++
|
||||
toast = if (failed) RecoveryMessage.StillUnreachable else null
|
||||
}
|
||||
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
|
||||
private fun formatSavedTime(): String = java.time.Instant.ofEpochMilli(nowMillis()).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm"))
|
||||
|
||||
private fun buildLanes(): List<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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ class LibraryGridState(
|
|||
}
|
||||
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
|
||||
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
|
||||
override val hasContent: Boolean get() = status != LibraryStatus.Loading
|
||||
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
|
||||
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)
|
||||
|
||||
|
|
|
|||
|
|
@ -59,3 +59,4 @@ fun LibraryPoster(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
||||
|
|
@ -57,7 +56,7 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
|
|||
|
||||
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||
@Composable
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, savedAt: String? = null) {
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
|
||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||
|
|
@ -101,7 +100,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
|
|||
}
|
||||
}
|
||||
}
|
||||
(savedAt ?: state.view.savedAt)?.let { time ->
|
||||
state.navSavedAt?.let { time ->
|
||||
Row(Modifier.align(Alignment.TopEnd)
|
||||
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
|
||||
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
@Composable
|
||||
fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val view = shell.view
|
||||
LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt }
|
||||
val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } }
|
||||
LaunchedEffect(state) { shell.navFocused = false }
|
||||
Box(modifier.fillMaxSize().background(C.bg)) {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) {
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.savedAt) { PersonPage(state, shell, it) }
|
||||
ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,13 +56,19 @@ fun ShellSurface(
|
|||
modifier: Modifier = Modifier,
|
||||
handlesContentKeys: Boolean = true,
|
||||
settingsOpenRequests: Int? = null,
|
||||
savedAt: String? = null,
|
||||
overlay: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
val focus = remember { FocusRequester() }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(state.toast) {
|
||||
val message = state.toast
|
||||
if (message != null) {
|
||||
kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong())
|
||||
if (state.toast == message) state.toast = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||
else contentFocus.requestFocus()
|
||||
|
|
@ -86,10 +92,11 @@ fun ShellSurface(
|
|||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2, savedAt = savedAt)
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
}
|
||||
state.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** Explicit CSS leading and margins share one fractional layout engine across Wolfskin screens. */
|
||||
fun Modifier.exactLineBox(style: TextStyle, top: Dp = 0.dp, bottom: Dp = 0.dp): Modifier =
|
||||
fun Modifier.exactLineBox(style: TextStyle, top: Dp = WolfskinTokens.Shell.zero, bottom: Dp = WolfskinTokens.Shell.zero): Modifier =
|
||||
if (style.lineHeight == TextUnit.Unspecified) this else then(CssLineBox(style.lineHeight, top, bottom))
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
|
|
@ -52,6 +51,8 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
|||
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||
@Composable
|
||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
||||
val view = shell.view
|
||||
LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt }
|
||||
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
|
||||
// No content target exists during an initial load; nav Down must not enter a skeleton.
|
||||
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
|
||||
|
|
@ -66,7 +67,7 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
|
|||
}) {
|
||||
val focusOwner = remember(shell) { { shell.navFocused = false } }
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
|
||||
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
|
||||
}
|
||||
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
|
||||
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
|
||||
|
|
@ -79,14 +80,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
|
|||
LaunchedEffect(message) {
|
||||
if (message != null) { delay(WolfskinTokens.Motion.toast.toLong()); onDismiss() }
|
||||
}
|
||||
if (message != null) {
|
||||
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
|
||||
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
|
||||
}
|
||||
}
|
||||
if (message != null) RecoveryToast(message, modifier)
|
||||
}
|
||||
|
||||
/** One keyed vertical frame renders the exact header, season, cast and related order. */
|
||||
|
|
|
|||
|
|
@ -590,6 +590,11 @@ object WolfskinTokens {
|
|||
val toastPadding = 14.0.dp
|
||||
val toastTop = 75.0.dp
|
||||
val toastWidth = 380.0.dp
|
||||
val toastPaddingY = 11.0.dp
|
||||
val toastGap = 7.0.dp
|
||||
val toastShadowBlur = 30.0.dp
|
||||
val toastShadowY = 10.0.dp
|
||||
const val toastShadowOpacity = 0.5f
|
||||
val cardWashInset = 15.0.dp
|
||||
val heroWashInset = 40.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -420,6 +420,10 @@
|
|||
--home-toastPadding: 28.0px;
|
||||
--home-toastTop: 150.0px;
|
||||
--home-toastWidth: 760.0px;
|
||||
--home-toastPaddingY: 22.0px;
|
||||
--home-toastGap: 14.0px;
|
||||
--home-toastShadowBlur: 60.0px;
|
||||
--home-toastShadowY: 20.0px;
|
||||
--home-cardWashInset: 30.0px;
|
||||
--home-heroWashInset: 80.0px;
|
||||
--home-noteTop: 14.0px;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||
9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||
ccdd0134fc2ea069c3d8bd7717657d53544fe8393696edc282bb40e142069e97 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json
|
||||
8b351ce603c6ca39c72d41e012831ef84c082f51132b53dd45b3068ad2dc68b2 tokens.css
|
||||
3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue