Wire selected Jellyfin users to Conductarr TV data

This commit is contained in:
justinzeus 2026-11-05 22:55:54 +01:00
parent f53defad90
commit 59c2728d9c
38 changed files with 1879 additions and 110 deletions

View file

@ -38,8 +38,17 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val state = shell.requests
val data = state.snapshot
val ratingsReader = LocalScreenRatings.current
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
state.awaitFocusedRatings()
if (ratingsReader == null) state.awaitFocusedRatings()
else if (data.signedIn) state.focused?.let { item ->
ratingsReader.read(item.tmdbId, item.mediaType, null, null)?.let { values ->
state.updateRatings(item.id, values.map { rating ->
val mapped = rating.homeRating()
ComingInRating(mapped.mark, mapped.score, mapped.percentage)
})
}
}
}
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth)

View file

@ -85,6 +85,12 @@ class ComingInState {
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
}
fun updateRatings(id: String, values: List<ComingInRating>) {
snapshot = snapshot.copy(items = snapshot.items.map { if (it.id == id) it.copy(ratings = values) else it })
snapshot.items.firstOrNull { it.id == id }?.let { cachedRatings.add(it.ratingKey) }
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
}
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
suspend fun awaitFocusedRatings() {
val item = focused ?: return

View file

@ -19,7 +19,7 @@ internal fun DiscoverTitle.libraryCard(state: DiscoverState): LibraryTitle {
}
return LibraryTitle(id = key, title = title, type = type, year = year,
runtimeSeconds = runtimeSeconds?.toLong(), seasons = seasonCount, poster = poster,
ratings = ratings.map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
ratings = state.ratingsFor(this).map { LibraryRating(it.source, it.score, it.label) }, availability = availability,
badge = when {
held -> "held"
receipts.isEmpty() -> null

View file

@ -29,7 +29,7 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
snapshot.lanes.forEach { lane ->
val source = lane.fullTitles.filter { matches(it) && it.key !in displayed && it.key !in hidden }
val items = source.take(if (lane.id.startsWith("new-")) 12 else WolfskinTokens.Behaviour.rowLimit.toInt())
if (items.isNotEmpty() || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
if (items.isNotEmpty() || lane.pending || lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) {
result += lane.copy(titles = items, fullTitles = source, total = if (lane.id.startsWith("new-")) items.size else source.size,
tail = if (lane.tail == DiscoverTail.Retry || lane.tail == DiscoverTail.ShowMore) lane.tail else if (!lane.id.startsWith("new-") && source.size > items.size) DiscoverTail.SeeAll else DiscoverTail.None)
if (!snapshot.reusesFixtureLanes) items.forEach { displayed.add(it.key) }

View file

@ -45,6 +45,8 @@ data class DiscoverSnapshot(
val status: DiscoverStatus = DiscoverStatus.NeedsSignIn,
val savedAt: String? = null,
val reusesFixtureLanes: Boolean = false,
val reachable: Boolean = true,
val requestsAllowed: Boolean = true,
)
/** Lane identity survives lazy recycling and provider refreshes. */
@ -57,6 +59,7 @@ data class DiscoverLane(
val pick: Boolean = false,
val hidden: Boolean = false,
val fullTitles: List<DiscoverTitle> = titles,
val pending: Boolean = false,
)
/** Caller identity is deliberately absent: the authenticated client supplies the actor. */

View file

@ -48,13 +48,21 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
}
LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } }
val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column)
val ratingsReader = LocalScreenRatings.current
LaunchedEffect(focusedTitle?.key, state.contentFocused) {
state.plateKey = null
if (state.contentFocused && focusedTitle != null) {
if (focusedTitle.key !in state.observedRatings) kotlinx.coroutines.delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (state.isSignedIn && ratingsReader != null) ratingsReader.read(focusedTitle.tmdb, focusedTitle.type, null, null)?.let { state.updateRatings(focusedTitle, it) }
state.observedRatings.add(focusedTitle.key); state.plateKey = focusedTitle.key
}
}
LaunchedEffect(state.hero?.key, state.title?.key, state.isSignedIn) {
if (state.isSignedIn && ratingsReader != null) {
val title = state.title ?: state.hero
if (title != null) ratingsReader.read(title.tmdb, title.type, null, null)?.let { state.updateRatings(title, it) }
}
}
if (state.title != null) { DiscoverTitleDestination(state, modifier); return }
state.collection?.let {
LibraryScreen(shell, it, modifier, ratingsSignedIn = state.isSignedIn, offlineArt = LocalDiscoverArt.current, registerFocus = false)

View file

@ -76,6 +76,10 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
}
} else if (lane.pending) {
items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
DiscoverPoster(state, item, active && index == column, Modifier.clickable {
@ -88,7 +92,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
}
}
if (!services && active) {
if (!services && active && !lane.pending) {
val item = lane.titles.getOrNull(column)
Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
@ -105,7 +109,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
@Composable
fun DiscoverPoster(state: DiscoverState, title: DiscoverTitle, focused: Boolean, modifier: Modifier = Modifier) {
val receipts = state.requests[title.key]
val card = remember(title, receipts, state.shell.person.key) { title.libraryCard(state) }
val card = remember(title, receipts, state.ratingsFor(title), state.shell.person.key) { title.libraryCard(state) }
LibraryPoster(card, focused, focused && state.plateKey == title.key && state.isSignedIn, true,
modifier.zIndex(if (focused) 1f else 0f), offlineArt = LocalDiscoverArt.current)
}

View file

@ -19,6 +19,8 @@ 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,
private val onMore: (suspend (String) -> List<DiscoverLane>?)? = null,
) : ShellContentFocus {
var snapshot by mutableStateOf(initial)
private set
@ -46,6 +48,7 @@ class DiscoverState(
val requests = mutableStateMapOf<String, List<DiscoverRequestResult>>()
private val requestSheets = mutableMapOf<Pair<String, String>, RequestSheetState>()
private val hidden = mutableStateMapOf<String, DiscoverTitle>().apply { initial.hidden.forEach { put(it.key, it) } }
private val ratingValues = mutableStateMapOf<String, List<DiscoverRating>>()
private lateinit var collectionCards: LinkedHashMap<String, LibraryTitle>
private var revision by mutableStateOf(0)
private var cachedRevision = -1
@ -59,11 +62,44 @@ class DiscoverState(
private val dismissalVersions = mutableMapOf<String, Int>()
private var rootFilter = "all"
fun updateSnapshot(value: DiscoverSnapshot) {
snapshot = value
hidden.clear()
value.hidden.forEach { hidden[it.key] = it }
revision++
}
fun invalidateLanes() { revision++ }
fun updateLanes(value: List<DiscoverLane>) { snapshot = snapshot.copy(lanes = value); revision++ }
private suspend fun loadLane(id: String) {
val loaded = onMore?.invoke(id)
if (loaded != null) updateLanes(loaded) else { snapshot = operations.refresh(); revision++ }
}
fun updateTitle(value: DiscoverTitle) {
if (title?.key == value.key) title = value
}
fun ratingsFor(item: DiscoverTitle): List<DiscoverRating> = ratingValues[item.key] ?: item.ratings
fun updateRatings(item: DiscoverTitle, values: List<LibraryRating>) {
val ratings = values.map { DiscoverRating(it.source, it.score, it.label) }
ratingValues[item.key] = ratings
fun replace(source: List<DiscoverTitle>) = source.map { if (it.key == item.key) it.copy(ratings = ratings) else it }
snapshot = snapshot.copy(picks = replace(snapshot.picks),
lanes = snapshot.lanes.map { it.copy(titles = replace(it.titles), fullTitles = replace(it.fullTitles)) },
seeds = snapshot.seeds.map { it.copy(items = replace(it.items)) })
title?.takeIf { it.key == item.key }?.let { title = it.copy(ratings = ratings) }
revision++
}
val isSignedIn: Boolean get() = snapshot.status != DiscoverStatus.NeedsSignIn
val canRequest: Boolean get() = when (snapshot.status) {
DiscoverStatus.Ready, DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable -> true
else -> false
}
val requestsEnabled: Boolean get() = canRequest && snapshot.reachable && snapshot.requestsAllowed
val hasHero: Boolean get() = service == null && collection == null && heroes.isNotEmpty() && canRequest
val heroes: List<DiscoverTitle> get() {
if (cachedHeroesRevision != revision || cachedHeroesFilter != filter) {
@ -88,7 +124,7 @@ class DiscoverState(
val focusedLane: DiscoverLane? get() = lanes.getOrNull(focusedRow - firstShelf)
val column: Int get() = shell.view.cards[rowKey].orZero().coerceAtMost(maxColumn())
val rowKey: String get() = when (focusedRow) { 0 -> "phero"; 1 -> "dchips"; else -> focusedLane?.id.orEmpty() }
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null
val isCard: Boolean get() = title == null && focusedRow >= firstShelf && focusedLane != null && focusedLane?.pending != true
val contentFocused: Boolean get() = !shell.navFocused && !shell.pickerVisible && overlay == null
override val cardKey: String? get() = when {
overlay != null || title != null -> null
@ -127,7 +163,7 @@ class DiscoverState(
grid = LibraryGridState(lane.title, collectionCards.values.filter { it.id !in hidden }, catalogue = true, observedRatings = observedRatings,
onTitle = { openTitle(titles.getValue(it.id)) }, onDiscover = { collection = null; onPop() }, onRecover = {
scope.launch {
snapshot = operations.refresh(); revision++
loadLane(lane.id)
if (collection !== grid) return@launch
val fresh = lanes.firstOrNull { it.id == lane.id }
if (canRequest && fresh != null) {
@ -142,6 +178,16 @@ class DiscoverState(
}).apply {
tail = when (lane.tail) { DiscoverTail.Retry -> GridTail.Retry; DiscoverTail.ShowMore -> GridTail.ShowMore; else -> GridTail.None }
}
if (onMore != null && lane.tail != DiscoverTail.None) grid.pagination = LibraryPagination(grid) {
loadLane(lane.id)
val fresh = lanes.firstOrNull { it.id == lane.id } ?: error("Catalogue lane unavailable")
if (fresh.tail == DiscoverTail.Retry) error("Catalogue read unavailable")
titles = fresh.fullTitles.associateBy { it.key }
collectionCards = LinkedHashMap<String, LibraryTitle>(titles.size).apply {
titles.values.forEach { put(it.key, it.libraryCard(this@DiscoverState)) }
}
LibraryPage(collectionCards.values.filter { it.id !in hidden }, fresh.tail != DiscoverTail.None)
}
collection = grid
onPush("discover-see-all/${lane.id}")
}
@ -217,7 +263,7 @@ class DiscoverState(
if (lane.id == "svc") { snapshot.services.getOrNull(column)?.let(::openService); return }
val item = lane.titles.getOrNull(column)
if (item == null) {
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) refresh()
if (lane.tail in setOf(DiscoverTail.Retry, DiscoverTail.ShowMore)) scope.launch { loadLane(lane.id) }
else openCollection(lane)
} else if (lane.hidden) overlay = DiscoverOverlayState.Restore(item)
else openTitle(item)
@ -243,7 +289,8 @@ class DiscoverState(
fun openRequest(other: Boolean, item: DiscoverTitle = requireNotNull(title), phase: RequestPhase = RequestPhase.Choose) {
if (!canRequest) { refresh(); return }
val choice = RequestSheetState(item, shell.person, shell.people, other, phase)
if (!requestsEnabled) { toast = "Can’t request right now. Try again."; return }
val choice = RequestSheetState(item, shell.person, requesters, other, phase)
val previous = requestSheets[item.key to choice.selectedPerson.key]
overlay = DiscoverOverlayState.Request(
if (phase == RequestPhase.Choose && previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) requireNotNull(previous) else choice,
@ -276,7 +323,7 @@ class DiscoverState(
}
private fun send(sheet: RequestSheetState, check: Boolean) {
if (!canRequest || (!check && !sheet.canSubmit)) return
if (!requestsEnabled || (!check && !sheet.canSubmit)) return
if (!check) {
val previous = requestSheets[sheet.title.key to sheet.selectedPerson.key]
if (previous?.phase in setOf(RequestPhase.Sending, RequestPhase.NotConfirmed, RequestPhase.Pending)) {

View file

@ -80,6 +80,7 @@ fun HomeScreen(
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
}
val cache = view.home.ratings
val ratingsReader = LocalScreenRatings.current
val row = snapshot.rows.getOrNull(view.row)
val title = row?.cards?.getOrNull(focus.column)
val hero = snapshot.rows.firstOrNull { it.kind == HomeRowKind.Hero }?.cards?.firstOrNull()
@ -87,13 +88,14 @@ fun HomeScreen(
var focusedRatings by remember(title?.id, shell.navFocused) { mutableStateOf(title?.let(cache::read).orEmpty()) }
LaunchedEffect(title?.id, hero?.id, shell.navFocused, shell.pickerVisible, snapshot.conductarr) {
if (hero != null && snapshot.conductarr == HomeAvailability.Ready) {
heroRatings = hero.ratings
cache.save(hero, heroRatings)
val values = if (ratingsReader == null) hero.ratings else hero.tmdbId?.let { ratingsReader.read(it.toLong(), hero.type, hero.season, hero.episode)?.map(LibraryRating::homeRating) }
if (values != null) { heroRatings = values; cache.save(hero, values) }
}
if (title != null && !shell.navFocused && !shell.pickerVisible && snapshot.conductarr == HomeAvailability.Ready) {
val saved = cache.read(title)
if (saved == null && row.kind != HomeRowKind.Hero) delay(WolfskinTokens.Motion.ratingDwell.toLong())
focusedRatings = saved ?: title.ratings.also { cache.save(title, it) }
val values = saved ?: if (ratingsReader == null) title.ratings else title.tmdbId?.let { ratingsReader.read(it.toLong(), title.type, title.season, title.episode)?.map(LibraryRating::homeRating) }
if (values != null) { focusedRatings = values; cache.save(title, values) }
}
}
val showRatings = snapshot.conductarr != HomeAvailability.SignedOut

View file

@ -49,6 +49,7 @@ fun LibraryScreen(
val focused = grid.focusedTitle
val count = if (grid.catalogue) items.size else grid.source.size
var plateKey by remember { mutableStateOf<String?>(null) }
val ratingsReader = LocalScreenRatings.current
DisposableEffect(shell, grid, registerFocus) {
if (registerFocus) shell.contentFocus = grid
onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null }
@ -72,6 +73,11 @@ fun LibraryScreen(
plateKey = null
if (active && ratingsSignedIn && focused != null && (ratingsReachable || focused.id in cachedRatings || focused.id in grid.observedRatings)) {
if (focused.id !in cachedRatings && focused.id !in grid.observedRatings) delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (ratingsReader != null) {
val tmdb = focused.tmdbId?.toLongOrNull() ?: focused.id.substringAfter(':', "").toLongOrNull()
val ratings = tmdb?.let { ratingsReader.read(it, focused.type, null, null) }
if (ratings != null) grid.source = grid.source.map { if (it.id == focused.id) it.copy(ratings = ratings) else it }
}
if (focused.ratings.isNotEmpty()) grid.observedRatings.add(focused.id)
plateKey = focused.id
}

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.compositionLocalOf
/** One focused-item collector reads through this seam; null keeps deterministic fixture ratings. */
fun interface ScreenRatings {
suspend fun read(tmdb: Long, type: String, season: Int?, episode: Int?): List<LibraryRating>?
}
val LocalScreenRatings = compositionLocalOf<ScreenRatings?> { null }
/** Convert supplier marks without inferring a Rotten Tomatoes label from a percentage. */
fun LibraryRating.homeRating(): HomeRating = HomeRating(
mark = when (label) { "Fresh", "Certified Fresh" -> "fresh"; "Rotten" -> "rotten"; "Upright" -> "upright"; "Spilled" -> "spilled"; else -> when (source) { "rtCritics" -> "tomato"; "rtAudience" -> "popcorn"; else -> source } },
score = score, percentage = source.startsWith("rt"), episode = source == "episode",
)

View file

@ -7,7 +7,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Household identity data contains no credentials or server objects. */
data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean)
data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean, val jellyfinUserId: String? = null)
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
sealed interface ShellAction {
@ -42,10 +42,21 @@ class RememberedView {
/** Focus and Back transitions are independent of drawing and server availability. */
class ShellState(
val people: List<ShellPerson>,
people: List<ShellPerson>,
initialPerson: String,
private val onAction: (ShellAction) -> Unit = {},
) {
var people by mutableStateOf(people)
private set
/** Refresh household labels without discarding this person's navigation memory. */
fun updatePeople(value: List<ShellPerson>) {
val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId }
?: value.firstOrNull { it.key == person.key }
people = value
if (selected != null) person = selected
pickerIndex = people.indexOf(person).coerceAtLeast(0)
}
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set
var route by mutableStateOf("home")