Align TV layouts and close recovery gaps
This commit is contained in:
parent
bfe3dbbb16
commit
04b1bdd6aa
22 changed files with 522 additions and 53 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue