Merge remote-tracking branch 'origin/main' into fm/tv-fb1-visual
All checks were successful
review/code-gpt Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner
review/design-claude Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner

This commit is contained in:
justinzeus 2026-11-06 20:39:32 +01:00
commit 12fd12f754
22 changed files with 11163 additions and 53 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()

View file

@ -8,7 +8,7 @@ import org.junit.Test
class DiscoverLaneProjectionTest {
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))),
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("movie:10", "Seed", titles.subList(3, 5), watched = true, added = false)),
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
@ -30,4 +30,25 @@ class DiscoverLaneProjectionTest {
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(series)), filtered.map { it.titles })
}
@Test fun addedOnlySeedsKeepTheirPicksWithoutClaimingTheyWereWatched() {
val titles = (1L..9L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
DiscoverSeed("movie:10", "Added seed", titles, watched = false, added = true)), status = DiscoverStatus.Ready)
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("Movies for you"), lanes.map { it.title })
assertEquals(titles, lanes.single().fullTitles)
}
@Test fun watchedLanesKeepWireOrderAndStableIdentityWhenTitlesCollide() {
val titles = (1L..24L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
DiscoverSeed("movie:50", "Added seed", titles.take(8), watched = false, added = true),
DiscoverSeed("tv:70", "Same title", titles.subList(8, 16), watched = true, added = true),
DiscoverSeed("movie:70", "Same title", titles.subList(16, 24), watched = true, added = false)),
status = DiscoverStatus.Ready)
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "pk-w-tv:70", "pk-w-movie:70"), lanes.map { it.id })
assertEquals(listOf(titles.subList(8, 16), titles.subList(16, 24)), lanes.drop(1).map { it.titles })
}
}

View file

@ -53,7 +53,6 @@ class RequestsKeysTest {
assertEquals(path.getInt("presses"), presses.length())
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
rule.onNodeWithTag("coming-in").assertExists()
rule.onNodeWithText("Requests · 5 open").assertExists()
assertEquals("settings", state.route)
assertEquals("x5", state.requests.focusedId)
assertFalse(state.navFocused)
@ -144,7 +143,5 @@ class RequestsKeysTest {
val viewer = fixturePeople().single { it.key == "shared" }
assertEquals("Shared", shared.actor(viewer))
assertEquals("Justin", shared.recipient(viewer))
assertEquals(5, snapshot.openCount)
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
}
}

View file

@ -38,12 +38,12 @@ class ShellCaptureTest {
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
private fun capture(name: String, requestCount: Int? = null, configure: (ShellState) -> Unit = {}) {
val state = ShellState(fixturePeople(), "justin")
configure(state)
rule.setContent {
WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsRequestCount = requestCount)
}
}
rule.mainClock.advanceTimeBy(1000)
@ -67,7 +67,7 @@ class ShellCaptureTest {
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
}