Present watched recommendation seeds and request history truthfully
All checks were successful
review/code-gpt Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
review/design-claude Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows

This commit is contained in:
justinzeus 2026-11-06 20:12:06 +01:00
parent deb24e3c9f
commit 278335f009
21 changed files with 11092 additions and 49 deletions

View file

@ -157,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
val actor = item.actor(viewer)
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
style = ComingInTypography.caption, color = secondary)
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
@ -168,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
}
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
item.stage.readiness.forEachIndexed { index, word ->
item.readiness.forEachIndexed { index, word ->
Row {
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" }
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
}
}
}
if (expanded) item.summary?.let { summary ->
Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary)
}
}
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
}

View file

@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
/** Household acquisition stages, ordered independently from their display wording. */
/** History states distinguish acquisition work from library evidence and playback health. */
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")),
CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")),
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")),
NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")),
HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")),
Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")),
NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")),
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
}
@ -33,13 +40,15 @@ data class ComingInRequest(
val stage: ComingInStage,
val poster: Any? = null,
val ratings: List<ComingInRating> = emptyList(),
val summary: String? = null,
val readiness: List<String> = stage.readiness,
) {
val ratingKey = "$mediaType:$tmdbId"
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
fun actor(viewer: ShellPerson): String = when {
fun actor(viewer: ShellPerson): String? = when {
actorKey == "shared" -> "Shared"
actorKey != null && actorKey == viewer.key -> "you"
else -> actorName ?: "Unknown"
else -> actorName?.takeIf { it.isNotBlank() }
}
}
@ -54,7 +63,13 @@ data class ComingInSnapshot(
val savedAt: String? = null,
) {
val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
val hasUnsettledRequests: Boolean get() = items.any {
when (it.stage) {
ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false
else -> true
}
}
}
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */

View file

@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
}
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
}
fun service(service: DiscoverService, type: String): List<DiscoverLane> {

View file

@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3)
val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }
.filter { it.items.isNotEmpty() }.take(3).toList()
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen }
@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
}
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed ->
groups.forEach { seed ->
val items = take(seed.items, size)
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
}
}
val displayed = heroes.mapTo(mutableSetOf()) { it.key }

View file

@ -26,8 +26,8 @@ data class DiscoverTitle(
/** Observed source marks keep labels separate from score values. */
data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
/** A contribution group preserves feed order rather than assigning a visible rank. */
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>)
/** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
/** Services carry their catalogue id and a text wordmark, never third-party logos. */
data class DiscoverService(val id: Long, val name: String, val hue: Float)

View file

@ -52,9 +52,9 @@ val SETTINGS_GROUPS = listOf(
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
)
/** The household request count shares the sheet's snapshot and excludes Ready. */
/** The count describes returned request history, not an inferred queue of active downloads. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
val list = state.view.listState
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
@ -78,7 +78,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") openRequests else null,
if (group.key == "requests") requestCount else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
}
@ -89,7 +89,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
/** The inverse focus fill has no focus ring and never changes row layout. */
@Composable
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
@ -97,7 +97,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}

View file

@ -55,7 +55,7 @@ fun ShellSurface(
state: ShellState,
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
settingsRequestCount: Int? = null,
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
@ -91,7 +91,7 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
overlay()