Merge current TV recovery states into Conductarr wiring
This commit is contained in:
commit
a00db96dc8
43 changed files with 1041 additions and 199 deletions
|
|
@ -63,6 +63,7 @@ class ConductarrSurfaceTest {
|
|||
rule.mainClock.advanceTimeBy(500)
|
||||
rule.waitUntil(10_000) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
fake.calls.any { it.url.encodedPath == "/catalogue/titles/43/movie/ratings" }
|
||||
}
|
||||
rule.waitForIdle()
|
||||
|
|
|
|||
|
|
@ -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(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin", retry = true) }
|
||||
started.await()
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
response.completeExceptionally(IOException("fixture offline"))
|
||||
refresh.await()
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertNotNull(model.snapshot.savedAt)
|
||||
assertEquals(RecoveryMessage.StillUnreachable, model.toast)
|
||||
}
|
||||
|
||||
@Test fun personBSeesNeitherPersonAsSavedHomeNorTheirLateRefreshResult() = runBlocking {
|
||||
val facade = mockk<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(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, "justin") }
|
||||
startedA.await()
|
||||
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
model.loadLibrary(server, personB, "mindel")
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
assertNull(model.snapshot.savedAt)
|
||||
assertTrue(model.snapshot.rows.any { it.kind == HomeRowKind.State && it.focusable })
|
||||
responseA.complete(library)
|
||||
requestA.await()
|
||||
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
|
||||
assertFalse(model.snapshot.jellyfinAvailable)
|
||||
}
|
||||
|
||||
@Test fun readableJellyfinDoesNotBecomeAnOutageWhenLocalCacheCannotBeWritten() = runBlocking {
|
||||
val facade = mockk<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(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
model.loadLibrary(server, personA, "justin")
|
||||
assertTrue(model.snapshot.jellyfinAvailable)
|
||||
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
|
||||
assertNull(model.toast)
|
||||
}
|
||||
}
|
||||
|
|
@ -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(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
|
||||
}
|
||||
|
||||
@Test fun failedPlaybackReadLeavesSavedHomeFocusedAndDrawsTheExactOfflineToast() {
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||
val title = snapshot.rows.first().cards.first()
|
||||
coEvery { facade.playableId(user.id, title) } throws IOException("fixture offline")
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
|
||||
HomeScreen(shell, snapshot, modifier) { model.act(it, shell, server, user) }
|
||||
model.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
awaitModel { model.toast != null }
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals("hero", snapshot.rows[shell.view.row].id)
|
||||
assertEquals(0, shell.view.cards["hero"] ?: 0)
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "st-090-integrated.png").absolutePath)
|
||||
}
|
||||
|
||||
@Test fun successfulPlaybackUsesTheRealFullscreenDestinationAndBackKeepsTheResumeCard() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val id = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||
coEvery { facade.playableId(user.id, title) } returns id
|
||||
val view = shell.view
|
||||
view.cards["hero"] = 0
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, user) }
|
||||
awaitModel { navigation.backStack.last() is Destination.Playback }
|
||||
val destination = navigation.backStack.last() as Destination.Playback
|
||||
assertEquals(id, destination.itemId)
|
||||
assertEquals((title.resumeSeconds * 1000).toLong(), destination.positionMs)
|
||||
assertTrue(destination.fullScreen)
|
||||
navigation.goBack()
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
assertSame(view, shell.view)
|
||||
assertEquals(0, shell.view.cards["hero"])
|
||||
assertNull(model.toast)
|
||||
}
|
||||
|
||||
@Test fun anotherPersonsSessionCannotResolveOrStartPlayback() {
|
||||
val title = HomeFixture(rule.activity).snapshot("mindel").rows.first().cards.first()
|
||||
val otherUser = user.copy(name = "Justin")
|
||||
rule.runOnIdle { model.act(HomeAction.Play(title, resume = true), shell, server, otherUser) }
|
||||
awaitModel { model.toast != null }
|
||||
assertEquals(RecoveryMessage.PlaybackUnavailable, model.toast)
|
||||
assertTrue(navigation.backStack.single() is Destination.Home)
|
||||
coVerify(exactly = 0) { facade.playableId(any(), any()) }
|
||||
}
|
||||
private fun awaitModel(condition: () -> Boolean) {
|
||||
rule.waitUntil(5000) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
condition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.lastOrNull
|
|||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.justinvisser.wolfskin.conductarr.*
|
||||
import org.justinvisser.wolfskin.conductarr.model.LibraryAudience
|
||||
import org.justinvisser.wolfskin.conductarr.model.Options
|
||||
import org.justinvisser.wolfskin.conductarr.model.Page
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
|
|
@ -22,7 +23,7 @@ internal data class CatalogueLaneQuery(
|
|||
)
|
||||
|
||||
/** Per-person read-ahead keeps unvisited shelves as nonfocusable skeletons. */
|
||||
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>) {
|
||||
internal class ConductarrCatalogue(private val client: ConductarrClient, val queries: List<CatalogueLaneQuery>, private val audiences: () -> List<LibraryAudience>) {
|
||||
private data class SourcePage(val number: Int, val last: Boolean, val titles: List<DiscoverTitle>, val failed: Boolean, val total: Int, val duplicateOnly: Boolean)
|
||||
private val sources = mutableMapOf<String, SourcePage>()
|
||||
private val visited = mutableSetOf<String>()
|
||||
|
|
@ -44,7 +45,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
|
|||
}
|
||||
|
||||
suspend fun more(id: String): List<DiscoverLane> = mutex.withLock {
|
||||
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.FORCE, true); visited.add(it.id) }
|
||||
queries.firstOrNull { it.id == id }?.let { load(it, ReadTrigger.IF_STALE, true); visited.add(it.id) }
|
||||
lanes()
|
||||
}
|
||||
|
||||
|
|
@ -55,10 +56,10 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
|
|||
if (next && previous?.last == true && !previous.failed) return@async key to previous
|
||||
val page = if (next && previous != null && !previous.failed) previous.number + 1 else previous?.number ?: 1
|
||||
val call = TvRoutes.discover(media, page = page, genreId = query.genres[media], serviceId = query.service, sort = query.sort)
|
||||
val result = client.read(call, trigger).lastOrNull()
|
||||
val result = client.read(call, if (previous?.failed == true && trigger != ReadTrigger.CACHE_ONLY) ReadTrigger.FORCE else trigger).lastOrNull()
|
||||
val saved = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
|
||||
if (saved == null && trigger == ReadTrigger.CACHE_ONLY) return@async key to null
|
||||
val items = saved?.items.orEmpty().map { it.discoverTitle() }
|
||||
val items = saved?.items.orEmpty().map { it.discoverTitle(audiences()) }
|
||||
val titles = if (page > 1) (previous?.titles.orEmpty() + items).distinctBy { it.key } else items
|
||||
key to SourcePage(if (saved != null) page else previous?.number ?: 1,
|
||||
saved != null && saved.page >= saved.totalPages, if (saved != null) titles else previous?.titles.orEmpty(),
|
||||
|
|
@ -78,7 +79,7 @@ internal class ConductarrCatalogue(private val client: ConductarrClient, val que
|
|||
pages.any { it.duplicateOnly } -> DiscoverTail.ShowMore
|
||||
pages.any { !it.last } -> DiscoverTail.SeeAll
|
||||
else -> DiscoverTail.None
|
||||
}, pending = pending)
|
||||
}, pending = pending, hasMore = pages.any { !it.last })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,13 +14,15 @@ internal fun Candidate.discoverTitle(genres: Map<Long, String>): DiscoverTitle =
|
|||
catalogueImage(posterPath), catalogueImage(backdropPath), score = score,
|
||||
)
|
||||
|
||||
internal fun Title.discoverTitle(): DiscoverTitle = DiscoverTitle(
|
||||
internal fun Title.discoverTitle(audiences: List<LibraryAudience>): DiscoverTitle = DiscoverTitle(
|
||||
tmdbId, mediaType.name.lowercase(), title, releaseDate?.take(4)?.toIntOrNull(), overview = overview,
|
||||
poster = posterUrl, backdrop = backdropUrl,
|
||||
heldBy = audiences.filter { audience -> audience.libraries.orEmpty().any { it in libraries.orEmpty() } }.mapTo(mutableSetOf()) { it.owner.lowercase() },
|
||||
)
|
||||
|
||||
internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
|
||||
if (status == DiscoverStatus.NeedsSignIn) return listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||
if (status == DiscoverStatus.Loading) return listOf(HomeRow("catalogue-loading", HomeRowKind.Shelf, skeleton = true, catalogue = true))
|
||||
if (status == DiscoverStatus.Unavailable) return listOf(homeDiscoverState(HomeAvailability.Unavailable))
|
||||
val personal = listOf("movie" to "Movies for you", "tv" to "Series for you").mapNotNull { (type, label) ->
|
||||
picks.filter { it.type == type }.takeIf { it.isNotEmpty() }?.let { items ->
|
||||
|
|
@ -28,8 +30,9 @@ internal fun DiscoverSnapshot.homeRows(): List<HomeRow> {
|
|||
}
|
||||
}
|
||||
return personal + lanes.filterNot { it.id.startsWith("soon") }.mapNotNull { lane ->
|
||||
lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title,
|
||||
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = items.size, allCards = items.map { it.homeTitle() }) }
|
||||
if (lane.pending) HomeRow(lane.id, HomeRowKind.Shelf, lane.title, catalogue = true, skeleton = true)
|
||||
else lane.titles.takeIf { it.isNotEmpty() }?.let { items -> HomeRow(lane.id, HomeRowKind.Shelf, lane.title,
|
||||
items.take(WolfskinTokens.Behaviour.rowLimit.toInt()).map { it.homeTitle() }, catalogue = true, total = if (lane.hasMore) lane.total else items.size, allCards = items.map { it.homeTitle() }) }
|
||||
} + HomeRow("explore", HomeRowKind.Explore)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
private var catalogue: ConductarrCatalogue? = null
|
||||
private var options = emptyList<Options>()
|
||||
private val services = mutableMapOf<String, ConductarrCatalogue>()
|
||||
@Volatile private var libraryAudiences = emptyList<LibraryAudience>()
|
||||
fun discover(profile: String, force: Boolean = false): Flow<DiscoverSnapshot> = flow {
|
||||
val saved = discoverSnapshot(profile, ReadTrigger.CACHE_ONLY)
|
||||
if (saved.picks.isNotEmpty() || saved.lanes.any { it.titles.isNotEmpty() }) emit(saved)
|
||||
|
|
@ -34,7 +35,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
val options = optionsJobs.mapNotNull { it.await() }
|
||||
this@ConductarrProvider.options = options
|
||||
val queries = catalogueQueries(options)
|
||||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries).also { catalogue = it }
|
||||
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
|
||||
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
|
||||
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
|
||||
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) }
|
||||
|
|
@ -66,7 +67,7 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
|
||||
suspend fun service(service: DiscoverService, type: String, through: Int = 1, more: String? = null): List<DiscoverLane> {
|
||||
val key = "${service.id}:$type"
|
||||
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) }
|
||||
val loader = services.getOrPut(key) { ConductarrCatalogue(client, catalogueQueries(options, service, type)) { libraryAudiences } }
|
||||
return if (more != null) loader.more(more) else loader.through(through)
|
||||
}
|
||||
|
||||
|
|
@ -75,21 +76,28 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
|
|||
val reads = SnapshotReads(client, trigger)
|
||||
val feed = reads.read(TvRoutes.activity())
|
||||
if (feed == null && trigger == ReadTrigger.CACHE_ONLY) continue
|
||||
val artReads = SnapshotReads(client, trigger)
|
||||
val items = coroutineScope {
|
||||
feed?.items.orEmpty().map { item -> async {
|
||||
val media = if (item.mediaType == RequestProgress.MediaType.MOVIE) MediaType.MOVIE else MediaType.TV
|
||||
val art = reads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE)
|
||||
val art = artReads.read(TvRoutes.display(item.tmdbId, media), if (trigger == ReadTrigger.CACHE_ONLY) trigger else ReadTrigger.IF_STALE)
|
||||
item.comingIn().copy(poster = art?.posterUrl)
|
||||
} }.map { it.await() }
|
||||
}
|
||||
emit(ComingInSnapshot(items,
|
||||
if (feed == null) ComingInStatus.Unavailable else ComingInStatus.Ready,
|
||||
partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && feed != null))
|
||||
if (feed == null || reads.failed) ComingInStatus.Unavailable else ComingInStatus.Ready,
|
||||
partial = feed?.truncated == true || reads.failed, signedIn = !reads.needsSignIn && !artReads.needsSignIn && feed != null,
|
||||
savedAt = reads.savedTime()))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())
|
||||
suspend fun audiences(): List<LibraryAudience> = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
|
||||
suspend fun audiences(): List<LibraryAudience> {
|
||||
libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty()
|
||||
return libraryAudiences
|
||||
}
|
||||
|
||||
fun holders(title: Title): Set<String> = title.discoverTitle(libraryAudiences).heldBy
|
||||
}
|
||||
|
||||
/** A batch tracks source failure without replacing saved bodies with empty success. */
|
||||
|
|
|
|||
|
|
@ -9,15 +9,16 @@ import org.justinvisser.wolfskin.design.ScreenRatings
|
|||
|
||||
/** Failed reads are not cached as absence; saved observations survive offline. */
|
||||
class ConductarrRatings(private val client: ConductarrClient) : ScreenRatings {
|
||||
override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List<LibraryRating>? {
|
||||
override suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean): List<LibraryRating>? {
|
||||
val media = if (type == "movie") MediaType.MOVIE else MediaType.TV
|
||||
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media)).lastOrNull()) {
|
||||
val trigger = if (cachedOnly) ReadTrigger.CACHE_ONLY else ReadTrigger.IF_STALE
|
||||
val ratings = when (val result = client.read(TvRoutes.ratings(tmdb, media), trigger).lastOrNull()) {
|
||||
is ReadResult.Value -> result.saved.value?.ratings.orEmpty().screenRatings(type)
|
||||
is ReadResult.Failure -> result.saved?.value?.ratings?.screenRatings(type) ?: return null
|
||||
null -> return null
|
||||
}
|
||||
if (type != "episode" || season == null || episode == null) return ratings
|
||||
val result = client.read(TvRoutes.season(tmdb, season), ReadTrigger.IF_STALE).lastOrNull()
|
||||
val result = client.read(TvRoutes.season(tmdb, season), trigger).lastOrNull()
|
||||
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value; else -> null }
|
||||
val vote = detail?.episodes?.firstOrNull { it.number == episode.toLong() }
|
||||
val score = vote?.voteAverage?.takeIf { it.isFinite() && it > 0 && it <= 10 && (vote.voteCount ?: 0) > 0 } ?: return ratings
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ internal fun rememberDiscoverState(
|
|||
val scope = rememberCoroutineScope()
|
||||
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
|
||||
val refresh: suspend () -> DiscoverSnapshot = {
|
||||
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull() ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||
if (identity?.signedIn == true) identity.provider.discover(identity.operator!!.recommendationProfile, force = true).lastOrNull()?.copy(requestsAllowed = identity.operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||
else { retrySignIn(); DiscoverSnapshot() }
|
||||
}
|
||||
val state = remember(shell, shell.person.key, identity?.client, identity?.signedIn, identity?.error, fixtures) {
|
||||
|
|
@ -59,7 +59,7 @@ internal fun rememberDiscoverState(
|
|||
fixtureState = loaded
|
||||
if (shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
|
||||
} else if (identity?.signedIn == true) {
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it) }
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile).collect { state.updateSnapshot(it.copy(requestsAllowed = identity.operator.capabilities.requestMedia)) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.service?.id, state.filter, state) {
|
||||
|
|
@ -75,12 +75,16 @@ internal fun rememberDiscoverState(
|
|||
if (service == null) {
|
||||
state.updateLanes(identity.provider.readAhead(lane))
|
||||
val focused = state.focusedLane
|
||||
if (focused != null && focused.tail == DiscoverTail.SeeAll && column >= focused.titles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||
state.updateLanes(identity.provider.more(focused.id))
|
||||
}
|
||||
} else {
|
||||
val index = state.lanes.indexOfFirst { it.id == lane }
|
||||
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, through = index + 2)
|
||||
val focused = state.focusedLane
|
||||
if (focused != null && focused.hasMore && focused.tail == DiscoverTail.SeeAll && column >= focused.fullTitles.size - WolfskinTokens.Behaviour.nextPageCards) {
|
||||
servicePages["${service.id}:${state.filter}"] = identity.provider.service(service, state.filter, more = focused.id)
|
||||
}
|
||||
state.invalidateLanes()
|
||||
}
|
||||
}
|
||||
|
|
@ -93,7 +97,7 @@ internal fun rememberDiscoverState(
|
|||
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
|
||||
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
|
||||
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
|
||||
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size))
|
||||
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -40,11 +37,16 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
private val conductarr: ConductarrSession,
|
||||
) : ViewModel() {
|
||||
val conductarrState get() = conductarr.identity
|
||||
val audiences get() = home.audiences
|
||||
var snapshot by mutableStateOf(HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading))
|
||||
private set
|
||||
var toast by mutableStateOf<String?>(null)
|
||||
private set
|
||||
private var catalogueJob: kotlinx.coroutines.Job? = null
|
||||
private var sourceScope: String? = null
|
||||
private var loadRevision = 0L
|
||||
private var catalogueSnapshot = DiscoverSnapshot()
|
||||
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
|
||||
|
||||
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
catalogueJob?.cancel()
|
||||
|
|
@ -53,65 +55,110 @@ class WolfskinHomeViewModel @Inject constructor(
|
|||
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
|
||||
conductarr.authenticate()
|
||||
}
|
||||
val continuation = listOf(homeDiscoverState(HomeAvailability.SignedOut))
|
||||
if (person != user.name.orEmpty().lowercase() && person != conductarr.identity.value?.operator?.requester) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||
loadLibrary(server, user, person, retry)
|
||||
}
|
||||
|
||||
/** The authenticated read path is shared by release and offline fixture-driven tests. */
|
||||
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
|
||||
val revision = ++loadRevision
|
||||
if (person != user.name.orEmpty().lowercase() && !(conductarr.identity.value?.person == "${server.id}:${user.id}" && person == conductarr.identity.value?.operator?.requester)) {
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false)
|
||||
return
|
||||
}
|
||||
val scope = "${server.id}:${user.id}"
|
||||
val saved = withContext(Dispatchers.IO) { cache.read(scope) }
|
||||
if (sourceScope != scope) {
|
||||
sourceScope = scope
|
||||
toast = null
|
||||
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true), HomeAvailability.Loading)
|
||||
}
|
||||
val saved = withContext(Dispatchers.IO) { try { cache.read(scope) } catch (_: java.io.IOException) { null } }
|
||||
if (revision != loadRevision) return
|
||||
var libraryCopy = saved?.first ?: HomeLibrary()
|
||||
if (saved != null) snapshot = HomeSnapshot(buildHomeRows(saved.first, continuation))
|
||||
var libraryAvailable = true
|
||||
var librarySavedAt: String? = null
|
||||
val identity = conductarr.identity.value
|
||||
var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn)
|
||||
fun publish() {
|
||||
if (revision != loadRevision || sourceScope != scope) return
|
||||
val status = when (catalogueCopy.status) {
|
||||
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
|
||||
DiscoverStatus.Loading -> HomeAvailability.Loading
|
||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||
else -> if (catalogueCopy.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||
}
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogueCopy.homeRows(), libraryAvailable = libraryAvailable),
|
||||
status, libraryAvailable, catalogueCopy.savedAt ?: librarySavedAt)
|
||||
}
|
||||
catalogueSnapshot = catalogueCopy
|
||||
publishCatalogue = { value -> if (revision == loadRevision && sourceScope == scope) { catalogueCopy = value; catalogueSnapshot = value; publish() } }
|
||||
if (saved != null) publish()
|
||||
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
|
||||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogue)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val library = withContext(Dispatchers.IO) { home.read(user.id) }
|
||||
val savedAt = System.currentTimeMillis()
|
||||
withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* A disk failure must not turn a reachable source into an outage. */ } }
|
||||
libraryCopy = library
|
||||
withContext(Dispatchers.IO) { cache.write(scope, library, savedAt) }
|
||||
snapshot = HomeSnapshot(buildHomeRows(library, continuation))
|
||||
publish()
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
snapshot = if (saved != null) HomeSnapshot(buildHomeRows(saved.first, continuation), jellyfinAvailable = false, savedAt = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(saved.second)))
|
||||
else HomeSnapshot(buildHomeRows(HomeLibrary(), continuation, libraryAvailable = false), jellyfinAvailable = false)
|
||||
if (retry) showToast("Still can’t reach it. The last saved view stays on screen.")
|
||||
}
|
||||
val identity = conductarr.identity.value
|
||||
if (identity?.signedIn == true) catalogueJob = viewModelScope.launch {
|
||||
identity.provider.discover(identity.operator!!.recommendationProfile, retry).collect { catalogue ->
|
||||
if (conductarr.identity.value?.client !== identity.client) return@collect
|
||||
val status = when (catalogue.status) {
|
||||
DiscoverStatus.NeedsSignIn -> HomeAvailability.SignedOut
|
||||
DiscoverStatus.Unavailable -> HomeAvailability.Unavailable
|
||||
else -> if (catalogue.savedAt != null) HomeAvailability.Saved else HomeAvailability.Ready
|
||||
}
|
||||
snapshot = HomeSnapshot(buildHomeRows(libraryCopy, catalogue.homeRows(), libraryAvailable = snapshot.jellyfinAvailable),
|
||||
status, snapshot.jellyfinAvailable, catalogue.savedAt ?: snapshot.savedAt)
|
||||
}
|
||||
if (revision != loadRevision) return
|
||||
libraryAvailable = false
|
||||
librarySavedAt = saved?.second?.let { SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(it)) }
|
||||
publish()
|
||||
if (retry) showToast(RecoveryMessage.StillUnreachable)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun readAhead(lane: String?) {
|
||||
val identity = conductarr.identity.value ?: return
|
||||
if (!identity.signedIn || ConductarrPreference.fixtures) return
|
||||
val revision = loadRevision
|
||||
val lanes = identity.provider.readAhead(lane)
|
||||
if (revision == loadRevision && conductarr.identity.value?.client === identity.client) publishCatalogue?.invoke(catalogueSnapshot.copy(lanes = lanes))
|
||||
}
|
||||
|
||||
fun act(action: HomeAction, shell: ShellState, server: JellyfinServer, user: JellyfinUser) {
|
||||
when (action) {
|
||||
HomeAction.Discover -> openRoot(shell, "discover")
|
||||
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person.key, retry = true) }
|
||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else seeAll.open(shell.person.key, action.row.id, action.row.title, action.row.allCards.map { it.libraryTitle() }, navigation)
|
||||
is HomeAction.SeeAll -> if (action.row.target in shell.tabs) openRoot(shell, action.row.target!!) else openCatalogueGrid(action.row, shell)
|
||||
is HomeAction.Details -> navigation.navigateTo(Destination.Wolfskin("title:${action.title.type}:${action.title.tmdbId ?: action.title.id}"))
|
||||
is HomeAction.Play -> viewModelScope.launch {
|
||||
if (!snapshot.jellyfinAvailable || shell.person.key != user.name.orEmpty().lowercase()) {
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
val selectedId = shell.person.jellyfinUserId
|
||||
val matchesUser = if (selectedId != null) selectedId == user.id.toString() else
|
||||
shell.person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && shell.person.key == conductarr.identity.value?.operator?.requester)
|
||||
if (!snapshot.jellyfinAvailable || !matchesUser) {
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
val id = withContext(Dispatchers.IO) { home.playableId(user.id, action.title) }
|
||||
if (id == null) showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
if (id == null) showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
else navigation.navigateTo(Destination.Playback(id, if (action.resume) (action.title.resumeSeconds * 1000).toLong() else 0L))
|
||||
} catch (exception: Exception) {
|
||||
if (exception is CancellationException) throw exception
|
||||
showToast("Can’t reach Jellyfin to play. Check the network and try again.")
|
||||
showToast(RecoveryMessage.PlaybackUnavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openCatalogueGrid(row: HomeRow, shell: ShellState) {
|
||||
val identity = conductarr.identity.value
|
||||
val lane = catalogueSnapshot.lanes.firstOrNull { it.id == row.id }
|
||||
val loadMore: (suspend (Int) -> LibraryPage)? = if (identity?.signedIn == true && lane?.hasMore == true) { _ ->
|
||||
if (conductarr.identity.value?.client !== identity.client) throw java.io.IOException("Selected person changed")
|
||||
val next = identity.provider.more(lane.id).first { it.id == lane.id }
|
||||
if (next.tail == DiscoverTail.Retry) throw java.io.IOException("Catalogue unavailable")
|
||||
LibraryPage(next.fullTitles.map { it.homeTitle().libraryTitle() }, next.hasMore)
|
||||
} else null
|
||||
seeAll.open(shell.person.key, row.id, row.title, row.allCards.map { it.libraryTitle() }, navigation, loadMore)
|
||||
}
|
||||
|
||||
private fun openRoot(shell: ShellState, route: String) {
|
||||
shell.enterDestination(route)
|
||||
shell.focusNav(shell.tabs.indexOf(route))
|
||||
|
|
@ -147,12 +194,15 @@ internal fun HomeTitle.libraryTitle(): LibraryTitle = LibraryTitle(
|
|||
@Composable
|
||||
fun HomeDestination(shell: ShellState, server: JellyfinServer, user: JellyfinUser, modifier: Modifier = Modifier, model: WolfskinHomeViewModel = hiltViewModel()) {
|
||||
val identity by model.conductarrState.collectAsState()
|
||||
LaunchedEffect(shell.person.key, server.id, user.id, identity?.client, identity?.signedIn) { model.load(server, user, shell.person.key) }
|
||||
LaunchedEffect(shell.person, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { model.load(server, user, shell.person.key) }
|
||||
val view = shell.rememberedView("home")
|
||||
LaunchedEffect(view.row, model.snapshot.rows) {
|
||||
val lane = model.snapshot.rows.drop(view.row).take(3).lastOrNull { it.catalogue }?.id
|
||||
model.readAhead(lane)
|
||||
}
|
||||
Box(modifier) {
|
||||
val shown = if (!ConductarrPreference.fixtures && identity?.signedIn != true) model.snapshot.copy(conductarr = HomeAvailability.SignedOut) else model.snapshot
|
||||
HomeScreen(shell, shown, Modifier.fillMaxSize()) { model.act(it, shell, server, user) }
|
||||
model.toast?.let { message ->
|
||||
Text(message, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.body, modifier = Modifier.align(Alignment.TopEnd).padding(top = WolfskinTokens.Home.toastTop, end = WolfskinTokens.Layout.safeSide).widthIn(max = WolfskinTokens.Home.toastWidth).background(WolfskinTokens.Color.surface2).padding(WolfskinTokens.Home.toastPadding))
|
||||
}
|
||||
model.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,18 +137,26 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
|
||||
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
|
||||
|
||||
/** Requests are person-scoped; a failed refresh retains the provider's saved observations. */
|
||||
fun refreshRequests() {
|
||||
/** Capture the person's state before I/O so a late refresh cannot paint another person's sheet. */
|
||||
fun refreshRequests() = refreshRequests(retry = false)
|
||||
|
||||
private fun refreshRequests(retry: Boolean) {
|
||||
val shell = session.state ?: return
|
||||
val target = shell.requests
|
||||
val identity = conductarr.identity.value
|
||||
viewModelScope.launch {
|
||||
if (ConductarrPreference.fixtures) {
|
||||
session.state?.requests?.update(withContext(Dispatchers.IO) { requestsSnapshot })
|
||||
} else {
|
||||
val identity = conductarr.identity.value
|
||||
if (identity?.signedIn != true) session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||
else identity.provider.requests().collect { snapshot ->
|
||||
if (conductarr.identity.value?.client === identity.client) session.state?.requests?.update(snapshot)
|
||||
target.update(withContext(Dispatchers.IO) { requestsSnapshot })
|
||||
} else if (identity?.signedIn != true) {
|
||||
target.update(org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||
} else identity.provider.requests().collect { snapshot ->
|
||||
if (conductarr.identity.value?.client === identity.client) {
|
||||
target.update(if (conductarr.identity.value?.signedIn == true) snapshot else org.justinvisser.wolfskin.design.ComingInSnapshot())
|
||||
}
|
||||
}
|
||||
if (retry && target.snapshot.status == org.justinvisser.wolfskin.design.ComingInStatus.Unavailable && shell.requests === target) {
|
||||
shell.toast = org.justinvisser.wolfskin.design.RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +169,7 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
|
||||
ShellAction.RetryRequests -> viewModelScope.launch {
|
||||
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
|
||||
refreshRequests()
|
||||
refreshRequests(retry = true)
|
||||
}
|
||||
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
|
||||
}
|
||||
|
|
@ -267,7 +275,7 @@ fun WolfskinApp(
|
|||
ShellSurface(state, handlesContentKeys = upstream == null,
|
||||
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
|
||||
when {
|
||||
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
|
||||
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
|
||||
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
|
||||
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") || route.startsWith("library-title/") ->
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ fun WolfskinLibraryContent(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val view = shell.rememberedView(route)
|
||||
val fixture by produceState<LibraryFixture?>(null, context) {
|
||||
val fixture by produceState<LibraryFixture?>(null, context, ConductarrPreference.fixtures) {
|
||||
value = null
|
||||
if (ConductarrPreference.fixtures) value = withContext(Dispatchers.IO) {
|
||||
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
|
||||
}
|
||||
|
|
@ -62,7 +63,7 @@ fun WolfskinLibraryContent(
|
|||
return
|
||||
}
|
||||
val grid = remember(shell.person.key, route, view) {
|
||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/")) else LibraryGridState(
|
||||
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
|
||||
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
|
||||
onTitle = { navigation.navigateTo(Destination.Wolfskin("library-title/${it.id}")) },
|
||||
onDiscover = { shell.enterDestination("discover"); navigation.replace(Destination.Wolfskin("discover")) },
|
||||
|
|
@ -73,7 +74,7 @@ fun WolfskinLibraryContent(
|
|||
ShellPlaceholder("See all list unavailable", modifier)
|
||||
return
|
||||
}
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture) {
|
||||
LaunchedEffect(grid, grid.refreshRevision, fixture, if (route in LIBRARY_DESTINATIONS) facade.audiences else null) {
|
||||
if (route in LIBRARY_DESTINATIONS && (!ConductarrPreference.fixtures || fixture != null)) {
|
||||
val wasLoading = grid.status == LibraryStatus.Loading
|
||||
if (fixture != null) {
|
||||
|
|
@ -84,6 +85,8 @@ fun WolfskinLibraryContent(
|
|||
grid.source = snapshot.items
|
||||
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
|
||||
grid.savedAt = if (snapshot.unreachable) snapshot.savedAtMillis?.let { DateTimeFormatter.ofPattern("HH:mm").format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) } else null
|
||||
grid.tail = if (snapshot.unreachable && snapshot.items.isNotEmpty()) GridTail.Retry else GridTail.None
|
||||
if (snapshot.unreachable && grid.refreshRevision > 0) shell.toast = RecoveryMessage.StillUnreachable
|
||||
}
|
||||
}
|
||||
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
|
|||
|
||||
/** All Home SDK calls and image URLs stay behind this facade, with their own required field list. */
|
||||
class HomeReadFacade @Inject constructor(private val api: ApiClient, private val images: ImageUrlService, private val libraries: LibraryReadFacade) {
|
||||
val audiences get() = libraries.audiences
|
||||
suspend fun read(userId: UUID): HomeLibrary {
|
||||
val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items
|
||||
val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
|
|
@ -18,7 +22,7 @@ import javax.inject.Inject
|
|||
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
||||
@javax.inject.Singleton
|
||||
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||
var audiences: List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>? = null
|
||||
var audiences by mutableStateOf<List<org.justinvisser.wolfskin.conductarr.model.LibraryAudience>?>(null)
|
||||
|
||||
fun destination(name: String, collection: CollectionType?): String? {
|
||||
audiences?.let { known ->
|
||||
|
|
|
|||
156
docker/README.md
156
docker/README.md
|
|
@ -92,10 +92,10 @@ Settings list. Native Up traversal stays in content until the first focusable ro
|
|||
then enters the nav. Turning the switch on renders Wolfskin Home and Libraries.
|
||||
Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled.
|
||||
|
||||
Debug people, tabs and initials come from the shell-only projection of the locked
|
||||
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
||||
do not package it. Saved Jellyfin identities use existing upstream services. The
|
||||
shell makes no Conductarr calls and does not add Conductarr sign-in.
|
||||
Saved Jellyfin identities use the existing upstream session. Conductarr supplies
|
||||
household labels, requester capabilities and library audiences after sign-in.
|
||||
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
|
||||
only when the Advanced debug fixture switch is enabled; release excludes it.
|
||||
|
||||
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
|
||||
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
|
||||
|
|
@ -104,13 +104,12 @@ request and pick actions. Unheld titles have no trailer control until the pinned
|
|||
Conductarr title contract supplies a trailer.
|
||||
|
||||
The debug-only fixture and local art live in
|
||||
`app/src/debug/assets/wolfskin-discover/`. Release builds remain on the explicit
|
||||
Conductarr sign-in state. The fixture host cannot construct a network client:
|
||||
debug request outcomes and dismissals stay in memory. The real mutation adapter
|
||||
accepts an injected `ConductarrClient` and uses only its typed routes; it is not
|
||||
wired to a live server. Unknown request outcomes force a fresh title-requests read
|
||||
instead of blindly submitting again, including after closing a sheet or choosing
|
||||
the same uncertain recipient from another chooser. Whole-series requests require every season.
|
||||
`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
|
||||
network-free fixture host; real providers are the default in debug and release.
|
||||
The mutation adapter accepts the selected person's `ConductarrClient` and uses
|
||||
only typed routes. Request submission first reads title receipts. Unknown outcomes
|
||||
are checked, never blindly re-sent, including after closing a sheet or choosing
|
||||
the same uncertain recipient again. Whole-series requests require season metadata.
|
||||
|
||||
Discover hero and Restore text use explicit per-line CSS allocations so native
|
||||
paragraph overflow cannot enlarge the bottom-anchored stack. Restore subtitles
|
||||
|
|
@ -205,15 +204,15 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
|
|||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||||
episode and checks Jellyfin before opening upstream's player. No season,
|
||||
recommendation stub or different person's session is promoted to playback.
|
||||
An unresolved person switch shows the same sign-in guard for Home as for native
|
||||
library content, before mounting Home's source reads.
|
||||
An unresolved person switch guards Home and both owned/upstream library content
|
||||
before mounting any source read with the prior person's Jellyfin session.
|
||||
|
||||
Debug variants additionally package the locked fixture and local artwork under
|
||||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||||
The fixture provider supplies the approved household data and Home states without
|
||||
network reads in tests. Conductarr sign-in is not implemented here: real signed-out
|
||||
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is
|
||||
used. Catalogue data and ratings in debug captures are fixture-backed.
|
||||
The explicit fixture provider supplies approved household data and Home states
|
||||
without network reads in tests. Real signed-out Home hides every rating and shows
|
||||
"Discover needs sign-in"; signed-in Home reads the Conductarr continuation and
|
||||
ratings-only endpoint. No Seerr fallback is used.
|
||||
|
||||
Home, Libraries and Discover reuse `TitleCard`, `Caption` and `LibrarySeeAllCard`;
|
||||
those screens and Requests share one `WolfskinImages` loader. Exact-slot requests use RGB565 for posters,
|
||||
|
|
@ -251,13 +250,12 @@ for Back, and closing the sheet restores its opener. Selecting a root or changin
|
|||
person clears the title-return link. Shared's authenticated actor always reads
|
||||
“by Shared”, never “by you”.
|
||||
|
||||
The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and
|
||||
its eight local posters, projected from the locked prototype fixture. Six stage
|
||||
rows feed the running debug shell; the Shared and focused-rating records feed
|
||||
native acceptance states. The identical projection is in design-test resources.
|
||||
No “sample” or “example stages” pill is drawn. Release builds package none of
|
||||
these assets and show Requests unavailable until Conductarr sign-in supplies
|
||||
the generated-client data path. There is no Seerr fallback or handwritten HTTP.
|
||||
The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
|
||||
its eight local posters. Shared and focused-rating records also feed the native
|
||||
acceptance states; the identical projection is in design-test resources.
|
||||
No “sample” or “example stages” pill is drawn. The default provider instead maps
|
||||
Conductarr household activity, authenticated actors and display artwork into
|
||||
the same sheet. Release excludes all fixture assets. There is no Seerr fallback.
|
||||
Foreground refreshes once; an open sheet or unsettled requests refresh every
|
||||
30 seconds while started. The immutable debug projection is loaded once, not
|
||||
decoded again on every poll.
|
||||
|
|
@ -299,8 +297,7 @@ uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py
|
|||
`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080,
|
||||
density 2.0. Title selection currently uses an explicitly labelled
|
||||
`request-title/...` destination until the independent title screen is integrated;
|
||||
this slice does not claim to implement title pages, TV sign-in, release or device
|
||||
deployment.
|
||||
title pages, release and device deployment remain outside the Requests component.
|
||||
|
||||
## Libraries and See all
|
||||
|
||||
|
|
@ -323,8 +320,8 @@ The debug-only `wolfskin-library.json` and `library/assets/` contain the locked
|
|||
prototype's titles and local poster art. Release libraries read through
|
||||
`LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person
|
||||
snapshots using the existing atomic disk cache. Saved data is painted before a
|
||||
quiet refresh; a failed refresh keeps it. Production ratings remain hidden until
|
||||
Conductarr sign-in exists.
|
||||
quiet refresh; a failed refresh keeps it. Production ratings remain hidden before
|
||||
Conductarr sign-in and use the shared ratings-only provider afterwards.
|
||||
|
||||
Home/Discover shelves open a full list through
|
||||
`WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`.
|
||||
|
|
@ -340,6 +337,109 @@ the title-screen implementation; this slice does not implement title pages.
|
|||
failure/retry, stale copies, and person/server isolation. Library captures are written
|
||||
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
|
||||
|
||||
## Offline states and recovery
|
||||
|
||||
Owned screens share `RecoveryToast`: the warning glyph, 760 px maximum width,
|
||||
22/28 px padding, 14 px icon gap, 22 px corners and CSS-sized body line boxes.
|
||||
Network failures never expose transport exception text. Playback failure reads
|
||||
“Can’t reach Jellyfin to play. Check the network and try again.” and leaves the
|
||||
originating card focused; no simulated player hand-off panel is shipped. Retry
|
||||
failure reads “Still can’t reach it. The last saved view stays on screen.”
|
||||
|
||||
Library headers and the nav publish the same saved-copy timestamp. Failed library
|
||||
refreshes retain cards and expose a reachable Try again tail. Discover retains its
|
||||
current catalogue after a failed refresh, adds a recovery tail and disables request
|
||||
and dismissal writes until recovery; a revoked sign-in discards that saved projection.
|
||||
Previously observed plates can remain visible offline, but moving to an uncached
|
||||
card cannot start a ratings read. `SavedPill` shares the compact cached-header
|
||||
treatment across Home, Libraries and Requests; the nav retains its larger wifi chip.
|
||||
|
||||
Requests focus, observed ratings and retained rows are partitioned by selected person.
|
||||
A failed authenticated read preserves the last rows and their timestamp; Down from
|
||||
the final row reaches Try again and Up returns to the row. Signed-out data is not
|
||||
retained as an authenticated copy. Returning to a foreground person triggers that
|
||||
person's refresh rather than waiting for the prior person's polling timer.
|
||||
|
||||
Home disk snapshots remain server/user scoped. A new identity starts with its own
|
||||
skeleton or saved copy, never the previous person's projection; late responses from
|
||||
an older read cannot overwrite the current identity. A disk-write failure does not
|
||||
turn a successful Jellyfin read into an outage. Skeleton-only Home, Library and
|
||||
Discover screens keep focus in the nav even when Down is pressed repeatedly.
|
||||
|
||||
Library/Discover posters use the same title/glyph/160-degree gradient fallback as
|
||||
Home, including an actual Coil read failure. Discover hero image failures use the
|
||||
shared ambient background; missing artwork is not a successful data refresh.
|
||||
|
||||
`RecoveryStateTest`, `RecoveryCaptureTest`, `HomeRecoveryTest` and `PlayerHandoffTest`
|
||||
exercise state transitions, person isolation, native recovery focus/geometry and the
|
||||
real upstream playback destination with offline fixtures. New native captures and
|
||||
rendered text/font metrics go to `build/wolfskin-captures/recovery/`; Dutch score
|
||||
captures configure the Android resource locale as well as the JVM locale.
|
||||
|
||||
Conductarr transport and identity live outside the state components. A revoked
|
||||
sign-in discards the current person's authenticated projections; an outage keeps
|
||||
last-known-good (LKG) data and its Saved timestamp. No Seerr fallback or offline
|
||||
request queue is used. Title-family screen adoption remains a separate integration.
|
||||
|
||||
## Conductarr production wiring
|
||||
|
||||
The default endpoint is `BuildConfig.CONDUCTARR_BASE_URL`:
|
||||
`https://wolfskin-tv.justinvisser.org`. Override **Conductarr server URL** beside
|
||||
**Wolfskin screens** in Advanced settings; blank restores the build default.
|
||||
HTTPS is required except for loopback fake servers. **Wolfskin preview fixtures**
|
||||
is a debug-only, off-by-default switch; it never sends HTTP mutations.
|
||||
|
||||
`ConductarrSession` borrows the currently selected user's existing Jellyfin access
|
||||
token. It sends only `Authorization: MediaBrowser Token="<token>"`, with no cookie,
|
||||
API key or second credential store. Person/server/token/endpoint changes discard
|
||||
the old client and in-memory providers. LKG files are isolated by person and client
|
||||
identity in app-private, backup-excluded storage. A 401 clears that identity's
|
||||
cache and returns to sign-in states; Try again reconstructs and verifies the client.
|
||||
|
||||
| Screen seam | Production supplier |
|
||||
| --- | --- |
|
||||
| People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins |
|
||||
| Home continuation | Recommendation feed, catalogue options/discovery; Coming soon stays Discover-only |
|
||||
| Discover | Feed/contributions, services, New on, Trending, genres, Coming soon, parameterized service shelves, dismissal reads/writes |
|
||||
| Catalogue paging | Two-shelf read-ahead, distinct title identities, per-parameter disk pages, retry/duplicate-only tails; no page counters |
|
||||
| Request sheet | Capability-limited requesters, season metadata, POST receipt, receipt lookup before any resubmit; actor comes only from the token |
|
||||
| Requests | Household activity, stage precedence, actor/requester evidence and display posters; foreground/30-second refresh |
|
||||
| Ratings | Ratings-only read after 350 ms card dwell; immediate hero/title/Coming-in reads; per-type six-hour/300-entry cache; episode season votes |
|
||||
| Additive title provider | Detail, cast, related, season/votes, performer, filmography, enrichment, display, title activity and uncached title receipts |
|
||||
|
||||
The pinned client exposes exactly the 22 TV operations, including ratings and
|
||||
display. Conditional reads send ETags and reuse the saved body on 304. The fake
|
||||
server tests use representative wire JSON, never a live Conductarr or Jellyfin.
|
||||
`ConductarrSessionTest`, `ConductarrProjectionTest` and `ConductarrSurfaceTest`
|
||||
cover identity/cache isolation, 401/retry, saved offline data, actor-safe requests,
|
||||
ratings timing and a native real-provider Discover surface. The existing fixture
|
||||
screen tests remain in the normal `make test` gate.
|
||||
|
||||
Not done here: adopting the new provider inside the independently reviewed title
|
||||
screen family; deploying the dedicated hostname or widening server authority;
|
||||
hardware installation/playback verification; releases, signing or version changes.
|
||||
The existing title integration destinations remain explicit rather than being
|
||||
silently replaced with invented catalogue/player data.
|
||||
|
||||
### Manual smoke after endpoint deployment (no build command)
|
||||
|
||||
Use an already authorized test build and a sandbox that accepts its selected
|
||||
Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin.
|
||||
|
||||
1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared,
|
||||
then another saved person; verify their operator/requester and independent views.
|
||||
2. Open Home, Discover, a service, Hidden picks and Requests. Check real catalogue
|
||||
text, stage/actor labels, card dwell ratings and continuous See all loading.
|
||||
3. Make the sandbox return 503 after a successful read. Reopen: saved content and
|
||||
Saved time remain, requests cannot submit, and Try again performs another read.
|
||||
4. Return 401, then restore authentication. Verify ratings/request actions disappear;
|
||||
Try again restores data without changing Jellyfin login or copying a token.
|
||||
5. Have the sandbox accept a Shared-on-behalf request but lose its POST response.
|
||||
Use Check again: the receipt says Shared, and the sandbox records one POST.
|
||||
6. Restore the blank/default URL when the dedicated TV hostname and server's
|
||||
22-operation authority are deployed. This checklist is manual, not a live gate.
|
||||
|
||||
|
||||
## Toolchain and disk use
|
||||
|
||||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import androidx.compose.ui.res.painterResource
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.tv.material3.Text
|
||||
import java.util.Locale
|
||||
|
|
@ -31,6 +32,8 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
|
|||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
|
||||
val mark = when (rating.mark) {
|
||||
"fresh" -> R.drawable.coming_in_rating_fresh
|
||||
"tomato" -> R.drawable.coming_in_rating_fresh
|
||||
"popcorn" -> R.drawable.coming_in_rating_upright
|
||||
"rotten" -> R.drawable.coming_in_rating_rotten
|
||||
"upright" -> R.drawable.coming_in_rating_upright
|
||||
"spilled" -> R.drawable.coming_in_rating_spilled
|
||||
|
|
@ -40,7 +43,8 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
|
|||
val wide = rating.mark == "imdb" || rating.mark == "tmdb"
|
||||
Image(painterResource(mark), contentDescription = rating.mark,
|
||||
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
|
||||
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3),
|
||||
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3)
|
||||
.graphicsLayer { alpha = if (rating.mark == "tomato" || rating.mark == "popcorn") WolfskinTokens.Library.unknownCriticOpacity else 1f },
|
||||
contentScale = ContentScale.Fit)
|
||||
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,10 +39,10 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
val state = shell.requests
|
||||
val data = state.snapshot
|
||||
val ratingsReader = LocalScreenRatings.current
|
||||
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
|
||||
LaunchedEffect(state, state.focusedId, data.signedIn, data.status) {
|
||||
if (ratingsReader == null) state.awaitFocusedRatings()
|
||||
else if (data.signedIn) state.focused?.let { item ->
|
||||
ratingsReader.read(item.tmdbId, item.mediaType, null, null)?.let { values ->
|
||||
ratingsReader.read(item.tmdbId, item.mediaType, null, null, cachedOnly = data.status == ComingInStatus.Unavailable)?.let { values ->
|
||||
state.updateRatings(item.id, values.map { rating ->
|
||||
val mapped = rating.homeRating()
|
||||
ComingInRating(mapped.mark, mapped.score, mapped.percentage)
|
||||
|
|
@ -90,27 +90,30 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
|
|||
}
|
||||
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
|
||||
else -> {
|
||||
if (data.status == ComingInStatus.Unavailable) Text("Conductarr didn’t answer.", style = ComingInTypography.caption, color = C.warn)
|
||||
data.savedAt?.takeIf { data.status == ComingInStatus.Unavailable }?.let { SavedPill(it) }
|
||||
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
|
||||
val start = state.windowStart
|
||||
val visibleRows = remember(data, start, state.windowSize) {
|
||||
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
|
||||
}
|
||||
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
LazyColumn(Modifier.clipToBounds().then(if (data.status == ComingInStatus.Unavailable) Modifier.weight(1f, fill = false) else Modifier), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
|
||||
itemsIndexed(visibleRows,
|
||||
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
|
||||
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
ComingInRow(item, shell.person, !state.recoveryFocused && item.id == state.focusedId, state.expanded && item.id == state.focusedId,
|
||||
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
|
||||
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
|
||||
}
|
||||
}
|
||||
if (data.status == ComingInStatus.Unavailable) SheetButton("Try again", primary = true, focused = state.recoveryFocused) { shell.retryRequests() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick = onClick) }
|
||||
private fun SheetButton(label: String, primary: Boolean = false, focused: Boolean = true, onClick: () -> Unit) {
|
||||
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, focused, primary, onClick = onClick) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -103,9 +110,14 @@ class ComingInState {
|
|||
|
||||
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
|
||||
fun key(key: ShellKey): Boolean {
|
||||
if (recoveryFocused) {
|
||||
if (key == ShellKey.Up) recoveryFocused = false
|
||||
return false
|
||||
}
|
||||
when (key) {
|
||||
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
|
||||
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
|
||||
ShellKey.Down -> if (snapshot.status == ComingInStatus.Unavailable && index == snapshot.ordered.lastIndex) recoveryFocused = true
|
||||
else focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
|
||||
ShellKey.Right -> if (focused != null) expanded = true
|
||||
ShellKey.Left -> expanded = false
|
||||
ShellKey.Ok -> return focused != null
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -29,9 +29,15 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
|
|||
snapshot.lanes.forEach { lane ->
|
||||
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
|
||||
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
|
||||
if (items.isNotEmpty() || lane.pending || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
|
||||
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
|
||||
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
|
||||
val paged = lane.hasMore
|
||||
val tail = when {
|
||||
lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore -> lane.tail
|
||||
paged && items.isEmpty() -> DiscoverTail.ShowMore
|
||||
paged || (!lane.id.startsWith("new-") && source.size > items.size) -> DiscoverTail.SeeAll
|
||||
else -> DiscoverTail.None
|
||||
}
|
||||
if (items.isNotEmpty() || lane.pending || tail == DiscoverTail.Retry || tail == DiscoverTail.ShowMore) {
|
||||
result += lane.copy(titles = items, fullTitles = source, total = if (paged) lane.total else if (lane.id.startsWith("new-")) items.size else source.size, tail = tail)
|
||||
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ data class DiscoverLane(
|
|||
val hidden: Boolean = false,
|
||||
val fullTitles: List<DiscoverTitle> = titles,
|
||||
val pending: Boolean = false,
|
||||
val hasMore: Boolean = false,
|
||||
)
|
||||
|
||||
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
|
|
@ -46,21 +49,22 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } }
|
||||
}
|
||||
LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt }
|
||||
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
|
||||
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
|
||||
val ratingsReader = LocalScreenRatings.current
|
||||
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
|
||||
LaunchedEffect(focusedTitle?.key, state.contentFocused, state.snapshot.savedAt, state.hero?.key, state.title?.key, state.isSignedIn) {
|
||||
state.plateKey = null
|
||||
if (state.contentFocused && focusedTitle != null) {
|
||||
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null)?.let { state.updateRatings(focusedTitle, it) }
|
||||
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.hero?.key, state.title?.key, state.isSignedIn) {
|
||||
if (state.isSignedIn && ratingsReader != null) {
|
||||
val title = state.title ?: state.hero
|
||||
if (title != null) ratingsReader.read(title.tmdb, title.type, null, null)?.let { state.updateRatings(title, it) }
|
||||
coroutineScope {
|
||||
if (state.isSignedIn && ratingsReader != null) launch {
|
||||
val prominent = state.title ?: state.hero
|
||||
if (prominent != null) ratingsReader.read(prominent.tmdb, prominent.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(prominent, it) }
|
||||
}
|
||||
if (state.title == null && state.contentFocused && focusedTitle != null && (state.snapshot.savedAt == null || focusedTitle.key in state.observedRatings || ratingsReader != null)) launch {
|
||||
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null, cachedOnly = state.snapshot.savedAt != null)?.let { state.updateRatings(focusedTitle, it) }
|
||||
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
|
||||
|
|
@ -76,11 +80,11 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
|
|||
val density = LocalDensity.current
|
||||
val list = shell.view.listState
|
||||
val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)
|
||||
val heights = remember(state.hasHero, state.lanes, note, state.canRequest) {
|
||||
val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) {
|
||||
buildList {
|
||||
add(if (state.hasHero) L.row_phero else L.row_lhead)
|
||||
add(if (state.canRequest) L.row_chips else WolfskinTokens.Shell.zero)
|
||||
if (!state.canRequest) add(L.row_state) else {
|
||||
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
|
||||
if (!state.hasCatalogue) add(L.row_state) else {
|
||||
if (note) add(L.row_note)
|
||||
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ class DiscoverState(
|
|||
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
|
||||
private val requesters: List<ShellPerson> = shell.people,
|
||||
private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null,
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
) : ShellContentFocus {
|
||||
var snapshot by mutableStateOf(initial)
|
||||
private set
|
||||
private var savedTime = initial.savedAt ?: formatSavedTime()
|
||||
private var serviceFilter by mutableStateOf("movie")
|
||||
var filter: String
|
||||
get() = if (service != null) serviceFilter else DISCOVER_FILTERS[shell.rememberedView("discover").cards["filter"] ?: 0]
|
||||
|
|
@ -60,12 +62,15 @@ class DiscoverState(
|
|||
private var cachedHeroes = emptyList<DiscoverTitle>()
|
||||
private val dismissalMutex = Mutex()
|
||||
private val dismissalVersions = mutableMapOf<String, Int>()
|
||||
private val pendingDismissals = mutableMapOf<String, DiscoverTitle?>()
|
||||
private var rootFilter = "all"
|
||||
|
||||
fun updateSnapshot(value: DiscoverSnapshot) {
|
||||
snapshot = value
|
||||
if (value.savedAt == null && value.status in setOf(DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable)) savedTime = formatSavedTime()
|
||||
hidden.clear()
|
||||
value.hidden.forEach { hidden[it.key] = it }
|
||||
pendingDismissals.forEach { (key, item) -> if (item == null) hidden.remove(key) else hidden[key] = item }
|
||||
revision++
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +78,9 @@ class DiscoverState(
|
|||
fun updateLanes(value: List<DiscoverLane>) { snapshot = snapshot.copy(lanes = value); revision++ }
|
||||
|
||||
private suspend fun loadLane(id: String) {
|
||||
if (id == "offline-recovery") { reload(); return }
|
||||
val loaded = onMore?.invoke(id)
|
||||
if (loaded != null) updateLanes(loaded) else { snapshot = operations.refresh(); revision++ }
|
||||
if (loaded != null) updateLanes(loaded) else reload()
|
||||
}
|
||||
|
||||
fun updateTitle(value: DiscoverTitle) {
|
||||
|
|
@ -94,13 +100,14 @@ class DiscoverState(
|
|||
revision++
|
||||
}
|
||||
|
||||
override val hasContent: Boolean get() = snapshot.status != DiscoverStatus.Loading
|
||||
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
|
||||
val canRequest: Boolean get() = when (snapshot.status) {
|
||||
val hasCatalogue: Boolean get() = when (snapshot.status) {
|
||||
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
|
||||
else -> false
|
||||
}
|
||||
val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed
|
||||
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
|
||||
val canRequest: Boolean get() = hasCatalogue && snapshot.savedAt == null && snapshot.reachable && snapshot.requestsAllowed
|
||||
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && hasCatalogue
|
||||
val heroes: List<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()
|
||||
|
|
@ -165,6 +172,7 @@ class DiscoverState(
|
|||
scope.launch {
|
||||
loadLane(lane.id)
|
||||
if (collection !== grid) return@launch
|
||||
grid.savedAt = snapshot.savedAt
|
||||
val fresh = lanes.firstOrNull { it.id == lane.id }
|
||||
if (canRequest && fresh != null) {
|
||||
titles = fresh.fullTitles.associateBy { it.key }
|
||||
|
|
@ -176,9 +184,10 @@ class DiscoverState(
|
|||
} else grid.tail = GridTail.Retry
|
||||
}
|
||||
}).apply {
|
||||
savedAt = snapshot.savedAt
|
||||
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
|
||||
}
|
||||
if (onMore != null && lane.tail != DiscoverTail.None) grid.pagination = LibraryPagination(grid) {
|
||||
if (onMore != null && lane.hasMore) grid.pagination = LibraryPagination(grid) {
|
||||
loadLane(lane.id)
|
||||
val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable")
|
||||
if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable")
|
||||
|
|
@ -186,7 +195,7 @@ class DiscoverState(
|
|||
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
|
||||
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
|
||||
}
|
||||
LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.tail != DiscoverTail.None)
|
||||
LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.hasMore)
|
||||
}
|
||||
collection = grid
|
||||
onPush("discover-see-all/${lane.id}")
|
||||
|
|
@ -231,7 +240,7 @@ class DiscoverState(
|
|||
return true
|
||||
}
|
||||
if (title != null) { titleKey(key); return true }
|
||||
if (!canRequest) {
|
||||
if (!hasCatalogue) {
|
||||
if (key == ShellKey.Up) shell.focusNav(shell.currentTab) else if (key == ShellKey.Ok) refresh()
|
||||
return true
|
||||
}
|
||||
|
|
@ -289,7 +298,6 @@ class DiscoverState(
|
|||
|
||||
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
|
||||
if (!canRequest) { refresh(); return }
|
||||
if (!requestsEnabled) { toast = "Can’t request right now. Try again."; return }
|
||||
val choice = RequestSheetState(item, shell.person, requesters, other, phase)
|
||||
val previous = requestSheets[item.key to choice.selectedPerson.key]
|
||||
overlay = DiscoverOverlayState.Request(
|
||||
|
|
@ -301,8 +309,8 @@ class DiscoverState(
|
|||
when (val sheet = overlay) {
|
||||
is DiscoverOverlayState.Request -> sheet.state.key(key, ::send, { overlay = null }) {
|
||||
scope.launch {
|
||||
snapshot = operations.refresh(); revision++
|
||||
sheet.state.phase = if (snapshot.status == DiscoverStatus.Ready) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
|
||||
reload()
|
||||
sheet.state.phase = if (canRequest) RequestPhase.Choose else RequestPhase.RecipientsUnavailable
|
||||
sheet.state.focus = sheet.state.buttonIndex
|
||||
}
|
||||
}
|
||||
|
|
@ -323,7 +331,7 @@ class DiscoverState(
|
|||
}
|
||||
|
||||
private fun send(sheet: RequestSheetState, check: Boolean) {
|
||||
if (!requestsEnabled || (!check && !sheet.canSubmit)) return
|
||||
if (!canRequest || (!check && !sheet.canSubmit)) return
|
||||
if (!check) {
|
||||
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
|
||||
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {
|
||||
|
|
@ -354,8 +362,10 @@ class DiscoverState(
|
|||
val profile = shell.person.key
|
||||
val version = (dismissalVersions[item.key] ?: 0) + 1
|
||||
dismissalVersions[item.key] = version
|
||||
pendingDismissals[item.key] = item
|
||||
scope.launch {
|
||||
val success = dismissalMutex.withLock { operations.hide(profile, item) }
|
||||
if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key)
|
||||
if (!success && dismissalVersions[item.key] == version) { hidden.remove(item.key); revision++; updateCollectionCards(); overlay = null; toast = "Couldn’t hide this pick. Try again." }
|
||||
}
|
||||
}
|
||||
|
|
@ -367,18 +377,33 @@ class DiscoverState(
|
|||
val profile = shell.person.key
|
||||
val version = (dismissalVersions[item.key] ?: 0) + 1
|
||||
dismissalVersions[item.key] = version
|
||||
pendingDismissals[item.key] = null
|
||||
scope.launch {
|
||||
val success = dismissalMutex.withLock { operations.restore(profile, item) }
|
||||
if (dismissalVersions[item.key] == version) pendingDismissals.remove(item.key)
|
||||
if (!success && dismissalVersions[item.key] == version) { hidden[item.key] = item; revision++; updateCollectionCards(); toast = "Couldn’t restore this pick. Try again." }
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() { scope.launch { snapshot = operations.refresh(); revision++; toast = if (snapshot.status == DiscoverStatus.Unavailable) "Still can’t reach it. The last saved view stays on screen." else null } }
|
||||
private fun matches(item: DiscoverTitle): Boolean = filter == "all" || item.type == filter
|
||||
fun refresh() { scope.launch { reload() } }
|
||||
|
||||
private fun buildLanes(): List<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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,12 +41,13 @@ fun HomeRatingsRow(ratings: List<HomeRating>, modifier: Modifier = Modifier, fon
|
|||
val resource = when (rating.mark) {
|
||||
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
|
||||
"upright" -> R.drawable.ic_rt_upright
|
||||
"popcorn" -> R.drawable.ic_rt_upright
|
||||
"spilled" -> R.drawable.ic_rt_spilled
|
||||
"imdb" -> R.drawable.ic_rating_imdb
|
||||
"tmdb" -> R.drawable.ic_rating_tmdb
|
||||
else -> R.drawable.ic_rotten_tomatoes_fresh
|
||||
}
|
||||
val muted = rating.mark == "tomato"
|
||||
val muted = rating.mark == "tomato" || rating.mark == "popcorn"
|
||||
Image(painterResource(resource), null, Modifier.size(size * if (rating.mark == "imdb" || rating.mark == "tmdb") 1.8f else 1f, size).graphicsLayer { alpha = if (muted) .55f else 1f }, colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
|
||||
if (rating.episode) Text("Ep", color = C.muted, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontWeight = T.label.fontWeight), maxLines = 1)
|
||||
Text(rating.format(), color = C.fg, style = T.ratingPlate.copy(fontSize = fontSize, lineHeight = fontSize * 1.2f, fontFeatureSettings = "tnum"), maxLines = 1, softWrap = false)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import androidx.compose.ui.semantics.semantics
|
|||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
|
@ -87,15 +89,18 @@ fun HomeScreen(
|
|||
var heroRatings by remember(hero?.id) { mutableStateOf(hero?.let(cache::read).orEmpty()) }
|
||||
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
|
||||
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
|
||||
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
|
||||
val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) }
|
||||
if (values != null) { heroRatings = values; cache.save(hero, values) }
|
||||
}
|
||||
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
|
||||
val saved = cache.read(title)
|
||||
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode)?.map(LibraryRating::homeRating) }
|
||||
if (values != null) { focusedRatings = values; cache.save(title, values) }
|
||||
val available = snapshot.conductarr == HomeAvailability.Ready || snapshot.conductarr == HomeAvailability.Saved
|
||||
coroutineScope {
|
||||
if (hero != null && available) launch {
|
||||
val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) }
|
||||
if (values != null) { heroRatings = values; cache.save(hero, values) }
|
||||
}
|
||||
if (title != null && row?.kind != HomeRowKind.Hero && !shell.navFocused && !shell.pickerVisible && available) launch {
|
||||
val saved = cache.read(title)
|
||||
if (saved == null) delay(WolfskinTokens.Motion.ratingDwell.toLong())
|
||||
val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode, cachedOnly = snapshot.conductarr == HomeAvailability.Saved)?.map(LibraryRating::homeRating) }
|
||||
if (values != null) { focusedRatings = values; cache.save(title, values) }
|
||||
}
|
||||
}
|
||||
}
|
||||
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ class LibraryGridState(
|
|||
}
|
||||
val railVisible: Boolean get() = !catalogue && sort == 0 && items.size >= WolfskinTokens.Behaviour.letterRailMin && status == LibraryStatus.Ready
|
||||
val tailIndex: Int get() = ((items.size + columns - 1) / columns) * columns
|
||||
override val hasContent: Boolean get() = status != LibraryStatus.Loading
|
||||
override val cardKey: String? get() = if (toolbar || inRail || status != LibraryStatus.Ready || items.isEmpty()) null else items.getOrNull(index)?.id ?: "grid-tail"
|
||||
val focusedTitle: LibraryTitle? get() = if (toolbar || inRail) null else items.getOrNull(index)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,14 +17,12 @@ import androidx.compose.ui.graphics.ColorMatrix
|
|||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
|
||||
|
||||
|
|
@ -41,11 +39,9 @@ fun LibraryPoster(
|
|||
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
|
||||
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
|
||||
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
else if (title.poster != null && offlineArt == null) {
|
||||
val context = LocalContext.current
|
||||
val request = remember(title.poster, context) { WolfskinImages.request(context, title.poster, (WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt(), true) }
|
||||
AsyncImage(request, title.title, imageLoader = WolfskinImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
} else MissingLibraryArt(title)
|
||||
else if (offlineArt == null) TitleArtwork(title.title, title.type, title.poster,
|
||||
WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
|
||||
else TitleArtwork(title.title, title.type, null, WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH, Modifier.fillMaxSize())
|
||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
||||
if (title.progress > 0f && title.progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||
|
|
@ -66,17 +62,6 @@ fun LibraryPoster(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingLibraryArt(title: LibraryTitle) {
|
||||
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
|
||||
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
|
||||
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
|
||||
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
|
||||
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
|
||||
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
|
||||
}
|
||||
}
|
||||
|
||||
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ fun LibraryScreen(
|
|||
plateKey = focused.id
|
||||
}
|
||||
}
|
||||
LaunchedEffect(grid.savedAt) { view.savedAt = grid.savedAt }
|
||||
LaunchedEffect(grid.status) {
|
||||
if (grid.status == LibraryStatus.Loading) shell.focusNav(shell.currentTab)
|
||||
}
|
||||
|
|
@ -175,7 +176,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
|
|||
Avatar(person, WolfskinTokens.Shell.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
|
||||
Text(title, modifier = Modifier.alignByBaseline(), color = C.fg, style = WolfskinTokens.Type.screenTitle)
|
||||
if (subtitle.isNotEmpty()) Text(subtitle, modifier = Modifier.alignByBaseline(), color = C.muted, style = G.headerSubtitle)
|
||||
savedAt?.let { Text("Saved $it", color = C.warn, style = WolfskinTokens.Type.caption) }
|
||||
savedAt?.let { SavedPill(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,10 +100,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
}
|
||||
state.view.savedAt?.let { time ->
|
||||
state.navSavedAt?.let { time ->
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2)
|
||||
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius))
|
||||
.padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
|
||||
.padding(horizontal = H.savedPadding + H.hairline), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) {
|
||||
Glyph("wifi", C.warn, Modifier.size(H.savedIcon))
|
||||
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.dropShadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.shadow.Shadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
|
||||
/** Recovery messages remain transient and never take focus from the action that failed. */
|
||||
@Composable
|
||||
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize()) {
|
||||
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
|
||||
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
|
||||
.widthIn(max = H.toastWidth)
|
||||
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
|
||||
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
|
||||
.background(C.surface2, shape)
|
||||
.testTag("recovery-toast")
|
||||
.padding(horizontal = H.toastPadding, vertical = H.toastPaddingY),
|
||||
horizontalArrangement = Arrangement.spacedBy(H.toastGap), verticalAlignment = Alignment.CenterVertically) {
|
||||
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
|
||||
Text(message, Modifier.weight(1f, fill = false).exactLineBox(HomeType.body), color = C.fg, style = HomeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared wording is independent of transport details and never exposes exception text. */
|
||||
object RecoveryMessage {
|
||||
const val StillUnreachable = "Still can’t reach it. The last saved view stays on screen."
|
||||
const val PlaybackUnavailable = "Can’t reach Jellyfin to play. Check the network and try again."
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
|
||||
/** Headers identify a retained copy without becoming a separate focus target. */
|
||||
@Composable
|
||||
fun SavedPill(time: String, modifier: Modifier = Modifier) {
|
||||
Box(modifier.height(S.settingsPillHeight)
|
||||
.border(H.hairline, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
|
||||
.padding(horizontal = S.settingsPillPadding + H.hairline), contentAlignment = Alignment.Center) {
|
||||
Text("Saved $time", color = C.warn, style = HomeType.caption.copy(fontWeight = HomeType.label.fontWeight))
|
||||
}
|
||||
}
|
||||
|
|
@ -4,14 +4,14 @@ package org.justinvisser.wolfskin.design
|
|||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */
|
||||
fun interface ScreenRatings {
|
||||
suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List<LibraryRating>?
|
||||
interface ScreenRatings {
|
||||
suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?, cachedOnly: Boolean = false): List<LibraryRating>?
|
||||
}
|
||||
|
||||
val LocalScreenRatings = compositionLocalOf<ScreenRatings?> { null }
|
||||
|
||||
/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */
|
||||
fun LibraryRating.homeRating(): HomeRating = HomeRating(
|
||||
mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } },
|
||||
mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; "episode" -> "tmdb"; else -> source } },
|
||||
score = score, percentage = source.startsWith("rt"), episode = source == "episode",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ enum class PeopleStatus { Ready, Loading, Unavailable }
|
|||
|
||||
/** Screens participate in the shell's hold guard and Back ladder without a second key adapter. */
|
||||
interface ShellContentFocus {
|
||||
val hasContent: Boolean get() = true
|
||||
val cardKey: String?
|
||||
fun key(key: ShellKey): Boolean
|
||||
}
|
||||
|
|
@ -70,7 +71,9 @@ class ShellState(
|
|||
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
|
||||
var recoveryIndex by mutableStateOf(0)
|
||||
var requestsVisible by mutableStateOf(false)
|
||||
val requests = ComingInState()
|
||||
var toast by mutableStateOf<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)
|
||||
|
|
@ -85,6 +88,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 =
|
||||
|
|
@ -129,7 +133,7 @@ class ShellState(
|
|||
if (requestsVisible) {
|
||||
when {
|
||||
key == ShellKey.Back -> closeRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && (requests.snapshot.items.isEmpty() || requests.recoveryFocused) -> retryRequests()
|
||||
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
|
||||
requests.key(key) -> activateRequest()
|
||||
}
|
||||
|
|
@ -195,7 +199,7 @@ class ShellState(
|
|||
when (key) {
|
||||
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
|
||||
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
|
||||
ShellKey.Down -> navFocused = false
|
||||
ShellKey.Down -> if (contentFocus?.hasContent != false) navFocused = false
|
||||
ShellKey.Ok -> activate()
|
||||
else -> Unit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,13 @@ fun ShellSurface(
|
|||
val focus = remember { FocusRequester() }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
val focusManager = LocalFocusManager.current
|
||||
LaunchedEffect(state.toast) {
|
||||
val message = state.toast
|
||||
if (message != null) {
|
||||
kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong())
|
||||
if (state.toast == message) state.toast = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
|
||||
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
|
||||
else contentFocus.requestFocus()
|
||||
|
|
@ -88,6 +95,7 @@ fun ShellSurface(
|
|||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
}
|
||||
state.toast?.let { RecoveryToast(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -443,6 +443,11 @@ object WolfskinTokens {
|
|||
val toastPadding = 14.0.dp
|
||||
val toastTop = 75.0.dp
|
||||
val toastWidth = 380.0.dp
|
||||
val toastPaddingY = 11.0.dp
|
||||
val toastGap = 7.0.dp
|
||||
val toastShadowBlur = 30.0.dp
|
||||
val toastShadowY = 10.0.dp
|
||||
const val toastShadowOpacity = 0.5f
|
||||
val cardWashInset = 15.0.dp
|
||||
val heroWashInset = 40.0.dp
|
||||
val noteTop = 7.0.dp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native state proof uses only packaged fixtures and records the actual text/font/layout surface. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class RecoveryCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val previousLocale = Locale.getDefault()
|
||||
|
||||
@Before fun fullscreen() {
|
||||
Locale.setDefault(Locale.US)
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
@After fun restoreLocale() { Locale.setDefault(previousLocale) }
|
||||
|
||||
private fun shell(route: String = "home") = ShellState(fixturePeople(), "mindel").apply { pickerVisible = false; enterDestination(route) }
|
||||
|
||||
private fun mount(shell: ShellState, overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit) {
|
||||
rule.setContent { WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), overlay = overlay, content = content)
|
||||
} }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "recovery").apply { mkdirs() }
|
||||
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
|
||||
val text = JSONArray()
|
||||
val forbidden = Regex("Recs Movies|Recs Shows|DGiC|(?:Justin|Mindel|Shared) (?:Movies|Shows)|(?:Next|Previous) page|Page [0-9]")
|
||||
rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
|
||||
layouts.forEach { layout ->
|
||||
val words = layout.layoutInput.text.text
|
||||
val style = layout.layoutInput.style
|
||||
assertTrue("Text smaller than 12sp: $words", style.fontSize.value >= 12f)
|
||||
assertFalse("Internal wording: $words", forbidden.containsMatchIn(words))
|
||||
text.put(JSONObject().put("text", words).put("fontSizeSp", style.fontSize.value).put("weight", style.fontWeight?.weight)
|
||||
.put("lineCount", layout.lineCount).put("lineHeightSp", style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL)
|
||||
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
|
||||
}
|
||||
}
|
||||
File(directory, "$name-layout.json").writeText(text.toString(2))
|
||||
}
|
||||
|
||||
@Test fun st090_offlinePlayToastRetainsHomeAndFocusedResume() {
|
||||
val shell = shell()
|
||||
val snapshot = HomeFixture(rule.activity).snapshot("mindel", "offline")
|
||||
shell.toast = RecoveryMessage.PlaybackUnavailable
|
||||
mount(shell) { HomeScreen(shell, snapshot, it) {} }
|
||||
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertIsDisplayed()
|
||||
rule.onNodeWithText("Resume S4 E9").assertIsDisplayed()
|
||||
assertFalse(shell.navFocused)
|
||||
val toast = rule.onNodeWithTag("recovery-toast").fetchSemanticsNode().boundsInRoot
|
||||
val message = rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(1064f, toast.left, 1f)
|
||||
assertEquals(150f, toast.top, 1f)
|
||||
assertEquals(760f, toast.width, 1f)
|
||||
assertEquals(1136f, message.left, 1f)
|
||||
capture("st-090")
|
||||
}
|
||||
|
||||
@Test fun savedLibraryHasNavAndHeaderChipAndAReachableRetryTail() {
|
||||
var retried = false
|
||||
val shell = shell("movies")
|
||||
val grid = LibraryGridState("Movies", listOf(LibraryTitle("saved", "A saved film", "movie")), onRecover = { retried = true }).apply {
|
||||
savedAt = "20:42"; tail = GridTail.Retry
|
||||
}
|
||||
mount(shell) { LibraryScreen(shell, grid, it) }
|
||||
assertEquals(2, rule.onAllNodesWithText("Saved 20:42").fetchSemanticsNodes().size)
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
|
||||
assertTrue(retried)
|
||||
capture("library-saved")
|
||||
}
|
||||
|
||||
@Test fun libraryWithoutSavedDataOffersRecovery() {
|
||||
var retried = false
|
||||
val shell = shell("shows")
|
||||
val grid = LibraryGridState("Shows", emptyList(), onRecover = { retried = true }).apply { status = LibraryStatus.Unavailable }
|
||||
mount(shell) { LibraryScreen(shell, grid, it) }
|
||||
rule.onNodeWithText("Try again").assertIsDisplayed()
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
assertTrue(retried)
|
||||
capture("library-unavailable")
|
||||
}
|
||||
|
||||
@Test fun savedDiscoverKeepsHeroAndRecoveryWithoutAllowingRequests() {
|
||||
val shell = shell("discover")
|
||||
val snapshot = discoverTestFixture().snapshot("mindel").copy(savedAt = "20:42")
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.Unavailable), "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
|
||||
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
|
||||
rule.onNodeWithText("Saved 20:42").assertIsDisplayed()
|
||||
assertTrue(state.hasHero)
|
||||
assertFalse(state.canRequest)
|
||||
rule.runOnIdle { state.focus(state.rowCount - 1) }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.onNodeWithText("More titles couldn’t load").assertIsDisplayed()
|
||||
capture("discover-saved")
|
||||
}
|
||||
|
||||
@Test fun unavailableDiscoverRetryShowsExactFailureToast() {
|
||||
val shell = shell("discover")
|
||||
val snapshot = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
|
||||
mount(shell, overlay = { DiscoverOverlay(state) }) { DiscoverScreen(state, it) }
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
|
||||
rule.onNodeWithText(RecoveryMessage.StillUnreachable).assertIsDisplayed()
|
||||
capture("discover-unavailable-retry")
|
||||
}
|
||||
|
||||
@Test fun savedRequestsKeepTheirRowsAndRecoveryAfterFailure() {
|
||||
val shell = shell("settings")
|
||||
val item = ComingInRequest("saved", "movie", 1, "A saved request", "mindel", "Mindel", "shared", "Shared", ComingInStage.Waiting)
|
||||
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
|
||||
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
|
||||
shell.openRequests()
|
||||
mount(shell) { SettingsList(shell, it) }
|
||||
rule.onNodeWithText("A saved request").assertIsDisplayed()
|
||||
rule.onNodeWithText("Try again").assertIsDisplayed()
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
|
||||
assertTrue(shell.requests.recoveryFocused)
|
||||
capture("requests-saved")
|
||||
}
|
||||
|
||||
@Test fun failedPosterReadUsesTheSameMissingArtFallbackAsHome() {
|
||||
val title = LibraryTitle("missing", "A missing film", "movie", poster = "file:///missing-offline-poster.jpg")
|
||||
mount(shell("movies")) { LibraryScreen(shell("movies"), LibraryGridState("Movies", listOf(title)), it) }
|
||||
rule.waitUntil(5000) { rule.onAllNodesWithText(title.title).fetchSemanticsNodes().size >= 2 }
|
||||
capture("library-missing-art")
|
||||
}
|
||||
|
||||
@Test fun dutchLibraryScoreRendersCommaWithoutBreakingThePlate() {
|
||||
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
|
||||
val title = LibraryTitle("dutch", "A film", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
|
||||
val shell = shell("movies")
|
||||
mount(shell) { LibraryScreen(shell, LibraryGridState("Movies", listOf(title)), it, ratingsSignedIn = true) }
|
||||
rule.onNodeWithText("7,8").assertIsDisplayed()
|
||||
capture("library-dutch-score")
|
||||
}
|
||||
|
||||
@Config(qualifiers = "nl-rNL-w960dp-h540dp-xhdpi")
|
||||
@Test fun dutchDiscoverHeroScoreRendersComma() {
|
||||
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
|
||||
val title = DiscoverTitle(1, "movie", "A film", ratings = listOf(DiscoverRating("tmdb", 7.8)))
|
||||
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
|
||||
val shell = shell("discover")
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
|
||||
mount(shell) { DiscoverScreen(state, it) }
|
||||
rule.onNodeWithText("7,8").assertIsDisplayed()
|
||||
capture("discover-dutch-score")
|
||||
}
|
||||
|
||||
@Test fun failedDiscoverBackdropUsesTheSharedAmbientFallback() {
|
||||
val shell = shell("discover")
|
||||
val title = DiscoverTitle(1, "movie", "A film", backdrop = "file:///missing-offline-backdrop.jpg")
|
||||
val snapshot = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
|
||||
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "Mindel"), CoroutineScope(Dispatchers.Main.immediate))
|
||||
mount(shell) { DiscoverScreen(state, it) }
|
||||
rule.waitUntil(5000) { rule.onAllNodesWithTag("discover-missing-art").fetchSemanticsNodes().isNotEmpty() }
|
||||
rule.onNodeWithTag("discover-missing-art").assertIsDisplayed()
|
||||
capture("discover-missing-art")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Failed refresh, loading focus and identity changes must never discard or leak a saved view. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class RecoveryStateTest {
|
||||
@Test fun failedDiscoverRefreshKeepsTitlesAndFocusButDisablesWritesUntilRecovery() = runTest {
|
||||
val title = DiscoverTitle(1, "movie", "Saved film")
|
||||
val initial = DiscoverSnapshot(picks = listOf(title), status = DiscoverStatus.Ready)
|
||||
var response = DiscoverSnapshot(status = DiscoverStatus.Unavailable)
|
||||
var writes = 0
|
||||
val operations = object : DiscoverOperations {
|
||||
override suspend fun refresh() = response
|
||||
override suspend fun request(intent: DiscoverRequest): DiscoverRequestResult { writes++; return DiscoverRequestResult(RequestPhase.Receipt) }
|
||||
override suspend fun check(intent: DiscoverRequest) = DiscoverRequestResult(RequestPhase.NotConfirmed)
|
||||
override suspend fun hide(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
|
||||
override suspend fun restore(profile: String, title: DiscoverTitle): Boolean { writes++; return true }
|
||||
}
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("discover") }
|
||||
val state = DiscoverState(shell, initial, operations, backgroundScope, nowMillis = { 1000L })
|
||||
state.focus(0)
|
||||
state.refresh()
|
||||
testScheduler.runCurrent()
|
||||
assertEquals(listOf(title), state.heroes)
|
||||
assertEquals(0, state.focusedRow)
|
||||
assertNotNull(state.snapshot.savedAt)
|
||||
assertFalse(state.canRequest)
|
||||
assertEquals(RecoveryMessage.StillUnreachable, state.toast)
|
||||
state.hide(title)
|
||||
testScheduler.runCurrent()
|
||||
assertEquals(0, writes)
|
||||
val recovery = state.lanes.last()
|
||||
state.focus(state.lanes.indexOf(recovery) + state.firstShelf)
|
||||
assertEquals(DiscoverTail.Retry, state.focusedLane?.tail)
|
||||
response = initial
|
||||
state.key(ShellKey.Ok)
|
||||
testScheduler.runCurrent()
|
||||
assertTrue(state.canRequest)
|
||||
assertNull(state.snapshot.savedAt)
|
||||
assertNull(state.toast)
|
||||
}
|
||||
|
||||
@Test fun revokedDiscoverIdentityDropsSavedTitlesInsteadOfPaintingThemWhileSignedOut() = runTest {
|
||||
val initial = discoverTestFixture().snapshot("justin")
|
||||
val operations = RecordingDiscoverOperations(DiscoverSnapshot(status = DiscoverStatus.NeedsSignIn), "Justin")
|
||||
val state = DiscoverState(ShellState(fixturePeople(), "justin"), initial, operations, backgroundScope)
|
||||
state.refresh()
|
||||
testScheduler.runCurrent()
|
||||
assertFalse(state.isSignedIn)
|
||||
assertTrue(state.heroes.isEmpty())
|
||||
assertTrue(state.lanes.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun DownCannotLeaveNavForSkeletonOnlyHomeLibraryOrDiscover() = runTest {
|
||||
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
|
||||
val loading = LibraryGridState("Movies", emptyList()).apply { status = LibraryStatus.Loading }
|
||||
val home = HomeFocus(shell, buildHomeRows(HomeLibrary(), emptyList(), loading = true)) {}
|
||||
val discover = DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), RecordingDiscoverOperations(DiscoverSnapshot(), "Justin"), backgroundScope)
|
||||
for (content in listOf(loading, home, discover)) {
|
||||
shell.contentFocus = content
|
||||
shell.focusNav(shell.currentTab)
|
||||
shell.key(ShellKey.Down)
|
||||
assertTrue("Skeleton-only content must retain nav focus", shell.navFocused)
|
||||
}
|
||||
loading.status = LibraryStatus.Unavailable
|
||||
shell.contentFocus = loading
|
||||
shell.key(ShellKey.Down)
|
||||
assertFalse("A recovery button is reachable once loading fails", shell.navFocused)
|
||||
}
|
||||
|
||||
@Test fun requestsKeepSavedRowsOnFailureAndRecoveryNeverOpensTheLastTitle() {
|
||||
var retried = false
|
||||
var opened = false
|
||||
val shell = ShellState(fixturePeople(), "justin") {
|
||||
if (it == ShellAction.RetryRequests) retried = true
|
||||
if (it is ShellAction.RequestTitle) opened = true
|
||||
}.apply { pickerVisible = false; enterDestination("settings") }
|
||||
val request = ComingInRequest("a", "movie", 1, "Saved film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
|
||||
shell.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
|
||||
shell.requests.update(ComingInSnapshot(status = ComingInStatus.Unavailable, signedIn = true))
|
||||
shell.openRequests()
|
||||
assertEquals(listOf(request), shell.requests.snapshot.items)
|
||||
assertEquals("20:42", shell.navSavedAt)
|
||||
shell.key(ShellKey.Down)
|
||||
assertTrue(shell.requests.recoveryFocused)
|
||||
shell.key(ShellKey.Ok)
|
||||
assertTrue(retried)
|
||||
assertFalse(opened)
|
||||
shell.key(ShellKey.Up)
|
||||
assertFalse(shell.requests.recoveryFocused)
|
||||
shell.key(ShellKey.Back)
|
||||
assertFalse(shell.requestsVisible)
|
||||
}
|
||||
|
||||
@Test fun switchingPeopleNeverShowsAnotherPersonsCachedRequestsOrSavedChip() {
|
||||
val shell = ShellState(fixturePeople(), "justin")
|
||||
val item = ComingInRequest("private", "movie", 1, "Person A film", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting)
|
||||
shell.requests.update(ComingInSnapshot(listOf(item), ComingInStatus.Ready, signedIn = true, savedAt = "20:42"))
|
||||
val original = shell.requests
|
||||
shell.pickerIndex = 1
|
||||
shell.key(ShellKey.Ok)
|
||||
shell.openRequests()
|
||||
assertEquals("mindel", shell.person.key)
|
||||
assertTrue(shell.requests.snapshot.items.isEmpty())
|
||||
assertNull(shell.navSavedAt)
|
||||
assertNotSame(original, shell.requests)
|
||||
}
|
||||
}
|
||||
|
|
@ -304,6 +304,10 @@
|
|||
--home-toastPadding: 28.0px;
|
||||
--home-toastTop: 150.0px;
|
||||
--home-toastWidth: 760.0px;
|
||||
--home-toastPaddingY: 22.0px;
|
||||
--home-toastGap: 14.0px;
|
||||
--home-toastShadowBlur: 60.0px;
|
||||
--home-toastShadowY: 20.0px;
|
||||
--home-cardWashInset: 30.0px;
|
||||
--home-heroWashInset: 80.0px;
|
||||
--home-noteTop: 14.0px;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
|
||||
a934faf6ce6972371455b51c18be7278e4f3d9daf6e1c32e230542a09ddb94c6 generate_tokens.py
|
||||
9421e7b073f8eb9d170c7011eba62a0cbc4700c94ceb9ad2cc53966a718ee1d5 home-tokens.json
|
||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||
1dcf1136efaf0eb0cda4ab93266abe32d68325c63d7a967d7edb66f6ccd64f29 shell-tokens.json
|
||||
cc7ad4add4a0e8b2eb3286ac25c75c3ba18189f5c076101f562d00ed141d4d83 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
3b675f3e71e9e1e0e5713b40b8600d5a7fcb5b19f5c4c447f778ec8f1ccfa791 tokens.css
|
||||
9bf7d34cd27f4156d7adc10911f0b28a5d77e41a1adc1618ee0dd7b302c86ca3 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
22c2a522d892e0751c8bc1bad7364cc3c2e19d3b73cc523dbaa8087d54727b6c tokens.css
|
||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue