Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens #64

Merged
justinzeus merged 25 commits from fm/tv-round2-rc into fm/tv-round2 2026-11-07 21:14:15 +01:00
22 changed files with 515 additions and 92 deletions
Showing only changes of commit febcded034 - Show all commits

View file

@ -40,6 +40,7 @@
<application
android:allowBackup="true"
android:hasFragileUserData="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View file

@ -70,7 +70,7 @@ class ConductarrTitlePagesTest {
assertEquals(TitleConnection.SignedOut, revoked.connection)
assertTrue(revoked.item.ratings.isEmpty())
assertTrue(revoked.playTarget!!.ratings.isEmpty())
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.options.any { it.id == "add" })
assertFalse(TitleState(revoked, ShellPerson("shared", "Shared", "S", true)) {}.menuOptions.any { it.id == "add" })
}
}
}

View file

@ -76,6 +76,9 @@ class ConductarrTitleSurfaceTest {
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "conductarr-title-real.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionCenter) }
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithText("Add for someone").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Request for Mindel").assertIsDisplayed()
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown); pressKey(Key.DirectionCenter) }
rule.waitUntil(30_000) {
@ -97,7 +100,8 @@ class ConductarrTitleSurfaceTest {
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") })
assertFalse(viewModel.title!!.options.any { it.id == "request" })
assertFalse(viewModel.title!!.menuOptions.any { it.id == "add" })
} finally { viewModel.viewModelScope.cancel() }
}
}

View file

@ -128,6 +128,8 @@ class PersonPickerSessionTest {
private fun awaitLoaded(user: JellyfinUser) {
try {
rule.waitUntil(10_000) {
// Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition.
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
Shadows.shadowOf(Looper.getMainLooper()).idle()
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
models.last().conductarr.identity.value?.signedIn == true &&

View file

@ -87,7 +87,7 @@ class TitleCaptureTest {
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
}
}
return requests.distinctBy { it.first }.associate { (path, width, height) ->
@ -170,7 +170,10 @@ class TitleCaptureTest {
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
@Test fun st047_mediaInfo() = capture(47) { state, shell ->
open(state, shell, "menu")
state.clickSheet(0)
}
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@ -216,4 +219,10 @@ class TitleCaptureTest {
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
}

View file

@ -14,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray
import org.json.JSONObject
@ -36,7 +33,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
@RunWith(RobolectricTestRunner::class)
@ -284,15 +280,6 @@ class TitleKeysTest {
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) })
@ -315,26 +302,6 @@ class TitleKeysTest {
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
render()
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
press(Key.DirectionCenter)
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
}
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun filmHeaderKeepsItsFixedCssStack() {
render()
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {

View file

@ -0,0 +1,274 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.graphics.BitmapFactory
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
/** Native title interactions keep secondary actions, focus and section geometry intact. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class TitleRound2Test {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val effects = mutableListOf<TitleAction>()
private lateinit var state: TitleState
private lateinit var shell: ShellState
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
val fixture = fixture()
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
} }
}
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
private fun menu() {
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
press(Key.DirectionCenter)
rule.mainClock.advanceTimeBy(300)
rule.waitForIdle()
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
render()
menu()
val opener = state.column
press(Key.Back)
assertNull(state.sheet)
assertEquals(opener, state.column)
assertFalse(shell.navFocused)
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
press(Key.Back)
assertEquals(TitleSheet.Menu, state.sheet)
press(Key.DirectionDown)
press(Key.DirectionCenter)
assertNull(state.sheet)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
assertEquals(opener, state.column)
}
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
render("The Amazing Spider-Man")
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.DirectionCenter)
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
}
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
render("The Amazing Spider-Man")
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
val gap = cast.top - actions.bottom
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
assertTrue(requestedActions.bottom < requestedCast.top)
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
}
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
rule.onNodeWithTag("title-progress").assertIsDisplayed()
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
rule.onNodeWithText("Ep", useUnmergedTree = true).assertDoesNotExist()
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
press(Key.DirectionDown)
assertEquals("seasons", state.row)
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
}
@Test fun seriesKeepsItsSeasonsShortcutAndSecondaryRequestUsesTheMenu() {
render("Andor")
rule.onNodeWithTag("title-option-seasons").assertIsDisplayed()
menu()
rule.onNodeWithText("Add for someone").assertIsDisplayed()
press(Key.Back)
press(Key.DirectionRight)
press(Key.DirectionCenter)
assertEquals("seasons", state.row)
}
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
rule.waitForIdle()
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
assertEquals(skeleton, ready)
assertEquals(448f, ready.width, 0.1f)
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
rule.runOnIdle { state.focus("episodes", 0) }
val episode = state.data.episodes.first()
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
}
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
render()
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
rule.onNodeWithText("Add for someone").assertDoesNotExist()
press(Key.DirectionCenter)
assertEquals(TitleSheet.MediaInfo, state.sheet)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
}
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
for (held in listOf(false, true)) {
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
}
}
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
render("The Amazing Spider-Man")
menu()
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
assertNull(state.sheet)
rule.mainClock.advanceTimeBy(300)
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
}
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
render()
menu()
press(Key.DirectionCenter)
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
}
} }
}
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
menu()
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
rule.runOnIdle { state.clickSheet(0) }
var waited = 0
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val records = mutableListOf<String>()
fun frame(phase: String, index: Int): Pair<Int, Int> {
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(path.absolutePath)
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
fun brightness(x: Int, y: Int): Int {
val pixel = bitmap.getPixel(x, y)
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
}
return try {
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
} finally { bitmap.recycle() }
}
val info = List(14) { index ->
rule.waitForIdle()
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
rule.runOnIdle { state.back(shell) }
repeat(14) { index ->
rule.mainClock.advanceTimeBy(16)
rule.waitForIdle()
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
}
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
rule.mainClock.autoAdvance = true
}
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
var waited = 0
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
waited += 16
}
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Int {
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val image = BitmapFactory.decodeFile(file.absolutePath)
val brightness = try {
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
} finally { image.recycle() }
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
return brightness
}
fun assertMonotonic(values: List<Int>) {
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
val direction = if (values.last() > values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) ->
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
}
}
val entering = List(14) { index ->
rule.waitForIdle()
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
}
assertMonotonic(entering)
rule.runOnIdle { state.back(shell) }
val leaving = mutableListOf<Int>()
repeat(9) { index ->
rule.waitForIdle()
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
rule.mainClock.advanceTimeBy(16)
}
assertMonotonic(leaving)
rule.onNodeWithTag("title-menu").assertDoesNotExist()
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
rule.mainClock.autoAdvance = true
press(Key.DirectionCenter)
assertEquals(TitleSheet.Menu, state.sheet)
}
}

View file

@ -27,6 +27,22 @@ Upstream enables JVM unit tests only for debug variants; there is no
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
task without changing upstream's test configuration.
### Full-suite test diagnostics
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
from the forked test-worker heap. The default test worker uses 512 MiB; changing
the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve
the person-picker fixture's missing Compose snapshot notifications.
Gradle 9.8.2 runs previously failed test classes first on the next invocation.
An immediate green `make test` rerun can therefore hide an order-sensitive
failure rather than prove a fix. For comparisons, clear only generated binary
test-result history (`build/test-results/<task>/binary` in each module) before
each run and record actual class-start order; XML file listing order is not
execution order. Keep worker heap and parallelism unchanged between samples.
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
or promoted to the front by previous failures.
## Saved-person launch and DNS
Launch restores an unprotected saved Jellyfin login from local storage before
@ -645,18 +661,21 @@ retain the approved frames except for the requested feedback changes:
that may still be cancelled; first mount restores the intended shelf item.
Discover uses all six approved poster slots. Person keeps the final column
at full poster width.
- Episode images choose episode Primary, then Thumb, then the already-read
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
Episode context uses the episode overview, with two summary lines on the
focused card. Held-series headers measure their copy/actions and leave 40 px
before seasons instead of reserving a fixed empty 900 px header.
- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
Title episode cards now use 448×280 px, preserving the taller approved crop.
Focused summaries ellipsise to one line, the show-name overlay is absent, and
`S1 E3 · duration` plus the watched check share one line.
Ready title headers measure their copy/actions and leave 40 px before the
following section; series retain the empty 56 px readiness reservation.
Poster-only movies floor that stack so Cast circles clear the poster by at
least 20 px, including held movie fallbacks and short overviews.
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
gap. Home heading geometry is unchanged.
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
authentication query parameters remain intact. TMDB uses w342 for posters and
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
@ -708,6 +727,26 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
target: Down stays in nav, while Back can still leave the title. A missing rating
set emits no layout node, so it cannot reserve an invisible extra facts gap.
The title Menu replaces the old More/media-info icon action and contains Media
info plus Add for someone when requests are permitted. It is shared by held
movie, series and episode pages; non-held pages expose Add for someone in the
same menu while Request/status and Not interested/Show again stay in the row.
Version, tracks, watched and trailer controls keep their existing positions.
Back from Media info returns to the menu; Back from the menu restores its
opening button. The compact menu fades in for 160 ms and out for 100 ms using
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
bounded menu panel owns an alpha layer; the scrim is static. Opening another
sheet immediately removes the menu panel and scrim, preventing double darkening.
An empty menu closes if its last permitted action disappears. Controls
use the shared 4 px straddle focus ring. Episode details omit the redundant
Seasons shortcut; series pages retain it. Ratings appear only in the header
facts line, not again beside progress, on movie, series and episode pages.
Episode shelf skeletons use the same width, height and pitch as ready cards.
Person filmography and related-title shelves remain posters; Home continuation
tiles are a separate component and retain their series-name context.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. `ConductarrTitlePages`
adds real detail, cast, related titles, performer/filmography, readiness/receipts
@ -732,7 +771,10 @@ headers, sheets, cast and poster captions without changing shared type tokens.
Fractional stack placement rounds only the accumulated position, preventing a
one-pixel-per-row drift. Focused controls share the canonical lift shadow.
`TitleKeysTest` replays real Android keys, including modal Back/hold, nearest-row
entry, remembered columns, long-sheet visibility and full media-table geometry.
entry, remembered columns and long-sheet visibility. `TitleRound2Test` covers
menu Back/focus, preserved media info and household requests, movie stacking,
the Mussolini resume example, request revocation, episode skeleton geometry,
and native menu fade frames sampled in 16 ms steps.
`TitleAsyncFocusTest` delays title and person replies through the real destination
and view-model, then exercises content focus and hardware Back. Subtitles Off
maps to the player's explicit disabled sentinel, not its unspecified default.
@ -1016,12 +1058,30 @@ Keep and back up this file across clean builds, disposable worktrees, container
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
are public; this key is for local debug/optimized builds, not release signing.
**One-time reinstall:** the first build with the stable key still needs one
uninstall+reinstall on the box because the signer changes once; later ones
install in place with `adb install -r`, retaining sign-ins and settings.
The initial uninstall removes app data, so sign in again once after that switch.
Deleting/replacing the persistent key or choosing a different override changes
the signer again and requires another reinstall.
### Preserve household TV data
Install and update the household TV app only in place with `adb install -r`,
using the same package and persistent debug signer. Its certificate SHA-256 is:
```text
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
```
Never run `adb uninstall`, `pm uninstall` or `pm clear`, or switch signers on
the household box: clearing/removing the app deletes saved people, sign-ins
and settings, and a different signer cannot update the existing app in place.
Do not delete or replace the persistent key. If an uninstall is unavoidable,
use `adb uninstall -k <package>` to keep app data, then reinstall the same
package with the same signer.
The manifest sets `android:hasFragileUserData="true"` so Android 10+ can offer
a **keep app data** choice during interactive uninstall. Choose it when
available; older Android versions ignore the attribute. This is a retention
prompt, not an automatic backup or a guarantee against data clearing.
For an in-place update, compare `dumpsys package <pkg>` before and after:
`firstInstallTime` must remain unchanged. The guarded installer that checks
the signer and this timestamp lives in the operator's tooling, not this repo.
## Release credentials

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
@ -110,7 +111,7 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
.size(L.heroSheetWidth, L.heroSheetHeight).testTag("title-poster-backdrop").dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
}
Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical))

View file

@ -74,7 +74,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))
}
state.data.playTarget?.let { target ->
state.data.playTarget?.takeIf { it.resumeSeconds > 0 || it.kind == "episode" }?.let { target ->
Row(Modifier.exactLineBox(TitleTextStyles.progress, top = D.progressLift, bottom = D.progressBottom).testTag("title-progress"), horizontalArrangement = Arrangement.spacedBy(D.progressGap), verticalAlignment = Alignment.CenterVertically) {
if (target.resumeSeconds > 0) {
Box(Modifier.size(D.progressWidth, L.progressBar).background(C.progressTrack, RoundedCornerShape(WolfskinTokens.Radius.progress))) {
@ -83,7 +83,6 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
}
Text((if (target.kind == "episode") "${target.episodeLabel} · " else "") + "${((target.runtimeSeconds - target.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left", color = C.muted, style = TitleTextStyles.progress)
} else if (target.kind == "episode") Text("Up next: ${target.episodeLabel} · ${target.name}", color = C.muted, style = TitleTextStyles.progress)
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = TitleTextStyles.progress)
}
}
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true).testTag("title-actions"), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {

View file

@ -0,0 +1,69 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.graphicsLayer
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** A compact modal keeps secondary actions reachable without growing the header row. */
@Composable
internal fun TitleMenu(state: TitleState, shell: ShellState) {
val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible
val focus = remember { FocusRequester() }
val reduced = reducedMotion()
val opacity = remember { Animatable(0f) }
LaunchedEffect(visible, reduced) {
if (visible) focus.requestFocus()
opacity.animateTo(if (visible) 1f else 0f,
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
}
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)
.clickable { if (visible) state.back(shell) }
.focusRequester(focus).onPreviewKeyEvent { event ->
val key = readShellKey(event)
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
.graphicsLayer { alpha = opacity.value }
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading)
state.menuOptions.forEachIndexed { index, option ->
val focused = visible && state.sheetIndex == index
TitleControl(option, focused,
Modifier.fillMaxWidth(),
onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) {
if (visible) state.clickSheet(index)
}
}
}
}
}
}

View file

@ -63,7 +63,7 @@ data class TitleVersion(
enum class TitleReadStatus { Ready, Loading, Unavailable }
enum class TitleConnection { SignedIn, SignedOut, Offline }
enum class TitleSheet { Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
enum class TitleSheet { Menu, Version, Tracks, MediaInfo, ConfirmWatched, HiddenReceipt }
/** A title read includes related titles and the selected season; reads happen outside composition. */
@Immutable

View file

@ -62,7 +62,7 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
TitleSheet.MediaInfo -> MediaInformation(state)
TitleSheet.ConfirmWatched -> WatchedConfirmation(state)
TitleSheet.HiddenReceipt -> HiddenReceipt(state)
null -> Unit
TitleSheet.Menu, null -> Unit
}
}
}

View file

@ -69,7 +69,8 @@ fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modi
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false) { TitlePage(state, shell, it) }
}
if (!shell.pickerVisible && state.sheet != null) TitleOptionsSheet(state, shell)
if (!shell.pickerVisible && state.sheet != null && state.sheet != TitleSheet.Menu) TitleOptionsSheet(state, shell)
TitleMenu(state, shell)
if (!shell.pickerVisible) TitleNotice(state.toast, Modifier.align(Alignment.TopEnd)) { state.toast = null }
}
}
@ -92,9 +93,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() }
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
state.data.item.kind == "tv" && state.data.item.seasons > 0
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
val compactHeader = state.data.status == TitleReadStatus.Ready
val item = state.data.item
val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) {
if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
}
@ -155,7 +158,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val active = state.row == id && !shell.navFocused && state.sheet == null
when (id) {
"header" -> Box(Modifier.height(headerHeight)) {
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
TitleHeader(state, active, if (compactHeader) Modifier.onSizeChanged { size ->
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
} else Modifier)
}

View file

@ -51,7 +51,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
LaunchedEffect(state.column, active, state.data.cast.size) {
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
}
Box(Modifier.height(D.castHeight)) {
Box(Modifier.height(D.castHeight).testTag("title-cast")) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2).skeletonPulse(state.data.castStatus == TitleReadStatus.Loading),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
@ -80,11 +80,11 @@ fun TitleCast(state: TitleState, active: Boolean) {
fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val titles = if (episodes) state.data.episodes else state.data.related
val id = if (episodes) "episodes" else "related"
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val width = if (episodes) D.episodeCardWidth else WolfskinTokens.Card.posterW
val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
val track = rememberLazyListState()
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
val visible = if (episodes) ((WolfskinTokens.Shell.stageWidth - L.safeSide * 2 + WolfskinTokens.Card.tileGap) / (D.episodeCardWidth + WolfskinTokens.Card.tileGap)).toInt() else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) D.episodeCardWidth + WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active, titles.size) {
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
}
@ -92,13 +92,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2).skeletonPulse(episodes && state.data.episodesStatus == TitleReadStatus.Loading),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(if (episodes) WolfskinTokens.Card.tileGap else WolfskinTokens.Card.posterGap)) {
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { index ->
Box(Modifier.size(width, height).testTag("title-episode-slot-$index").background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
} else itemsIndexed(titles, key = { _, item -> item.id }, contentType = { _, _ -> if (episodes) "tile" else "poster" }) { index, item ->
TitlePoster(item, active && state.column == index, Modifier.width(width)
TitlePoster(item, active && state.column == index, Modifier.width(width).then(if (episodes) Modifier.testTag("title-episode-slot-$index") else Modifier)
.onGloballyPositioned { state.recordCenter(id, index, it.boundsInRoot().center.x) },
episodes, state.data.connection != TitleConnection.SignedOut, onFocus = { state.focus(id, index) }) { state.click(id, index) }
}
@ -117,17 +117,16 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
D.episodeCardWidth, D.episodeCardHeight, watched = item.watched,
progress = item.progress.takeIf { it < 1f } ?: 0f) {
TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
Text(item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
CardMetadata(null, item.episodeLabel + if (item.runtimeSeconds > 0) " · ${item.runtime}" else "", item.watched && item.resumeSeconds <= 0)
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut)), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()), easing = WolfskinTokens.Motion.easeOut))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}

