Align TV layouts and close recovery gaps

This commit is contained in:
justinzeus 2026-11-06 02:15:09 +01:00
parent bfe3dbbb16
commit 04b1bdd6aa
22 changed files with 522 additions and 53 deletions

View file

@ -67,4 +67,39 @@ class ConductarrAudienceRecoveryTest {
}
}
}
@Test fun retryingSignedInPeopleKeepsTheUncertainSubmissionLedger() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previousUrl = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { session ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
session.authenticate()
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
try {
val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
org.justinvisser.wolfskin.design.DiscoverTitle(42, "movie", "Moon"), "mindel", "shared")
fake.postStatus = 503
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
identity.provider.operations(identity.operator!!).request(intent).phase)
model.retryConductarr(server, user)
val refreshed = session.identity.value!!
assertEquals(org.justinvisser.wolfskin.design.RequestPhase.NotConfirmed,
refreshed.provider.operations(refreshed.operator!!).request(intent).phase)
assertEquals(1, fake.calls.count { it.method == "POST" })
} finally {
model.viewModelScope.cancel()
ConductarrPreference.updateBaseUrl(previousUrl)
}
}
}
}
}

View file

@ -60,7 +60,7 @@ class ConductarrHomeSurfaceTest {
try {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false }
rule.setContent { WolfskinTheme {
LaunchedEffect(model) { model.loadLibrary(server, user, "shared") }
LaunchedEffect(model) { model.loadLibrary(server, user, shell.person) }
val snapshot = model.snapshot
CompositionLocalProvider(LocalScreenRatings provides androidx.compose.runtime.remember(session.identity.value?.client) { ConductarrRatings(session.identity.value!!.client) }) {
ShellSurface(shell) { HomeScreen(shell, snapshot, it) { action -> model.act(action, shell, server, user) } }

View file

@ -32,6 +32,8 @@ class HomeRecoveryTest {
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 viewerA = ShellPerson("justin", "Justin", "J", false, personA.id.toString())
private val viewerB = ShellPerson("mindel", "Mindel", "M", false, personB.id.toString())
private val film = HomeTitle("private-film", "Person A film", "movie", held = true, runtimeSeconds = 5400)
private val library = HomeLibrary(resume = listOf(film))
@ -43,7 +45,7 @@ class HomeRecoveryTest {
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) }
val refresh = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA, retry = true) }
started.await()
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
response.completeExceptionally(IOException("fixture offline"))
@ -63,10 +65,10 @@ class HomeRecoveryTest {
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") }
val requestA = async(Dispatchers.IO) { model.loadLibrary(server, personA, viewerA) }
startedA.await()
assertTrue(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
model.loadLibrary(server, personB, "mindel")
model.loadLibrary(server, personB, viewerB)
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 })
@ -83,9 +85,23 @@ class HomeRecoveryTest {
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")
model.loadLibrary(server, personA, viewerA)
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
assertNull(model.toast)
}
@Test fun renamedHouseholdProfileKeepsItsOwnJellyfinLibraryWithoutAConductarrIdentity() = runBlocking {
val facade = mockk<HomeReadFacade>()
coEvery { facade.read(personA.id) } returns library
val cache = mockk<HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val model = WolfskinHomeViewModel(context, facade, cache, NavigationManager(), WolfskinSeeAllLists(), ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()))
model.loadLibrary(server, personA, viewerA.copy(key = "household-profile", name = "Household display name"))
assertTrue(model.snapshot.jellyfinAvailable)
assertEquals(film.id, model.snapshot.rows.first().cards.first().id)
model.loadLibrary(server, personA, viewerB.copy(key = "justin"))
assertFalse(model.snapshot.jellyfinAvailable)
assertFalse(model.snapshot.rows.any { row -> row.cards.any { it.id == film.id } })
}
}

View file

