From 6d7513bd0e0c55a2308141c24006471a8ca1096b Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:05:07 +0100 Subject: [PATCH 1/2] Refine title episode cards, section stacking and action menus --- .../wolfskin/tv/ConductarrTitlePagesTest.kt | 2 +- .../wolfskin/tv/ConductarrTitleSurfaceTest.kt | 6 +- .../wolfskin/tv/TitleCaptureTest.kt | 11 +- .../justinvisser/wolfskin/tv/TitleKeysTest.kt | 33 --- .../wolfskin/tv/TitleRound2Test.kt | 197 ++++++++++++++++++ docker/README.md | 35 +++- .../wolfskin/design/TitleArtwork.kt | 4 +- .../wolfskin/design/TitleHeader.kt | 3 +- .../justinvisser/wolfskin/design/TitleMenu.kt | 69 ++++++ .../wolfskin/design/TitleModel.kt | 2 +- .../wolfskin/design/TitleOptionsSheet.kt | 2 +- .../justinvisser/wolfskin/design/TitlePage.kt | 10 +- .../wolfskin/design/TitlePrefetch.kt | 2 +- .../wolfskin/design/TitleShelf.kt | 27 ++- .../wolfskin/design/TitleState.kt | 43 +++- .../wolfskin/design/WolfskinTokens.kt | 4 +- wolfskin-design/title-tokens.json | 4 +- wolfskin-design/tokens.css | 4 +- wolfskin-design/tokens.sha256 | 6 +- 19 files changed, 379 insertions(+), 85 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt index 3c77fa66..d3ea3804 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitlePagesTest.kt @@ -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" }) } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt index e41862d8..27d2d214 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrTitleSurfaceTest.kt @@ -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() } } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 55f9d747..270271b1 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -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,8 @@ 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") } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 0b6dac33..4e664da3 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -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() - 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() { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt new file mode 100644 index 00000000..d0c503e4 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -0,0 +1,197 @@ +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() + private val effects = mutableListOf() + 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 must follow the action caption and normal section gap: $gap", gap in 40f..110f) + 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 + assertEquals("An actual readiness line belongs in the stack, not a fixed empty header", gap, requestedCast.top - requestedActions.bottom, 1f) + } + + @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(16f / 9f, ready.width / ready.height, 0.01f) + 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 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() + 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) { + 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() + 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) + } +} diff --git a/docker/README.md b/docker/README.md index 5cd6f6fa..c342ac2f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -627,18 +627,18 @@ 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×252 px (16:9), with a one-line focused summary, + no show-name overlay, and `S1 E3 · duration` plus the watched check on 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. - 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×252; 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 @@ -690,6 +690,24 @@ 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. 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 @@ -714,7 +732,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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 3a3efd9b..8adac820 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -136,13 +136,13 @@ fun TitleTileArtwork(item: TitleItem) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { for (index in 0 until 15) values[index] *= D.tileAmbientBrightness }) } - TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + TitleArtwork(item.poster, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) val height = D.episodeCardHeight - D.tileSheetInset * 2 val shape = remember { RoundedCornerShape(D.tileSheetRadius) } val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) - } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) + } else TitleArtwork(item.backdrop, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 5b2a7c47..85c224bf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -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)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt new file mode 100644 index 00000000..2195ff72 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt @@ -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) { + 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) + } + } + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 1fb71dd9..ab6379fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 3a87dcdd..49039828 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -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 } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index cefe5064..44800e1b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -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 } } } @@ -91,9 +92,8 @@ 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 headerHeight = if (compactHeader) measuredHeaderHeight else L.row_dhead LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } @@ -154,7 +154,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) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index d4a956fc..fdf8b69d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { val item = data.episodes[index] - queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight) + queue(if (item.isPosterBackdrop) item.poster else item.backdrop, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt()) } "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 429a38ce..708bd7ca 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -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()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index d8a6d560..af484aeb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -24,6 +24,13 @@ class TitleState( var sheet by mutableStateOf(null) private set var sheetIndex by mutableStateOf(0) + private var mediaInfoFromMenu = false + val menuOptions: List by derivedStateOf { buildList { + if (target != null) add(TitleOption("info", "Media info", "more", 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) @@ -140,7 +147,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 +189,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 +212,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 +225,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 +252,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 +300,13 @@ fun titleOptions(state: TitleState): List = 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)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 6c014755..459fd3fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -451,7 +451,9 @@ object WolfskinTokens { const val sheetOpacity = 0.5f } object Title { - val episodeCardHeight = 140.0.dp + val episodeCardWidth = 224.0.dp + val episodeCardHeight = 126.0.dp + val actionMenuWidth = 260.0.dp val seriesHeaderGap = 20.0.dp val lineGap = 11.0.dp val lineLift = -2.0.dp diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 309913d4..cdcda86e 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,6 +1,8 @@ { "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%; 16:9 art"}, + "episodeCardHeight": {"dp": 126, "px": 252, "source": "tv-round2: 16:9 episode art"}, + "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"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index cb81f2a5..8ca184ac 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -290,7 +290,9 @@ --lift-heroY: 30.0px; --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; - --title-episodeCardHeight: 280.0px; + --title-episodeCardWidth: 448.0px; + --title-episodeCardHeight: 252.0px; + --title-actionMenuWidth: 520.0px; --title-seriesHeaderGap: 40.0px; --title-lineGap: 22.0px; --title-lineLift: -4.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index a45b7f17..3ef0cd19 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json -d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css +12420580fed6e6977c8762fb0649f0ed22ca6a8df32d65ea69b3965cd851bb65 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +98054296d1de4df2f9f4808d505e512d5906f5f23f7a05e6d846391d93effa5e title-tokens.json +40d763d9023abb56c197201a6a13d3fefacb681713010f849d450c96216e37cb tokens.css ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json From a159c835c54564ce1b935aeda91436811bfb570f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:46:56 +0100 Subject: [PATCH 2/2] Preserve episode height and fix title overlay and poster collisions --- .../wolfskin/tv/TitleCaptureTest.kt | 2 + .../wolfskin/tv/TitleRound2Test.kt | 83 ++++++++++++++++++- docker/README.md | 13 ++- .../wolfskin/design/TitleArtwork.kt | 7 +- .../justinvisser/wolfskin/design/TitleMenu.kt | 2 +- .../justinvisser/wolfskin/design/TitlePage.kt | 5 +- .../wolfskin/design/TitlePrefetch.kt | 2 +- .../wolfskin/design/TitleState.kt | 3 +- .../wolfskin/design/WolfskinTokens.kt | 5 +- .../wolfskin/design/WolfskinImagesTest.kt | 2 +- wolfskin-design/title-tokens.json | 5 +- wolfskin-design/tokens.css | 3 +- wolfskin-design/tokens.json | 4 +- wolfskin-design/tokens.sha256 | 8 +- 14 files changed, 118 insertions(+), 26 deletions(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 270271b1..01a166a0 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -223,4 +223,6 @@ class TitleCaptureTest { @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.")) }) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt index d0c503e4..ff2edf66 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -88,11 +88,12 @@ class TitleRound2Test { val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot val gap = cast.top - actions.bottom - assertTrue("Cast must follow the action caption and normal section gap: $gap", gap in 40f..110f) + 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 - assertEquals("An actual readiness line belongs in the stack, not a fixed empty header", gap, requestedCast.top - requestedActions.bottom, 1f) + 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() { @@ -126,7 +127,8 @@ class TitleRound2Test { rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) } val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot assertEquals(skeleton, ready) - assertEquals(16f / 9f, ready.width / ready.height, 0.01f) + 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() @@ -143,6 +145,81 @@ class TitleRound2Test { 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() + fun frame(phase: String, index: Int): Pair { + 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 diff --git a/docker/README.md b/docker/README.md index c342ac2f..5522ca13 100644 --- a/docker/README.md +++ b/docker/README.md @@ -628,17 +628,20 @@ retain the approved frames except for the requested feedback changes: 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. - Title episode cards now use 448×252 px (16:9), with a one-line focused summary, - no show-name overlay, and `S1 E3 · duration` plus the watched check on one line. + 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 - 448×252; 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 @@ -698,7 +701,9 @@ 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. Controls +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. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 8adac820..25f5cffa 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -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)) @@ -136,13 +137,13 @@ fun TitleTileArtwork(item: TitleItem) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { for (index in 0 until 15) values[index] *= D.tileAmbientBrightness }) } - TitleArtwork(item.poster, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) + TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) val height = D.episodeCardHeight - D.tileSheetInset * 2 val shape = remember { RoundedCornerShape(D.tileSheetRadius) } val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) - } else TitleArtwork(item.backdrop, (D.episodeCardWidth.value * 2).toInt(), (D.episodeCardHeight.value * 2).toInt(), Modifier.fillMaxSize()) + } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize()) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt index 2195ff72..64e848e1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleMenu.kt @@ -43,7 +43,7 @@ internal fun TitleMenu(state: TitleState, shell: ShellState) { 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) { + 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 -> diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 44800e1b..4a79a147 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -93,7 +93,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val focus = remember { FocusRequester() } var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) } val compactHeader = state.data.status == TitleReadStatus.Ready - val headerHeight = if (compactHeader) measuredHeaderHeight else L.row_dhead + 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) { if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index fdf8b69d..d4a956fc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { val item = data.episodes[index] - queue(if (item.isPosterBackdrop) item.poster else item.backdrop, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt()) + queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight) } "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index af484aeb..fad34b31 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -26,7 +26,7 @@ class TitleState( var sheetIndex by mutableStateOf(0) private var mediaInfoFromMenu = false val menuOptions: List by derivedStateOf { buildList { - if (target != null) add(TitleOption("info", "Media info", "more", icon = false)) + 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)) } @@ -72,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. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 459fd3fc..e10e109f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 @@ -452,9 +452,10 @@ object WolfskinTokens { } object Title { val episodeCardWidth = 224.0.dp - val episodeCardHeight = 126.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 diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt index dfefe549..a027f92f 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/WolfskinImagesTest.kt @@ -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), diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index cdcda86e..99f15d01 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -1,9 +1,10 @@ { "title": { - "episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%; 16:9 art"}, - "episodeCardHeight": {"dp": 126, "px": 252, "source": "tv-round2: 16:9 episode art"}, + "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"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 8ca184ac..8967d79f 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -291,9 +291,10 @@ --lift-sheetBlur: 80.0px; --lift-sheetX: -30.0px; --title-episodeCardWidth: 448.0px; - --title-episodeCardHeight: 252.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; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index d7db186e..ea56e6d1 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -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": [ diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 3ef0cd19..bd2e8257 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-token c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -12420580fed6e6977c8762fb0649f0ed22ca6a8df32d65ea69b3965cd851bb65 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -98054296d1de4df2f9f4808d505e512d5906f5f23f7a05e6d846391d93effa5e title-tokens.json -40d763d9023abb56c197201a6a13d3fefacb681713010f849d450c96216e37cb tokens.css -ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json +0d69fccfbfada103268bfc38186205cf5f94daeb345a00a6aec585609e7ddb1e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json +a68f336e8b4373e97a7acaeb02383b4a40dc9f7cc6f6e5413341f14fb2d308eb tokens.css +b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json