View file

@ -24,6 +24,13 @@ class TitleState(
var sheet by mutableStateOf<TitleSheet?>(null)
private set
var sheetIndex by mutableStateOf(0)
private var mediaInfoFromMenu = false
val menuOptions: List<TitleOption> by derivedStateOf { buildList {
if (target != null) add(TitleOption("info", "Media info", "", icon = false))
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) {
add(TitleOption("add", "Add for someone", "users", icon = false))
}
} }
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
var versionId by mutableStateOf(initial.selectedVersion)
var audioIndex by mutableStateOf(initial.selectedAudio)
@ -65,6 +72,7 @@ class TitleState(
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
if (row !in rows) { row = "header"; column = 0 }
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null
}
/** Pointer focus and D-pad use the same row memory. */
@ -140,7 +148,7 @@ class TitleState(
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
fun back(shell: ShellState) {
when {
sheet != null -> sheet = null
sheet != null -> closeSheet()
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
else -> onAction(TitleAction.Pop)
}
@ -182,13 +190,13 @@ class TitleState(
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
"retry" -> onAction(TitleAction.Retry)
"back" -> onAction(TitleAction.Pop)
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
"request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
"status" -> onAction(TitleAction.Requests(data.item))
"hide", "unhide" -> {
val hidden = id == "hide"
@ -205,9 +213,10 @@ class TitleState(
}
private fun sheetKey(key: ShellKey) {
if (key == ShellKey.Back) { sheet = null; return }
if (key == ShellKey.Back) { closeSheet(); return }
val current = version
val size = when (sheet) {
TitleSheet.Menu -> menuOptions.size
TitleSheet.Version -> data.versions.size
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
@ -217,6 +226,16 @@ class TitleState(
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
ShellKey.Ok -> when (sheet) {
TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option ->
if (option.id == "info") {
mediaInfoFromMenu = true
sheetIndex = 0
sheet = TitleSheet.MediaInfo
} else {
sheet = null
activateOption(option.id)
}
}
TitleSheet.Version -> {
val selected = data.versions[sheetIndex]
versionId = selected.id
@ -234,11 +253,19 @@ class TitleState(
onAction(TitleAction.Hide(data.item, false))
} else onAction(TitleAction.Pop)
}
else -> sheet = null
else -> closeSheet()
}
}
}
private fun closeSheet() {
if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) {
mediaInfoFromMenu = false
sheetIndex = 0
sheet = TitleSheet.Menu
} else sheet = null
}
private fun selectTrack(current: TitleVersion) {
when {
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
@ -274,14 +301,13 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
val series = data.watchTarget.kind == "tv"
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
if (item.kind == "tv" && item.seasons > 0 && data.watchTarget.kind != "episode") add(TitleOption("seasons", "Seasons", "tv", icon = false))
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
} else {
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
add(TitleOption("other", "For someone else", "users", icon = false))
add(TitleOption("menu", "Menu", "more"))
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
}

View file

@ -262,7 +262,7 @@ object WolfskinTokens {
const val heroRequestHeight = 1080
const val heroPosterRequestWidth = 420
const val heroPosterRequestHeight = 630
const val episodeRequestWidth = 400
const val episodeRequestWidth = 448
const val episodeRequestHeight = 280
const val castRequestWidth = 200
const val castRequestHeight = 200
@ -451,8 +451,11 @@ object WolfskinTokens {
const val sheetOpacity = 0.5f
}
object Title {
val episodeCardWidth = 224.0.dp
val episodeCardHeight = 140.0.dp
val actionMenuWidth = 260.0.dp
val seriesHeaderGap = 20.0.dp
val posterCastGap = 10.0.dp
val lineGap = 11.0.dp
val lineLift = -2.0.dp
val overviewGap = 17.0.dp

View file

@ -18,7 +18,7 @@ class WolfskinImagesTest {
Surface("grid/Home/Discover/person poster", 256, 384, "w342"),
Surface("Home tile", 400, 225, "w780"),
Surface("Home wide", 544, 306, "w780"),
Surface("episode still", 400, 280, "w780"),
Surface("episode still", 448, 280, "w780"),
Surface("cast portrait", 200, 200, "w342"),
Surface("person portrait", 240, 240, "w342"),
Surface("hero poster", 420, 630, "w342", true),

View file

@ -1,7 +1,10 @@
{
"title": {
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
"episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"},
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"},
"actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"},
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
"posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"},
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},

View file

@ -290,8 +290,11 @@
--lift-heroY: 30.0px;
--lift-sheetBlur: 80.0px;
--lift-sheetX: -30.0px;
--title-episodeCardWidth: 448.0px;
--title-episodeCardHeight: 280.0px;
--title-actionMenuWidth: 520.0px;
--title-seriesHeaderGap: 40.0px;
--title-posterCastGap: 20.0px;
--title-lineGap: 22.0px;
--title-lineLift: -4.0px;
--title-overviewGap: 34.0px;

View file

@ -1543,9 +1543,9 @@
"source": "feedback-2026-11-06: largest upright hero poster slot"
},
"episodeRequest": {
"px": [400, 280],
"px": [448, 280],
"decode": "ARGB_8888",
"source": "feedback-2026-11-06: taller episode cards"
"source": "tv-round2: wider episode cards at their preserved height"
},
"castRequest": {
"px": [

View file

@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
695e086fd771ee463411a28e8f241637c3c86d13e9474995abaea27a1feff1b7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
71905adcd89991f8ce41859cce7714fcf382a19de25538e5272a84e88f070b07 tokens.css
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json
8c47b082670b4805c9c961addee6ef8b567d874bf93d8f8dc4b160eef6d18b6a src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
f188d525547d20d5751688573d8a81f8a2cb6c050dfb0141b8378842a7ff8a9c tokens.css
b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json