Keep Home responsive and resolve authorized request recipients
Some checks failed
review/code-claude Block: token sent to image hosts via Coil auth client; failed audiences read hides Jellyfin libraries
Some checks failed
review/code-claude Block: token sent to image hosts via Coil auth client; failed audiences read hides Jellyfin libraries
This commit is contained in:
parent
78322f588a
commit
09f1d72f30
13 changed files with 120 additions and 29 deletions
|
|
@ -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<HomeReadFacade>()
|
||||
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<HomeLibraryCache>(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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class ConductarrTitleSurfaceTest {
|
|||
val viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk<NavigationManager>(relaxed = true), session, mockk<ServerRepository>(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<String, ImageBitmap>()
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<String, ConductarrCatalogue>()
|
||||
@Volatile private var libraryAudiences = emptyList<LibraryAudience>()
|
||||
private var requestOperations: ConductarrDiscoverOperations? = null
|
||||
private val profilesMutex = Mutex()
|
||||
private var observedProfiles: RecommendationProfiles? = null
|
||||
private val profileNames = MutableStateFlow<List<ReadProfile>>(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<LibraryAudience> {
|
||||
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<String> = title.discoverTitle(libraryAudiences).heldBy
|
||||
}
|
||||
|
||||
/** Request recipients need an authorized profile, not a saved Jellyfin login on this TV. */
|
||||
internal fun ConductarrIdentity.requesters(profiles: List<ReadProfile>, saved: List<ShellPerson>): List<ShellPerson> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ internal fun rememberDiscoverState(
|
|||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val servicePages = remember(identity?.client, shell.person.key) { mutableStateMapOf<String, List<DiscoverLane>>() }
|
||||
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<DiscoverState?>(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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -19,12 +19,22 @@ class DiscoverState(
|
|||
private val onPush: (String) -> Unit = {},
|
||||
private val onPop: () -> Unit = {},
|
||||
private val serviceLanes: (DiscoverService, String) -> List<DiscoverLane> = { _, _ -> emptyList() },
|
||||
private val requesters: List<ShellPerson> = shell.people,
|
||||
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 requesters = requesters
|
||||
|
||||
fun updateRequesters(people: List<ShellPerson>) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<ShellPerson>) {
|
||||
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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue