From 09f1d72f30f6035910a7adf7749f52680a2c2d9f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 00:31:48 +0100 Subject: [PATCH] Keep Home responsive and resolve authorized request recipients --- .../wolfskin/tv/ConductarrHomeSurfaceTest.kt | 22 +++++++++------ .../wolfskin/tv/ConductarrSessionTest.kt | 2 +- .../wolfskin/tv/ConductarrSurfaceTest.kt | 7 +++-- .../wolfskin/tv/ConductarrTitleSurfaceTest.kt | 17 +++++++++--- .../wolfskin/tv/FakeConductarr.kt | 3 ++- .../wolfskin/tv/ConductarrProvider.kt | 27 ++++++++++++++++++- .../wolfskin/tv/ConductarrSession.kt | 2 +- .../justinvisser/wolfskin/tv/DiscoverHost.kt | 12 +++++++-- .../wolfskin/tv/HomeDestination.kt | 16 +++++++---- .../justinvisser/wolfskin/tv/TitleRequests.kt | 8 +++++- docker/README.md | 7 +++++ .../wolfskin/design/DiscoverState.kt | 12 ++++++++- .../wolfskin/design/RequestSheetState.kt | 14 ++++++++-- 13 files changed, 120 insertions(+), 29 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt index f22642a5..13ac36d4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrHomeSurfaceTest.kt @@ -9,6 +9,9 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onRoot +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -21,7 +24,6 @@ import java.io.File import java.util.UUID import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.last -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Rule @@ -50,7 +52,8 @@ class ConductarrHomeSurfaceTest { session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) runBlocking { session.authenticate() } val home = mockk() - coEvery { home.read(user.id) } returns HomeLibrary() + coEvery { home.read(user.id) } returns HomeLibrary(resume = listOf( + HomeTitle("00000000-0000-0000-0000-000000000010", "Jellyfin held item", "movie", held = true))) val cache = mockk(relaxed = true) every { cache.read(any()) } returns null val model = WolfskinHomeViewModel(rule.activity, home, cache, NavigationManager(), WolfskinSeeAllLists(), session) @@ -59,15 +62,18 @@ class ConductarrHomeSurfaceTest { rule.setContent { WolfskinTheme { LaunchedEffect(model) { model.loadLibrary(server, user, "shared") } val snapshot = model.snapshot - LaunchedEffect(snapshot.rows) { - val index = snapshot.rows.indexOfFirst { it.catalogue && it.cards.any { card -> card.title == "Moon" } } - if (index >= 0) { shell.view.row = index; shell.view.home.focusedKey = snapshot.rows[index].id; shell.navFocused = false } - } - CompositionLocalProvider(LocalScreenRatings provides ConductarrRatings(session.identity.value!!.client)) { + 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) } } } } } - rule.waitUntil(10_000) { model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } } + rule.waitUntil(timeoutMillis = 30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + model.snapshot.rows.any { row -> row.cards.any { it.title == "Moon" } } + } + rule.runOnIdle { shell.navFocused = false; shell.view.row = 0; shell.view.home.focusedKey = "hero" } + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + rule.mainClock.advanceTimeBy(500) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() rule.onAllNodesWithText("Moon")[0].assertIsDisplayed() assertTrue(model.snapshot.rows.filter { it.catalogue }.flatMap { it.cards }.none { it.held }) val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt index 4a1e952f..3027d5fc 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSessionTest.kt @@ -69,7 +69,7 @@ class ConductarrSessionTest { fake.status = 200 fake.ratingsStatus = 401 val ratings = ConductarrRatings(reopened.identity.value!!.client).read(42, "movie", null, null) - assertNull(ratings) + assertTrue(ratings!!.isEmpty()) assertFalse(reopened.identity.value!!.signedIn) assertNull(reopened.identity.value!!.operator) assertEquals(ClientError.NeedsSignIn, reopened.identity.value!!.error) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt index 4bb35f9f..265dcbd9 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrSurfaceTest.kt @@ -45,11 +45,14 @@ class ConductarrSurfaceTest { val identity by session.identity.collectAsState() val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, identity = identity, fixtures = false) current = state - CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { ConductarrRatings(it.client) }) { + CompositionLocalProvider(LocalDiscoverArt provides { art }, LocalScreenRatings provides identity?.takeIf { it.signedIn }?.let { selected -> androidx.compose.runtime.remember(selected.client) { ConductarrRatings(selected.client) } }) { WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } } } } - rule.waitUntil(10_000) { current.snapshot.status == DiscoverStatus.Ready } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + current.snapshot.status == DiscoverStatus.Ready + } rule.onNodeWithText("Moon").assertIsDisplayed() rule.mainClock.autoAdvance = false rule.runOnIdle { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index aef205b9..e41862d8 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -52,7 +52,7 @@ class ConductarrTitleSurfaceTest { val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true), session, mockk(relaxed = true)) try { val route = titleRoute("movie:42") - val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true), ShellPerson("mindel", "Mindel", "M", true)), "shared").apply { + val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", true)), "shared").apply { pickerVisible = false; pickerLaunch = false; enterDestination(route) } val art = mutableMapOf() @@ -67,7 +67,10 @@ class ConductarrTitleSurfaceTest { rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = viewModel, fixtures = false) } } } - rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedIn } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.connection == TitleConnection.SignedIn && session.identity.value!!.provider.requesterProfiles.value.any { it.key == "mindel" } + } rule.onNodeWithText("Moon").assertIsDisplayed() assertFalse(viewModel.title!!.options.any { it.id == "play" }) val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() } @@ -75,7 +78,10 @@ class ConductarrTitleSurfaceTest { rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) } rule.onNodeWithText("Request for Mindel").assertIsDisplayed() rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) } - rule.waitUntil(10_000) { viewModel.title?.data?.requestActor == "Shared" } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.requestActor == "Shared" + } assertEquals("Mindel", viewModel.title!!.data.requestFor) rule.onAllNodesWithText("for Mindel ยท by Shared")[0].assertIsDisplayed() val post = fake.calls.single { it.method == "POST" && it.url.encodedPath == "/api/v1/requests" } @@ -86,7 +92,10 @@ class ConductarrTitleSurfaceTest { rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) } fake.ratingsStatus = 401 runBlocking { ConductarrRatings(session.identity.value!!.client).read(45, "movie", null, null) } - rule.waitUntil(10_000) { viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + viewModel.title?.data?.connection == TitleConnection.SignedOut && viewModel.title?.data?.status == TitleReadStatus.Unavailable + } assertTrue(viewModel.title!!.data.item.ratings.isEmpty()) assertFalse(viewModel.title!!.options.any { it.id in listOf("request", "other", "add") }) } finally { viewModel.viewModelScope.cancel() } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index 4173d914..6170f043 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -75,7 +75,8 @@ internal class FakeConductarr : Closeable { if (key == "season" && path.endsWith("/2")) body = JsonObject(body + ("episodes" to JsonArray(body.getValue("episodes").jsonArray.map { JsonObject(it.jsonObject + ("vote_average" to JsonPrimitive(seasonTwoScore))) }))) - return response(if (localArt) localizeImages(body).toString() else body.toString()) + val responseBody = if (localArt) localizeImages(body).toString() else body.toString() + return response(responseBody) } } server.start() diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt index 60c96798..922b5b4e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -9,6 +9,10 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.justinvisser.wolfskin.conductarr.* import org.justinvisser.wolfskin.conductarr.model.* import org.justinvisser.wolfskin.design.* @@ -21,10 +25,15 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider private val services = mutableMapOf() @Volatile private var libraryAudiences = emptyList() private var requestOperations: ConductarrDiscoverOperations? = null + private val profilesMutex = Mutex() + private var observedProfiles: RecommendationProfiles? = null + private val profileNames = MutableStateFlow>(emptyList()) + val requesterProfiles = profileNames.asStateFlow() /** One uncertain-submission ledger survives screen/navigation changes for this identity. */ fun operations(operator: Operator): ConductarrDiscoverOperations = synchronized(this) { requestOperations ?: ConductarrDiscoverOperations(client, { + profiles() discover(operator.recommendationProfile, force = true).lastOrNull() ?.copy(requestsAllowed = operator.capabilities.requestMedia) ?: DiscoverSnapshot(status = DiscoverStatus.Unavailable) }, operator.recommendationProfile, operator.capabilities.requesters.orEmpty().toSet(), operator.capabilities.requestMedia) @@ -101,7 +110,12 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider } } - suspend fun profiles(): RecommendationProfiles? = SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles()) + suspend fun profiles(): RecommendationProfiles? = profilesMutex.withLock { + observedProfiles ?: SnapshotReads(client, ReadTrigger.LAUNCH).read(TvRoutes.profiles())?.also { + observedProfiles = it + profileNames.value = it.profiles.orEmpty() + } + } suspend fun audiences(): List { libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() return libraryAudiences @@ -110,6 +124,17 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider fun holders(title: Title): Set = title.discoverTitle(libraryAudiences).heldBy } +/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */ +internal fun ConductarrIdentity.requesters(profiles: List, saved: List): List { + val allowed = operator?.capabilities?.requesters.orEmpty() + return buildMap { + saved.filter { it.key in allowed }.forEach { put(it.key, it) } + profiles.filter { it.key in allowed }.forEach { profile -> + put(profile.key, ShellPerson(profile.key, profile.name, profile.name.take(1).uppercase(), false)) + } + }.values.toList() +} + /** A batch tracks source failure without replacing saved bodies with empty success. */ internal class SnapshotReads(private val client: ConductarrClient, private val trigger: ReadTrigger) { @Volatile var failed = false diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt index e0ace3ac..64cdba69 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrSession.kt @@ -39,7 +39,7 @@ class ConductarrSession(private val cache: CacheStore) : Closeable { selection = fingerprint if (token.isNullOrBlank()) return val client = try { - ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), onEvent = { event -> + ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) { mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt index 550527d2..8b11ee3a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/DiscoverHost.kt @@ -27,6 +27,13 @@ internal fun rememberDiscoverState( val context = LocalContext.current val scope = rememberCoroutineScope() val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf>() } + val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() + val requesters = remember(profileNames, shell.people, identity?.operator, fixtures) { + if (fixtures) shell.people else identity?.requesters(profileNames, shell.people).orEmpty() + } + LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { + if (!fixtures && identity?.signedIn == true) identity.provider.profiles() + } val refresh: suspend () -> DiscoverSnapshot = { 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() } @@ -37,7 +44,7 @@ internal fun rememberDiscoverState( lateinit var loaded: DiscoverState loaded = DiscoverState(shell, DiscoverSnapshot(status = if (fixtures || identity?.signedIn == true) DiscoverStatus.Loading else if (identity?.error != null && identity.error != ClientError.NeedsSignIn) DiscoverStatus.Unavailable else DiscoverStatus.NeedsSignIn), operations, scope, push, pop, { service, type -> servicePages["${service.id}:$type"].orEmpty() }, - requesters = if (fixtures) shell.people else shell.people.filter { it.key in identity?.operator?.capabilities?.requesters.orEmpty() }, + requesters = requesters, onMore = if (fixtures || identity?.signedIn != true) null else { lane -> val service = loaded.service if (service == null) identity.provider.more(lane) @@ -48,6 +55,7 @@ internal fun rememberDiscoverState( }) loaded } + SideEffect { if (!fixtures) state.updateRequesters(requesters) } var fixtureState by remember(shell, shell.person.key, fixtures) { mutableStateOf(null) } LaunchedEffect(state, fixtures) { if (fixtures) { @@ -68,7 +76,7 @@ internal fun rememberDiscoverState( } } LaunchedEffect(state, state.snapshot.status, state.service?.id, state.filter) { - if (!fixtures && identity?.signedIn == true) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> + if (!fixtures && identity?.signedIn == true && state.hasCatalogue) snapshotFlow { state.focusedLane?.id to state.column }.collect { (lane, column) -> val service = state.service if (service == null) { state.updateLanes(identity.provider.readAhead(lane)) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt index eba7ef13..034f46a7 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/HomeDestination.kt @@ -53,7 +53,6 @@ class WolfskinHomeViewModel @Inject constructor( withContext(Dispatchers.IO) { debugHomeSnapshot(context, person) }?.let { snapshot = it; return } if (retry && conductarr.identity.value?.signedIn != true) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) - conductarr.authenticate() } loadLibrary(server, user, person, retry) } @@ -77,7 +76,7 @@ class WolfskinHomeViewModel @Inject constructor( 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) + var catalogueCopy = DiscoverSnapshot(status = if (identity?.signedIn == true || (retry && identity != null)) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn) fun publish() { if (revision != loadRevision || sourceScope != scope) return val status = when (catalogueCopy.status) { @@ -92,17 +91,24 @@ class WolfskinHomeViewModel @Inject constructor( 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 (identity != null && (identity.signedIn || retry)) catalogueJob = viewModelScope.launch { + if (retry && !identity.signedIn) conductarr.authenticate() + val source = conductarr.identity.value + if (source == null || source.client !== identity.client) return@launch + if (!source.signedIn) { + publishCatalogue?.invoke(DiscoverSnapshot(status = if (source.error == org.justinvisser.wolfskin.conductarr.ClientError.NeedsSignIn) DiscoverStatus.NeedsSignIn else DiscoverStatus.Unavailable)) + return@launch + } + source.provider.discover(source.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 publish() + withContext(Dispatchers.IO) { try { cache.write(scope, library, savedAt) } catch (_: java.io.IOException) { /* Saving cannot delay first paint or turn a reachable source into an outage. */ } } } catch (exception: Exception) { if (exception is CancellationException) throw exception if (revision != loadRevision) return diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt index 0a1ad443..fda961a9 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleRequests.kt @@ -9,15 +9,21 @@ import org.justinvisser.wolfskin.design.* internal fun rememberTitleRequests(viewModel: WolfskinTitleViewModel, shell: ShellState, route: String, identity: ConductarrIdentity?, fixtures: Boolean, title: TitleState?): DiscoverState? { val scope = rememberCoroutineScope() + val profileNames = identity?.provider?.requesterProfiles?.collectAsState()?.value.orEmpty() + val requesters = remember(profileNames, shell.people, identity?.operator) { identity?.requesters(profileNames, shell.people).orEmpty() } + LaunchedEffect(identity?.client, identity?.signedIn, fixtures) { + if (!fixtures && identity?.signedIn == true) identity.provider.profiles() + } val requests = if (fixtures) rememberDiscoverState(shell, viewModel::openRoute, viewModel::popRoute, identity = null, fixtures = true, retrySignIn = viewModel::retrySignIn) else remember(shell, shell.person.key, route, identity?.client, identity?.signedIn) { identity?.takeIf { it.signedIn }?.let { DiscoverState(shell, DiscoverSnapshot(status = DiscoverStatus.Loading), it.provider.operations(it.operator!!), scope, viewModel::openRoute, viewModel::popRoute, - requesters = shell.people.filter { person -> person.key in it.operator.capabilities.requesters.orEmpty() }) + requesters = requesters) } } + SideEffect { if (!fixtures) requests?.updateRequesters(requesters) } LaunchedEffect(requests, title?.data) { val data = title?.data ?: return@LaunchedEffect if (!fixtures && requests != null && data.item.tmdbId != null) { diff --git a/docker/README.md b/docker/README.md index ea8ec610..ffaa984a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -459,6 +459,13 @@ the old client and in-memory providers. LKG files are isolated by person and cli 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. +Home publishes Jellyfin/LKG rows independently of Conductarr, including retry +authentication, and paints new Jellyfin data before writing its disk copy. +Production calls use 3-second connect, 5-second read and 8-second total limits. +Request recipients include authorized Conductarr profiles without saved Jellyfin +logins; that does not create selectable TV logins. Late name reads preserve the +open chooser's selected recipient and footer focus. + | Screen seam | Production supplier | | --- | --- | | People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt index 482687b9..47ec35ff 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverState.kt @@ -19,12 +19,22 @@ class DiscoverState( private val onPush: (String) -> Unit = {}, private val onPop: () -> Unit = {}, private val serviceLanes: (DiscoverService, String) -> List = { _, _ -> emptyList() }, - private val requesters: List = shell.people, + requesters: List = shell.people, private val onMore: (suspend (String) -> List?)? = null, private val nowMillis: () -> Long = System::currentTimeMillis, ) : ShellContentFocus { var snapshot by mutableStateOf(initial) private set + private var requesters = requesters + + fun updateRequesters(people: List) { + if (requesters == people) return + requesters = people + requestSheets.values.forEach { it.updatePeople(people) } + (overlay as? DiscoverOverlayState.Request)?.state?.let { current -> + if (requestSheets.values.none { it === current }) current.updatePeople(people) + } + } private var savedTime = initial.savedAt ?: formatSavedTime() private var serviceFilter by mutableStateOf("movie") var filter: String diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt index 6e9bd517..4ab9cc13 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RequestSheetState.kt @@ -20,10 +20,11 @@ class RequestSheetState( other: Boolean, initialPhase: RequestPhase = RequestPhase.Choose, ) { - val people = listOf(viewer) + people.filter { it.key != viewer.key } + var people by mutableStateOf(listOf(viewer) + people.filter { it.key != viewer.key }) + private set val held = viewer.key in title.heldBy val blocked = title.type == "tv" && !held && title.seasons.isNullOrEmpty() - val buttonIndex = this.people.size + val buttonIndex: Int get() = people.size var selected by mutableStateOf(if (other || held) this.people.indexOfFirst { it.key != viewer.key && it.key !in title.heldBy }.coerceAtLeast(0) else 0) var phase by mutableStateOf(initialPhase) var focus by mutableStateOf(if (initialPhase != RequestPhase.Choose || blocked || !other) buttonIndex else selected) @@ -36,6 +37,15 @@ class RequestSheetState( else -> "Request for ${selectedPerson.name}" } + fun updatePeople(value: List) { + val selectedKey = selectedPerson.key + val focusedKey = people.getOrNull(focus)?.key + val footerOffset = focus - buttonIndex + people = listOf(viewer) + value.filter { it.key != viewer.key } + selected = people.indexOfFirst { it.key == selectedKey }.coerceAtLeast(0) + focus = if (footerOffset >= 0) buttonIndex + footerOffset else people.indexOfFirst { it.key == focusedKey }.coerceAtLeast(0) + } + fun key(key: ShellKey, send: (RequestSheetState, Boolean) -> Unit, close: () -> Unit, retryRecipients: () -> Unit) { if (key == ShellKey.Back) { close(); return } when (phase) {