@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.flow
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The real host exposes usable remote focus before the remaining library chunks arrive. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class LibraryProgressiveSurfaceTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun firstChunkPaintsAndAcceptsRemoteInputBeforeTheLastChunk() {
ConductarrPreference.initialize(rule.activity)
ConductarrPreference.updateFixtures(false)
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin").apply {
pickerVisible = false
enterDestination("movies")
focusNav(currentTab)
}
val user = JellyfinUser(id = UUID(0, 1), name = "Justin", serverId = UUID(0, 2), accessToken = null)
val first = List(200) { LibraryTitle(UUID(0, it + 10L).toString(), "Film %03d".format(it), "movie") }
val release = CompletableDeferred<Unit>()
val facade = mockk<LibraryReadFacade>()
every { facade.audiences } returns null
every { facade.readLibrary(user.id, "movies") } returns flow {
emit(first)
release.await()
emit(first + LibraryTitle(UUID(0, 999).toString(), "Added film", "movie"))
}
val scope = "progressive:${UUID.randomUUID()}"
val navigation = NavigationManager()
val seeAll = WolfskinSeeAllLists()
try {
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
WolfskinLibraryContent(rule.activity, "movies", shell, user, scope, facade, navigation, seeAll, modifier)
} } }
rule.waitUntil(timeoutMillis = 10_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
shell.view.library?.source?.size == 200
}
rule.mainClock.advanceTimeBy(500)
assertFalse(shell.navFocused)
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight) }
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "library-first-chunk.png").absolutePath)
release.complete(Unit)
rule.waitUntil(timeoutMillis = 10_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
shell.view.library?.source?.size == 201
}
assertEquals(first[1].id, shell.view.library!!.focusedTitle!!.id)
assertFalse(shell.navFocused)
} finally { release.complete(Unit) }
}
}

View file

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.justinvisser.wolfskin.tv.read.LibrarySnapshot
import org.justinvisser.wolfskin.tv.read.LibrarySnapshotStore
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Cold libraries publish usable chunks before later SDK reads finish; only complete copies reach disk. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class LibraryReadFacadeTest {
@Test fun firstTwoHundredTitlesPublishWhileTheNextChunkIsSuspended() = runBlocking {
val api = mockk<ApiClient>()
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
val user = UUID(0, 1)
val library = BaseItemDto(id = UUID(1, 0), name = "Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
coEvery { views.getUserViews(userId = user) } returns successQueryResult(listOf(library))
val firstChunk = List(200) { index -> BaseItemDto(id = UUID(0, index + 10L), name = "Film $index", type = BaseItemKind.MOVIE) }
val nextRead = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
val offsets = mutableListOf<Int>()
coEvery { items.getItems(userId = user, parentId = library.id, recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE),
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = capture(offsets), limit = 200) } coAnswers {
if (offsets.last() == 0) successQueryResult(firstChunk, 202) else {
assertEquals(200, offsets.last())
nextRead.complete(Unit)
release.await()
successQueryResult(listOf(firstChunk.first().copy(name = "Updated film"), BaseItemDto(id = UUID(0, 999), name = "Last film", type = BaseItemKind.MOVIE)), 202)
}
}
val facade = LibraryReadFacade(api)
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
val published = CompletableDeferred<LibrarySnapshot>()
val snapshots = mutableListOf<LibrarySnapshot>()
val job = launch {
store.read("server:justin", "movies") {
facade.readLibrary(user, "movies").catch { throw AssertionError("SDK fixture read failed", it) }
}.collect {
snapshots += it
if (!published.isCompleted) published.complete(it)
}
}
val initial = published.await()
assertEquals(firstChunk.map { it.id.toString() }, initial.items.map { it.id })
nextRead.await()
assertTrue(job.isActive)
release.complete(Unit)
job.join()
val final = snapshots.last()
assertEquals(201, final.items.size)
assertEquals("Updated film", final.items.first().title)
assertEquals("Last film", final.items.last().title)
assertFalse(final.unreachable)
val saved = store.read("server:justin", "movies") { error("Complete fresh copy must not read") }.toList().single()
assertEquals(final.items, saved.items)
}
}

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.app.Application
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Test
@ -20,7 +21,7 @@ class LibrarySnapshotStoreTest {
var now = 1000L
val store = LibrarySnapshotStore(MemoryCacheStore()) { now }
val items = listOf(LibraryTitle("a", "Film A", "movie", year = 2007, runtimeSeconds = 6000, played = true))
store.read("server:justin", "movies") { items }.toList()
store.read("server:justin", "movies") { flowOf(items) }.toList()
val fresh = store.read("server:justin", "movies") { error("Fresh snapshot must not read") }.toList()
assertEquals(items, fresh.single().items)
now += 11 * 60_000
@ -39,11 +40,31 @@ class LibrarySnapshotStoreTest {
@Test fun explicitRecoveryRefreshesEvenFreshEmptyDestination() = runBlocking {
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
store.read("server:justin", "movies") { emptyList() }.toList()
store.read("server:justin", "movies") { flowOf(emptyList()) }.toList()
val current = listOf(LibraryTitle("b", "New Film", "movie"))
val reads = store.read("server:justin", "movies", force = true) { current }.toList()
val reads = store.read("server:justin", "movies", force = true) { flowOf(current) }.toList()
assertTrue(reads.first().items.isEmpty())
assertEquals(current, reads.last().items)
assertFalse(reads.last().unreachable)
}
@Test fun failedLaterChunkKeepsTheCompleteSavedCopyOrTheColdPartialWithoutSavingIt() = runBlocking {
val partial = listOf(LibraryTitle("partial", "First film", "movie"))
val complete = partial + LibraryTitle("last", "Last film", "movie")
for (hasSaved in listOf(false, true)) {
val store = LibrarySnapshotStore(MemoryCacheStore()) { 1000L }
if (hasSaved) store.read("server:justin", "movies") { flowOf(complete) }.toList()
val reads = store.read("server:justin", "movies", force = true) {
kotlinx.coroutines.flow.flow {
emit(partial)
throw java.io.IOException("Later chunk failed")
}
}.toList()
assertEquals(if (hasSaved) complete else partial, reads.last().items)
assertTrue(reads.last().unreachable)
assertEquals(if (hasSaved) 1000L else null, reads.last().savedAtMillis)
val reopened = store.read("server:justin", "movies") { throw java.io.IOException("Offline") }.toList()
assertEquals(if (hasSaved) complete else emptyList<LibraryTitle>(), reopened.last().items)
}
}
}

View file

@ -48,9 +48,9 @@ class WolfskinHomeViewModel @Inject constructor(
private var catalogueSnapshot = DiscoverSnapshot()
private var publishCatalogue: ((DiscoverSnapshot) -> Unit)? = null
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: String, retry: Boolean = false) {
suspend fun load(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, retry: Boolean = false) {
catalogueJob?.cancel()
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return }
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
if (retry && conductarr.identity.value?.signedIn != true) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
}
@ -58,9 +58,11 @@ class WolfskinHomeViewModel @Inject constructor(
}
/** 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) {
internal suspend fun loadLibrary(server: JellyfinServer, user: JellyfinUser, person: ShellPerson, 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)) {
val matchesUser = person.jellyfinUserId?.let { it == user.id.toString() } ?:
(person.key == user.name.orEmpty().lowercase() || (conductarr.identity.value?.person == "${server.id}:${user.id}" && person.key == conductarr.identity.value?.operator?.requester))
if (!matchesUser) {
snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), listOf(homeDiscoverState(HomeAvailability.SignedOut)), libraryAvailable = false), jellyfinAvailable = false)
return
}
@ -130,7 +132,7 @@ class WolfskinHomeViewModel @Inject constructor(
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) }
HomeAction.Retry -> viewModelScope.launch { load(server, user, shell.person, retry = true) }
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(titleRoute(action.title.id)))
is HomeAction.Play -> viewModelScope.launch {
@ -200,7 +202,7 @@ 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, server.id, user.id, identity?.client, identity?.signedIn, model.audiences) { 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) }
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

View file

@ -53,7 +53,7 @@ internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: She
is TitleAction.Requests -> shell.openRequests()
is TitleAction.Hide -> if (current != null && requests != null) {
val item = current.data.requestTitle(shell.person)
if (action.hidden) requests.hide(item) else requests.restore(item)
if (action.hidden) requests.hide(item) else requests.restore(item, showToast = false)
requests.overlay = null // TitleState already owns the native hide receipt and Undo.
}
else -> error("Not a Conductarr title action")

View file

@ -142,7 +142,8 @@ class WolfskinShellViewModel @Inject constructor(
}
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
retry = conductarr.identity.value?.signedIn != true)
loadHousehold(user)
}

View file

@ -75,19 +75,24 @@ fun WolfskinLibraryContent(
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 (wasLoading && !shell.navFocused) shell.focusNav(shell.currentTab)
val entryRevision = shell.inputRevision
if (fixture != null) {
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
grid.status = LibraryStatus.Ready
} else {
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
val focusedId = grid.focusedTitle?.id
grid.source = snapshot.items
grid.status = if (snapshot.unreachable && snapshot.savedAtMillis == null) LibraryStatus.Unavailable else LibraryStatus.Ready
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }
grid.status = if (snapshot.unreachable && snapshot.items.isEmpty() && 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.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
}
}
if (wasLoading && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
if (wasLoading && shell.inputRevision == entryRevision && shell.navFocused && shell.navIndex == shell.currentTab) shell.navFocused = false
}
}
LibraryScreen(shell, grid, modifier, ratingsSignedIn = fixture != null || LocalScreenRatings.current != null, view = view)

View file

@ -3,6 +3,8 @@ package org.justinvisser.wolfskin.tv.read
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.api.client.ApiClient
@ -47,7 +49,7 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
}
/** A destination spans every matching accessible library; containers never become titles. */
suspend fun readLibrary(userId: UUID, destination: String): List<LibraryTitle> {
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->
destination(library.name.orEmpty(), library.collectionType) == destination
}
@ -76,8 +78,9 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
)
}
offset += chunk.size
if (chunk.isNotEmpty()) emit(titles.values.toList())
} while (chunk.isNotEmpty() && offset < response.totalRecordCount)
}
return titles.values.toList()
if (titles.isEmpty()) emit(emptyList())
}
}

View file

@ -19,21 +19,25 @@ data class LibrarySnapshot(val items: List<LibraryTitle>, val savedAtMillis: Lon
/** Library snapshots share the existing atomic disk store, partitioned by server and person. */
class LibrarySnapshotStore(private val cache: CacheStore, private val now: () -> Long = System::currentTimeMillis) {
fun read(scope: String, destination: String, force: Boolean = false, load: suspend () -> List<LibraryTitle>): Flow<LibrarySnapshot> = flow {
fun read(scope: String, destination: String, force: Boolean = false, load: () -> Flow<List<LibraryTitle>>): Flow<LibrarySnapshot> = flow {
val key = "library:$destination"
val saved = try { cache.read(scope, key) } catch (_: IOException) { null }
val titles = saved?.let { try { decode(it.body) } catch (_: JSONException) { null } }
if (saved != null && titles != null) emit(LibrarySnapshot(titles, saved.savedAtMillis, false))
val lifetime = (WolfskinTokens.Behaviour.libraryRefreshMinutes * 60_000).toLong()
if (!force && saved != null && titles != null && now() - saved.validatedAtMillis < lifetime) return@flow
var current: List<LibraryTitle> = emptyList()
try {
val current = load()
load().collect { chunk ->
current = chunk
if (titles == null) emit(LibrarySnapshot(chunk, null, false))
}
val time = now()
if (titles != null) emit(LibrarySnapshot(current, time, false))
try { cache.write(scope, key, CacheEntry(encode(current), null, time, time, group = "library")) } catch (_: IOException) { /* A readable server remains usable without writable local storage. */ }
emit(LibrarySnapshot(current, time, false))
} catch (exception: Exception) {
if (exception is CancellationException) throw exception
emit(LibrarySnapshot(titles.orEmpty(), saved?.savedAtMillis.takeIf { titles != null }, true))
emit(LibrarySnapshot(titles ?: current, saved?.savedAtMillis.takeIf { titles != null }, true))
}
}.flowOn(Dispatchers.IO)