From 6d7513bd0e0c55a2308141c24006471a8ca1096b Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:05:07 +0100 Subject: [PATCH 01/10] 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 12c201dc559f77e82d3b02cafbd0b7a2efceee10 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 15:23:45 +0100 Subject: [PATCH 02/10] Fix Home navigation ownership and merge watch lanes --- .../wholphin/data/model/HomeRowConfig.kt | 4 +- .../wholphin/data/model/HomeWatchRows.kt | 15 ++ .../wholphin/services/DatePlayedService.kt | 56 ++---- .../wholphin/services/HomeDataService.kt | 40 +--- .../wholphin/services/HomeSettingsService.kt | 34 ++-- .../wholphin/services/LatestNextUpService.kt | 2 +- .../ui/main/settings/HomeRowConfigAction.kt | 7 - .../ui/main/settings/HomeRowSettings.kt | 23 --- .../ui/main/settings/HomeSettingsAddRow.kt | 6 +- .../ui/main/settings/HomeSettingsPage.kt | 14 -- .../ui/main/settings/HomeSettingsViewModel.kt | 91 +-------- .../wholphin/services/HomeWatchRowsTest.kt | 29 +++ .../wolfskin/tv/HomeLibraryCacheTest.kt | 17 +- .../justinvisser/wolfskin/tv/WatchReadTest.kt | 26 ++- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 2 + .../wolfskin/tv/read/HomeLibraryCache.kt | 2 + .../wolfskin/tv/read/HomeReadFacade.kt | 48 ++++- data/tv-spec/spec.md | 11 ++ docker/README.md | 30 ++- wolfskin-design/home-tokens.json | 6 +- .../wolfskin/design/HomeFixture.kt | 2 + .../wolfskin/design/DiscoverScreen.kt | 10 +- .../justinvisser/wolfskin/design/HomeModel.kt | 2 + .../justinvisser/wolfskin/design/HomeRows.kt | 32 +++- .../wolfskin/design/HomeScreen.kt | 5 +- .../justinvisser/wolfskin/design/HomeShelf.kt | 7 +- .../wolfskin/design/LibraryScreen.kt | 7 +- .../wolfskin/design/PersonPage.kt | 10 +- .../wolfskin/design/ScreenEntry.kt | 20 ++ .../wolfskin/design/ShellState.kt | 3 + .../wolfskin/design/ShellSurface.kt | 18 +- .../justinvisser/wolfskin/design/TitlePage.kt | 11 +- .../wolfskin/design/WolfskinTokens.kt | 2 +- .../wolfskin/design/HomeCaptureTest.kt | 6 +- .../wolfskin/design/HomeGradientTest.kt | 68 +++++++ .../wolfskin/design/HomeKeysTest.kt | 6 +- .../wolfskin/design/HomeNavigationTest.kt | 175 ++++++++++++++++++ .../wolfskin/design/HomeRowsTest.kt | 21 ++- .../design/ScreenEntryNavigationTest.kt | 102 ++++++++++ wolfskin-design/tokens.css | 2 +- wolfskin-design/tokens.sha256 | 6 +- 41 files changed, 671 insertions(+), 307 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 39b82bc8..9a6fba35 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -29,7 +29,7 @@ sealed interface HomeRowConfig { fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig /** - * Continue watching media that the user has started but not finished + * Legacy serialized resume row, migrated to ContinueWatchingCombined when settings are read. */ @Serializable @SerialName("ContinueWatching") @@ -40,7 +40,7 @@ sealed interface HomeRowConfig { } /** - * Next up row for next episodes in a series the user has started + * Legacy serialized next-up row, migrated to ContinueWatchingCombined when settings are read. */ @Serializable @SerialName("NextUp") diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt new file mode 100644 index 00000000..3e2fdb5f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeWatchRows.kt @@ -0,0 +1,15 @@ +package com.github.damontecres.wholphin.data.model + +/** Saved split rows become one watch row at their first position, retaining its presentation choices. */ +fun mergeHomeWatchRows(rows: List): List = buildList { + var watchRowSeen = false + for (row in rows) { + when (row) { + is HomeRowConfig.ContinueWatching, is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> { + if (!watchRowSeen) add(HomeRowConfig.ContinueWatchingCombined(row.viewOptions)) + watchRowSeen = true + } + else -> add(row) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt index ceb84695..b44dc024 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DatePlayedService.kt @@ -8,7 +8,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.collectLatestIn -import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler import com.github.damontecres.wholphin.util.WholphinDispatchers import com.google.common.cache.CacheBuilder import dagger.hilt.android.qualifiers.ActivityContext @@ -21,13 +20,15 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.InvalidStatusException import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.request.GetEpisodesRequest -import timber.log.Timber +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder import java.time.LocalDateTime import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import timber.log.Timber /** * Caches when media was lasted played. This is mostly used by the combined Continue Watching & Next Up home row. @@ -46,50 +47,21 @@ class DatePlayedService .expireAfterWrite(2, TimeUnit.HOURS) .build>() - private fun getLastPlayed( - seriesId: UUID, - item: BaseItem, - ): Deferred { - val request = - GetEpisodesRequest( - seriesId = seriesId, - adjacentTo = item.id, - limit = 1, - ) - return scope.async(WholphinDispatchers.IO) { - val premiereDate = item.data.premiereDate + private fun readLastPlayed(seriesId: UUID): Deferred = + scope.async(WholphinDispatchers.IO) { try { - val result = - GetEpisodesRequestHandler - .execute( - api, - request, - ).content.items - .firstOrNull() - ?.userData - ?.lastPlayedDate ?: LocalDateTime.MIN - if (premiereDate != null && result.isBefore(premiereDate)) { - premiereDate - } else { - result - } + api.itemsApi.getItems(parentId = seriesId, recursive = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1) + .content.items.firstOrNull()?.userData?.lastPlayedDate ?: LocalDateTime.MIN } catch (ex: InvalidStatusException) { - Timber.w( - "Error fetching series=%s, item=%s: %s", - seriesId, - item.id, - ex.localizedMessage, - ) - LocalDateTime.MIN.coerceAtLeast(premiereDate ?: LocalDateTime.MIN) + Timber.w(ex, "Error fetching series=%s", seriesId) + LocalDateTime.MIN } } - } /** - * Get the logical timestamp when the item was last played. - * - * This is calculated using lastest of the actual last played timestamp of the item, - * the previous episode's last played timestamp, and the episode's premiere date + * Get the series' latest actual watch activity, never an upcoming episode's premiere date. */ suspend fun getLastPlayed(item: BaseItem): LocalDateTime? = withContext(WholphinDispatchers.IO) { @@ -97,7 +69,7 @@ class DatePlayedService return@withContext if (seriesId != null) { datePlayedCache .get(SeriesItemId(seriesId, item.id)) { - getLastPlayed(seriesId, item) + readLastPlayed(seriesId) }.await() } else { null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt index fe462b98..c529b5cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeDataService.kt @@ -92,44 +92,8 @@ class HomeDataService usePaging: Boolean = false, ): HomeRowLoadingState = when (row) { - is HomeRowConfig.ContinueWatching -> { - val resume = - latestNextUpService.getResume( - userDto.id, - limit, - true, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.continue_watching), - items = resume, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = resume.size >= limit, - ) - } - - is HomeRowConfig.NextUp -> { - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - row.viewOptions.useSeries, - ) - - Success( - title = ResStringProvider(R.string.next_up), - items = nextUp, - viewOptions = row.viewOptions, - rowType = row, - showViewMore = nextUp.size >= limit, - ) - } - + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> { val resume = latestNextUpService.getResume( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 1c05640e..f0f6c00e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.ui.AspectRatio import com.github.damontecres.wholphin.ui.Cards @@ -159,7 +160,7 @@ class HomeSettingsService null } }.orEmpty() - return HomePageSettings(rows, version) + return HomePageSettings(mergeHomeWatchRows(rows), version) } private suspend fun tryLoad(block: suspend () -> HomePageSettings?): HomePageSettings? = @@ -191,7 +192,7 @@ class HomeSettingsService ) return settings?.let { val resolvedRows = - settings.rows.mapIndexed { index, config -> + mergeHomeWatchRows(settings.rows).mapIndexed { index, config -> resolve(index, config) } HomePageResolvedSettings(userId, resolvedSource, resolvedRows) @@ -261,7 +262,7 @@ class HomeSettingsService settings: HomePageSettings, ) { val resolvedRows = - settings.rows.mapIndexed { index, config -> + mergeHomeWatchRows(settings.rows).mapIndexed { index, config -> resolve(index, config) } currentSettings.update { it.copy(rows = resolvedRows) } @@ -313,7 +314,7 @@ class HomeSettingsService listOf( HomeRowConfigDisplay( id = includedIds.size + 1, - title = ResStringProvider(R.string.combine_continue_next), + title = ResStringProvider(R.string.continue_watching), config = HomeRowConfig.ContinueWatchingCombined(), ), ) @@ -434,7 +435,8 @@ class HomeSettingsService null } }.flatten() - HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, rowConfigs) + val merged = mergeHomeWatchRows(rowConfigs.map { it.config }).mapIndexed { index, config -> resolve(index, config) } + HomePageResolvedSettings(userId, HomePageSettingsSource.WEB_CONFIG, merged) } else { null } @@ -457,19 +459,13 @@ class HomeSettingsService ) } - is HomeRowConfig.ContinueWatching -> { - HomeRowConfigDisplay( - id, - ResStringProvider(R.string.continue_watching), - config, - ) - } - + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.NextUp, is HomeRowConfig.ContinueWatchingCombined -> { HomeRowConfigDisplay( id, - ResStringProvider(R.string.combine_continue_next), - config, + ResStringProvider(R.string.continue_watching), + HomeRowConfig.ContinueWatchingCombined(config.viewOptions), ) } @@ -495,14 +491,6 @@ class HomeSettingsService HomeRowConfigDisplay(id, StringStringProvider(config.name), config) } - is HomeRowConfig.NextUp -> { - HomeRowConfigDisplay( - id, - ResStringProvider(R.string.next_up), - config, - ) - } - is HomeRowConfig.RecentlyAdded -> { val title = getItemName(R.string.recently_added_in, config.parentId) HomeRowConfigDisplay( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index dbaa219e..f58bacaa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -158,7 +158,7 @@ class LatestNextUpService val timestamps = mutableMapOf() nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val result = (resume + nextUp).distinctBy { it.data.seriesId ?: it.id }.sortedByDescending { timestamps[it.id] } val duration = (System.currentTimeMillis() - start).milliseconds Timber.v("buildCombined took %s", duration) return@withContext result diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt deleted file mode 100644 index 2a44c8f4..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowConfigAction.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.github.damontecres.wholphin.ui.main.settings - -sealed interface HomeRowConfigAction { - data object Combine : HomeRowConfigAction - - data object Split : HomeRowConfigAction -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index 48901b0f..dcd46216 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -60,7 +60,6 @@ fun HomeRowSettings( viewOptions: HomeRowViewOptions, onViewOptionsChange: (HomeRowViewOptions) -> Unit, onConfigChange: (HomeRowConfig) -> Unit, - onConfigAction: (HomeRowConfigAction) -> Unit, onApplyApplyAll: () -> Unit, modifier: Modifier = Modifier, defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), @@ -126,7 +125,6 @@ fun HomeRowSettings( addAdditionalConfig( config = config, onConfigChange = onConfigChange, - onConfigAction = onConfigAction, ) } } @@ -135,29 +133,8 @@ fun HomeRowSettings( private fun LazyListScope.addAdditionalConfig( config: HomeRowConfig, onConfigChange: (HomeRowConfig) -> Unit, - onConfigAction: (HomeRowConfigAction) -> Unit, ) { when (config) { - is HomeRowConfig.ContinueWatchingCombined -> { - item { - ClickPreference( - title = stringResource(R.string.split_into_separate_rows), - onClick = { onConfigAction.invoke(HomeRowConfigAction.Split) }, - ) - } - } - - is HomeRowConfig.ContinueWatching, - is HomeRowConfig.NextUp, - -> { - item { - ClickPreference( - title = stringResource(R.string.combine_continue_next), - onClick = { onConfigAction.invoke(HomeRowConfigAction.Combine) }, - ) - } - } - is HomeRowConfig.ByParent -> { item { val sortAndDirection = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index 439b2505..1055455b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -35,8 +35,6 @@ fun HomeSettingsAddRow( ) { itemsIndexed( listOf( - MetaRowType.CONTINUE_WATCHING, - MetaRowType.NEXT_UP, MetaRowType.COMBINED_CONTINUE_WATCHING, ), ) { index, type -> @@ -94,9 +92,7 @@ fun HomeSettingsAddRow( enum class MetaRowType( @param:StringRes val stringId: Int, ) { - CONTINUE_WATCHING(R.string.continue_watching), - NEXT_UP(R.string.next_up), - COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), + COMBINED_CONTINUE_WATCHING(R.string.continue_watching), FAVORITES(R.string.favorites), DISCOVER(R.string.discover), COLLECTION(R.string.collection), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 0bd6b7db..01ef4cb1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -157,8 +157,6 @@ fun HomeSettingsPage( onClick = { backStack.add(ChooseRowType(it)) }, onClickMeta = { when (it) { - MetaRowType.CONTINUE_WATCHING, - MetaRowType.NEXT_UP, MetaRowType.COMBINED_CONTINUE_WATCHING, -> { addRow { viewModel.addRow(it) } @@ -246,18 +244,6 @@ fun HomeSettingsPage( onConfigChange = { viewModel.onConfigChange(row, it) }, - onConfigAction = { - viewModel.onConfigAction(row, it) - when (it) { - HomeRowConfigAction.Combine, - HomeRowConfigAction.Split, - -> { - backStack.removeAt( - backStack.lastIndex, - ) - } - } - }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 1082a23f..4c74fc44 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.HomeDataService @@ -249,26 +250,10 @@ class HomeSettingsViewModel val id = idCounter++ val newRow = when (type) { - MetaRowType.CONTINUE_WATCHING -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.continue_watching), - config = ContinueWatching(), - ) - } - - MetaRowType.NEXT_UP -> { - HomeRowConfigDisplay( - id = id, - title = ResStringProvider(R.string.next_up), - config = NextUp(), - ) - } - MetaRowType.COMBINED_CONTINUE_WATCHING -> { HomeRowConfigDisplay( id = id, - title = ResStringProvider(R.string.combine_continue_next), + title = ResStringProvider(R.string.continue_watching), config = ContinueWatchingCombined(), ) } @@ -813,7 +798,7 @@ class HomeSettingsViewModel if (result != null) { Timber.v("Got remote settings") val newRows = - result.rows.mapIndexed { index, config -> + mergeHomeWatchRows(result.rows).mapIndexed { index, config -> homeSettingsService.resolve(index, config) } idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 @@ -839,76 +824,6 @@ class HomeSettingsViewModel } } - fun onConfigAction( - row: HomeRowConfigDisplay, - action: HomeRowConfigAction, - ) { - viewModelScope.launchDefault { - when (action) { - HomeRowConfigAction.Combine -> { - val index = - state.value.rows - .indexOf(row) - .coerceAtLeast(0) - val rowsToRemove = - state.value.rows - .filter { it.config is ContinueWatching || it.config is NextUp } - - updateState { - it.copy( - loading = LoadingState.Loading, - rows = - it.rows.toMutableList().apply { - set( - index, - HomeRowConfigDisplay( - id = it.rows[index].id, - title = ResStringProvider(R.string.combine_continue_next), - config = ContinueWatchingCombined(row.config.viewOptions), - ), - ) - removeAll(rowsToRemove) - }, - ) - } - fetchRowData() - } - - HomeRowConfigAction.Split -> { - val index = - state.value.rows - .indexOf(row) - .coerceAtLeast(0) - updateState { - it.copy( - loading = LoadingState.Loading, - rows = - it.rows.toMutableList().apply { - set( - index, - HomeRowConfigDisplay( - id = it.rows[index].id, - title = ResStringProvider(R.string.continue_watching), - config = ContinueWatching(row.config.viewOptions), - ), - ) - add( - index + 1, - HomeRowConfigDisplay( - id = idCounter++, - title = ResStringProvider(R.string.next_up), - config = NextUp(row.config.viewOptions), - ), - ) - }, - ) - } - fetchRowData() - } - } - } - } - fun onConfigChange( row: HomeRowConfigDisplay, config: HomeRowConfig, diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt new file mode 100644 index 00000000..7b8c5655 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/HomeWatchRowsTest.kt @@ -0,0 +1,29 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.mergeHomeWatchRows +import org.junit.Assert.* +import org.junit.Test + +/** Legacy saved watch lanes retain the first lane's order and display options after merging. */ +class HomeWatchRowsTest { + @Test fun splitAndAlreadyCombinedRowsBecomeOneWithoutChangingOtherPresets() { + val options = HomeRowViewOptions(heightDp = 148) + val other = HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter()) + for (watchRows in listOf( + listOf(HomeRowConfig.ContinueWatching(options), other, HomeRowConfig.NextUp()), + listOf(HomeRowConfig.NextUp(options), other, HomeRowConfig.ContinueWatching()), + listOf(HomeRowConfig.ContinueWatchingCombined(options), other, HomeRowConfig.NextUp()), + )) { + val result = mergeHomeWatchRows(watchRows) + assertEquals(listOf(HomeRowConfig.ContinueWatchingCombined(options), other), result) + assertEquals(result, mergeHomeWatchRows(result)) + } + } + + @Test fun settingsWithoutWatchRowsStayWithoutWatchRows() { + val rows = listOf(HomeRowConfig.GetItems(name = "Saved shelf", filter = com.github.damontecres.wholphin.data.model.HomeGetItemsFilter())) + assertEquals(rows, mergeHomeWatchRows(rows)) + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt index 49fc269f..fcf22467 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/HomeLibraryCacheTest.kt @@ -17,7 +17,7 @@ import org.justinvisser.wolfskin.tv.read.HomeLibraryCache class HomeLibraryCacheTest { @Test fun savedHomeReopensWithEpisodeMetadataAndFullShelfCount() { val context = ApplicationProvider.getApplicationContext() - val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42) + val episode = HomeTitle("episode-id", "The next episode", "episode", year = 2025, runtimeSeconds = 1800, overview = "Overview", poster = "poster", backdrop = "backdrop", seriesTitle = "A series", season = 2, episode = 3, resumeSeconds = 125.5, addedAt = "2026-11-05", genres = listOf("Drama"), held = true, tmdbId = 42, seriesId = "series-id", lastPlayedAt = 123456L) val film = HomeTitle("film-id", "A film", "movie", year = 2024, runtimeSeconds = 5400, played = true, held = true, tmdbId = 43) val library = HomeLibrary(listOf(episode), listOf(episode.copy(id = "next", resumeSeconds = 0.0)), listOf(film), mapOf("movies" to listOf(film)), mapOf("movies" to 84)) HomeLibraryCache(context).write("server:person", library, 12345L) @@ -26,4 +26,19 @@ class HomeLibraryCacheTest { assertEquals(12345L, saved.second) assertNull(HomeLibraryCache(context).read("server:someone-else")) } + + @Test fun preMergeCacheWithoutWatchIdentityOrTimestampStillReopens() { + val context = ApplicationProvider.getApplicationContext() + val cache = HomeLibraryCache(context) + val title = HomeTitle("legacy", "An episode", "episode", seriesTitle = "Series", resumeSeconds = 24.0, held = true) + cache.write("legacy-scope", HomeLibrary(resume = listOf(title)), 12L) + val store = org.justinvisser.wolfskin.conductarr.FileCacheStore(java.io.File(context.filesDir, "wolfskin-home")) + val entry = store.read("legacy-scope", "home:jellyfin")!! + val body = org.json.JSONObject(entry.body) + body.getJSONArray("resume").getJSONObject(0).apply { remove("seriesId"); remove("lastPlayedAt") } + store.write("legacy-scope", "home:jellyfin", entry.copy(body = body.toString())) + val saved = cache.read("legacy-scope")!! + assertEquals(title, saved.first.resume.single()) + assertEquals(12L, saved.second) + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt index a7901727..fdcaa710 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WatchReadTest.kt @@ -121,7 +121,7 @@ class WatchReadTest { runCurrent() assertFalse(loading.isCompleted) assertTrue(model.snapshot.rows.first().watchReady) - assertEquals(1136.274, model.snapshot.rows.single { it.id == "nu" }.cards.single().resumeSeconds, .0001) + assertEquals(1136.274, model.snapshot.rows.single { it.id == "cw" }.cards.single().resumeSeconds, .0001) releaseLibrary.complete(Unit) loading.join() assertEquals(96.078, model.snapshot.rows.first().cards.first().resumeSeconds, .0001) @@ -132,4 +132,28 @@ class WatchReadTest { WholphinDispatchers.reset() } } + + @Test fun mergedHomeUsesSeriesWatchActivityNotAnUnwatchedNextEpisodeDate() = runTest { + val otherSeries = UUID.fromString("00000000-0000-0000-0000-000000000004") + val checkpoint = decode(resumeWire).let { it.copy(seriesId = seriesId, seriesName = "Series A", + userData = it.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse("2026-11-05T12:00:00"))) } + val hero = checkpoint.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000005"), type = BaseItemKind.MOVIE, seriesId = null, seriesName = null) + val next = decode(nextWire).let { it.copy(seriesId = seriesId, seriesName = "Series A", + userData = it.userData!!.copy(playbackPositionTicks = 0, lastPlayedDate = null)) } + val otherNext = next.copy(id = UUID.fromString("00000000-0000-0000-0000-000000000006"), seriesId = otherSeries, seriesName = "Series B") + coEvery { items.getResumeItems(userId = userId, fields = any(), mediaTypes = any(), limit = 20) } returns successQueryResult(listOf(hero, checkpoint)) + coEvery { shows.getNextUp(userId = userId, fields = any(), limit = 20, enableUserData = true) } returns successQueryResult(listOf(next, otherNext)) + for ((id, date) in listOf(seriesId to "2026-11-04T12:00:00", otherSeries to "2026-11-07T12:00:00")) { + coEvery { library.getItem(userId = userId, itemId = id) } returns successResponse(BaseItemDto(id = id, type = BaseItemKind.SERIES)) + val watched = checkpoint.copy(userData = checkpoint.userData!!.copy(lastPlayedDate = java.time.LocalDateTime.parse(date))) + coEvery { items.getItems(userId = userId, parentId = id, recursive = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true, + sortBy = listOf(org.jellyfin.sdk.model.api.ItemSortBy.DATE_PLAYED), + sortOrder = listOf(org.jellyfin.sdk.model.api.SortOrder.DESCENDING), limit = 1) } returns successQueryResult(listOf(watched)) + } + val rows = buildHomeRows(HomeReadFacade(api, images, mockk()).readWatch(userId), emptyList()) + val watching = rows.single { it.id == "cw" }.cards + assertEquals(listOf(otherNext.id.toString(), checkpoint.id.toString()), watching.map { it.id }) + assertEquals(96.078, watching.last().resumeSeconds, .0001) + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index aed8ec7b..2eb874eb 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -317,6 +317,7 @@ fun WolfskinApp( LaunchedEffect(destination, state, isCurrent) { if (isCurrent && !destination.fullScreen) state.enterDestination(route) } + org.justinvisser.wolfskin.design.ScreenEntry(state, route, isCurrent) { if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { @@ -342,6 +343,7 @@ fun WolfskinApp( } } } + } } }, ) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt index 10422c09..796c0b29 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeLibraryCache.kt @@ -47,6 +47,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co put("seriesTitle", title.seriesTitle); put("season", title.season); put("episode", title.episode) put("resume", title.resumeSeconds); put("played", title.played); put("addedAt", title.addedAt) put("genres", JSONArray(title.genres)); put("tmdb", title.tmdbId) + put("seriesId", title.seriesId); put("lastPlayedAt", title.lastPlayedAt) }) } } @@ -59,6 +60,7 @@ class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Co item.getString("id"), item.getString("title"), item.getString("type"), number("year"), number("runtime"), number("seasons"), item.getString("overview"), string("poster"), string("backdrop"), item.getBoolean("posterOnly"), string("seriesTitle"), number("season"), number("episode"), item.getDouble("resume"), item.getBoolean("played"), item.getString("addedAt"), List(genres.length()) { genres.getString(it) }, held = true, tmdbId = number("tmdb"), + seriesId = string("seriesId"), lastPlayedAt = if (item.isNull("lastPlayedAt")) null else item.getLong("lastPlayedAt"), ) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt index bb86e68a..109a7a0a 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/HomeReadFacade.kt @@ -3,7 +3,12 @@ package org.justinvisser.wolfskin.tv.read import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.services.ImageUrlService import java.util.UUID +import java.time.ZoneOffset import javax.inject.Inject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi @@ -27,18 +32,39 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val suspend fun readWatch(userId: UUID): HomeLibrary { val resumeItems = api.itemsApi.getResumeItems(userId = userId, fields = FIELDS, mediaTypes = listOf(MediaType.VIDEO), limit = 20).content.items val nextItems = api.tvShowsApi.getNextUp(userId = userId, fields = FIELDS, limit = 20, enableUserData = true).content.items - val series = (resumeItems + nextItems).mapNotNull { it.seriesId }.distinct().associateWith { id -> - try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } - catch (exception: Exception) { - if (exception is kotlinx.coroutines.CancellationException) throw exception - null - } + val seriesIds = buildSet { + resumeItems.forEach { it.seriesId?.let(::add) } + nextItems.forEach { it.seriesId?.let(::add) } + } + val series = coroutineScope { + val limiter = Semaphore(WATCH_READ_CONCURRENCY) + seriesIds.associateWith { id -> async { limiter.withPermit { readSeriesWatch(userId, id) } } } + .mapValues { it.value.await() } } val resume = resumeItems.map { title(it, series) } val next = nextItems.map { title(it, series) } return HomeLibrary(resume = resume, nextUp = next) } + private suspend fun readSeriesWatch(userId: UUID, id: UUID): SeriesWatch { + val tmdb = try { api.userLibraryApi.getItem(userId = userId, itemId = id).content.providerIds?.get("Tmdb")?.toIntOrNull() } + catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } + // Next-up episodes are unwatched; date the series by its latest actually watched episode. + val playedAt = try { + api.itemsApi.getItems(userId = userId, parentId = id, recursive = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), isPlayed = true, enableUserData = true, + sortBy = listOf(ItemSortBy.DATE_PLAYED), sortOrder = listOf(SortOrder.DESCENDING), limit = 1) + .content.items.firstOrNull()?.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() + } catch (exception: Exception) { + if (exception is kotlinx.coroutines.CancellationException) throw exception + null + } + return SeriesWatch(tmdb, playedAt) + } + /** Library failures do not discard an already successful watch read. */ suspend fun readLibrary(userId: UUID): HomeLibrary { val libraries = api.userViewsApi.getUserViews(userId = userId).content.items @@ -66,7 +92,7 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val return readPlayback(api, userId, id) } - private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { + private fun title(item: BaseItemDto, series: Map = emptyMap()): HomeTitle { val wrapped = BaseItem(item) return HomeTitle( id = item.id.toString(), title = item.name.orEmpty(), type = when (item.type) { BaseItemKind.EPISODE -> "episode"; BaseItemKind.SERIES -> "tv"; else -> "movie" }, @@ -76,12 +102,18 @@ class HomeReadFacade @Inject constructor(private val api: ApiClient, private val seriesTitle = item.seriesName, season = item.parentIndexNumber, episode = item.indexNumber, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / TICKS_PER_SECOND.toDouble(), played = item.userData?.played == true, addedAt = item.dateCreated?.toString().orEmpty(), genres = item.genres.orEmpty(), held = true, - tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId] else item.providerIds?.get("Tmdb")?.toIntOrNull(), + tmdbId = if (item.type == BaseItemKind.EPISODE) series[item.seriesId]?.tmdbId else item.providerIds?.get("Tmdb")?.toIntOrNull(), + seriesId = item.seriesId?.toString(), + lastPlayedAt = maxOf(item.userData?.lastPlayedDate?.toInstant(ZoneOffset.UTC)?.toEpochMilli() ?: Long.MIN_VALUE, + series[item.seriesId]?.lastPlayedAt ?: Long.MIN_VALUE).takeUnless { it == Long.MIN_VALUE }, ) } + private data class SeriesWatch(val tmdbId: Int?, val lastPlayedAt: Long?) + companion object { private const val TICKS_PER_SECOND = 10_000_000L + private const val WATCH_READ_CONCURRENCY = 3 private val FIELDS = listOf(ItemFields.PROVIDER_IDS, ItemFields.OVERVIEW, ItemFields.DATE_CREATED, ItemFields.GENRES, ItemFields.CHILD_COUNT) } } diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..a67848fd 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -14,6 +14,17 @@ The focused card and its ring remain fully inside the viewport, with at least th Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it. +## Home continuation and navigation — round 2 amendment + +Home has one **Continue watching** lane for in-progress films/episodes and next-up episodes. Use the existing landscape-tile geometry, order by most recent actual watch activity, and show a series once: its resumable episode takes precedence over its next-up episode. Unknown activity dates retain source order after known dates. The non-rotating hero keeps its resume → next-up → newest → pick precedence and is not duplicated in the lane. + +The lane retains the `cw` identity. Legacy local, server, web and plugin Home settings merge saved Resume/Next up rows at the first watch row's position and preserve that row's view options. Existing serialized types remain readable; new settings offer only Continue watching. + +Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh. + +Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band. The title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp; the approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. + + ## Episode artwork and dimensions Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it. diff --git a/docker/README.md b/docker/README.md index 5cd6f6fa..edf99eb4 100644 --- a/docker/README.md +++ b/docker/README.md @@ -330,7 +330,10 @@ attribution and project URL. The check does not depend on incremental merge file ## Wolfskin Home Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero, -Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation. +one merged **Continue watching** lane, newest and per-destination Jellyfin shelves, +and the Discover continuation. The watch lane uses landscape tiles, prefers a +resumable episode over another next-up episode from the same series, and sorts +the chosen items by latest actual watch activity. The hero stays off the lane. The final action opens the real merged Discover screen at its top through the shell's root re-selection transition. Home's See all cards open `WolfskinSeeAllLists`, carrying the complete underlying list rather than the visible shelf window. Title content @@ -339,11 +342,13 @@ remains the later-slice target. All Home components and immutable screen models live in `:wolfskin-design`. The shell's remembered view owns vertical position, each row's selected card and actual horizontal `LazyListState`, and the bounded six-hour ratings session cache. -Composing destinations bind `rememberedView(route)` before navigation effects run, so a -new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the -remembered track before the page with `scrollToItem`, preserving exact indices and -pixel offsets rather than animating back. Android focus stays with the shell; -cards use its logical focus, without requests against uncomposed lazy items. +Every `NavEntry` installs `ScreenEntry` with its own `rememberedView(route)` and +active state before navigation effects run. Home, Library, Discover, title and +person surfaces consume that entry's viewport; an outgoing page cannot borrow +Home's scroller during Back. Only the active entry registers logical key handling +or acquires Android focus. Re-entry restores the remembered horizontal track +with `scrollToItem`, preserving exact indices and pixel offsets. Cards use the +shell's logical focus without requests against uncomposed lazy items. Back preserves the viewport; returning to Home does not re-run focus-follow over a manually scrolled saved view. The next arrow re-enters visible content when scrolling moved focus off-screen. @@ -356,10 +361,23 @@ The initial skeleton parks focus on the nav. When the first content arrives, an untouched fresh entry focuses hero button zero; intervening nav input prevents that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` release/repeat guard and Back ladder. +Landscape title scrims include the reserved state-line area inside their gradient +rather than leaving an exposed image strip below it. Their top padding is the +shared Home token (18 dp); opacity remains the approved 92%. + +The legacy opt-out Home and its presets also use one combined watch row. Saved +local/server/web/plugin split rows migrate at their first position, preserving +that row's display options and all unrelated rows. Old serialized row names stay +decodable; settings no longer offer separate lanes or a Split action. `HomeReadFacade` confines Home SDK reads to the selected user. Resume and Next up publish independently of slow or failed library shelves; saved metadata still paints immediately, but a saved hero offers Details until watch data revalidates. +Series chronology reads the latest actually watched episode, not the next-up +episode's unwatched user data or premiere date. Enrichment is bounded to three +concurrent series reads; absent history leaves the date unknown rather than +inventing a timestamp. The disk projection preserves series IDs and watch dates, +and still accepts older cache entries without either field. Home and title Play actions resolve the current Jellyfin item and its current checkpoint at the click, preserving millisecond positions. Series actions use live NextUp with resumable episodes enabled; an explicitly opened episode stays diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 7cceeaa7..8a903933 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -50,9 +50,9 @@ }, "cardOverlayTop": { "$type": "dimension", - "px": 54, - "dp": 27.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + "px": 36, + "dp": 18.0, + "source": "TV round 2: lower card bottom gradient; cover the state-line reserve" }, "cardOverlayBottom": { "$type": "dimension", diff --git a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt index c046c290..f56c3459 100644 --- a/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt +++ b/wolfskin-design/src/debug/java/org/justinvisser/wolfskin/design/HomeFixture.kt @@ -99,6 +99,8 @@ class HomeFixture(context: Context) { resumeSeconds = item.optDouble("resumeSeconds", 0.0).takeIf { it.isFinite() } ?: 0.0, played = item.optBoolean("played"), addedAt = item.optString("addedAt", ""), genres = genres(item).toList(), ratings = ratings(item), held = item.optString("availability") == "in_library", tmdbId = item.intOrNull("tmdbId"), + seriesId = item.stringOrNull("seriesId"), + lastPlayedAt = item.stringOrNull("lastPlayedAt")?.let { java.time.Instant.parse(it).toEpochMilli() }, ) private fun ratings(item: JSONObject): List { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index e3137e45..bff191bd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -44,12 +44,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L @Composable fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { val shell = state.shell + val view = screenView(shell) + val entryActive = LocalScreenActive.current val focus = remember(state) { object : ShellContentFocus by state {} } - DisposableEffect(focus) { - shell.contentFocus = focus + DisposableEffect(focus, entryActive) { + if (entryActive) shell.contentFocus = focus onDispose { if (shell.contentFocus === focus) { shell.contentFocus = null; shell.contentOverlayVisible = false } } } - LaunchedEffect(shell.route, state.snapshot.savedAt) { shell.view.savedAt = state.snapshot.savedAt } + LaunchedEffect(view, state.snapshot.savedAt) { view.savedAt = state.snapshot.savedAt } LaunchedEffect(state.toast) { if (state.toast != null) { kotlinx.coroutines.delay(WolfskinTokens.Motion.toast.toLong()); state.toast = null } } val focusedTitle = if (state.collection != null) null else state.focusedLane?.titles?.getOrNull(state.column) val ratingsReader = LocalScreenRatings.current @@ -78,7 +80,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) { return } val density = LocalDensity.current - val list = shell.view.listState + val list = view.listState val note = state.snapshot.status in setOf(DiscoverStatus.NoSeeds, DiscoverStatus.PicksUnavailable) val heights = remember(state.hasHero, state.lanes, note, state.hasCatalogue) { buildList { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt index 8bf7d6e2..45ddf9d4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt @@ -28,6 +28,8 @@ data class HomeTitle( val held: Boolean = false, val otherOwner: String? = null, val tmdbId: Int? = null, + val seriesId: String? = null, + val lastPlayedAt: Long? = null, ) { val isEpisode: Boolean get() = type == "episode" val displayTitle: String get() = seriesTitle ?: title diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt index 4c7f8beb..b92c4386 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeRows.kt @@ -18,12 +18,13 @@ fun buildHomeRows( ): List = buildList { if (loading) { add(HomeRow("hero-sk", HomeRowKind.Hero, skeleton = true)) - listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster, HomeCardKind.Poster).forEachIndexed { index, kind -> + listOf(HomeCardKind.Tile, HomeCardKind.Tile, HomeCardKind.Poster).forEachIndexed { index, kind -> add(HomeRow("sk$index", HomeRowKind.Shelf, cardKind = kind, skeleton = true)) } return@buildList } val choice = when { + // Hero precedence remains independent of the merged shelf's recency order. library.resume.isNotEmpty() -> "resume" to library.resume.first() library.nextUp.isNotEmpty() -> "next" to library.nextUp.first() library.newest.isNotEmpty() -> "new" to library.newest.first() @@ -34,10 +35,10 @@ fun buildHomeRows( if (!libraryAvailable) { add(HomeRow("jf-down", HomeRowKind.State, "Your library can’t be reached", text = "Jellyfin isn’t answering, so nothing can play right now.")) } else { - listOf(Triple("cw", "Continue watching", library.resume), Triple("nu", "Next up", library.nextUp), Triple("new", "New in your library", library.newest)).forEach { (id, title, source) -> - val cards = source.filter { it.id != hero?.id } - if (cards.isNotEmpty()) add(HomeRow(id, HomeRowKind.Shelf, title, cards, if (id == "cw") HomeCardKind.Wide else HomeCardKind.Tile)) - } + val watching = continueWatching(library).filter { it.watchIdentity != hero?.watchIdentity } + if (watching.isNotEmpty()) add(HomeRow("cw", HomeRowKind.Shelf, "Continue watching", watching, HomeCardKind.Tile)) + val newest = library.newest.filter { it.id != hero?.id } + if (newest.isNotEmpty()) add(HomeRow("new", HomeRowKind.Shelf, "New in your library", newest, HomeCardKind.Tile)) listOf("movies", "shows", "anime").forEach { destination -> val titles = library.destinations[destination].orEmpty().sortedWith(compareBy { it.played }.thenByDescending { it.addedAt }) if (titles.isNotEmpty()) add(HomeRow("lib-$destination", HomeRowKind.Shelf, if (destination == "anime") "Anime" else "Your $destination", titles.take(WolfskinTokens.Behaviour.rowLimit.toInt()), total = library.destinationTotals[destination] ?: titles.size, target = destination)) @@ -47,6 +48,27 @@ fun buildHomeRows( addAll(continuation) } +/** Prefer a resumable episode per series, then sort the chosen items by actual watch activity. */ +internal fun continueWatching(library: HomeLibrary): List { + val chosen = linkedMapOf() + fun choose(source: List) { + for (title in source) { + val key = title.watchIdentity + val previous = chosen[key] + if (previous == null || (previous.resumeSeconds <= 0 && title.resumeSeconds > 0) || + ((previous.resumeSeconds > 0) == (title.resumeSeconds > 0) && (title.lastPlayedAt ?: Long.MIN_VALUE) > (previous.lastPlayedAt ?: Long.MIN_VALUE))) { + chosen[key] = title + } + } + } + choose(library.resume) + choose(library.nextUp) + return chosen.values.sortedByDescending { it.lastPlayedAt } +} + +private val HomeTitle.watchIdentity: String + get() = if (isEpisode) seriesId?.let { "series:$it" } ?: seriesTitle?.let { "series-name:$it" } ?: id else id + /** Authentication failure is distinct from an unreachable signed-in catalogue. */ fun homeDiscoverState(status: HomeAvailability): HomeRow = if (status == HomeAvailability.SignedOut) { HomeRow("signin", HomeRowKind.State, "Discover needs sign-in", text = "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual.", calm = true) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 11725f4a..f6cc248f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -29,11 +29,12 @@ fun HomeScreen( onAction: (HomeAction) -> Unit, ) { val view = shell.rememberedView("home") + val active = LocalScreenActive.current LaunchedEffect(view, snapshot.savedAt) { view.savedAt = snapshot.savedAt } val action by rememberUpdatedState(onAction) val focus = remember(shell, snapshot.rows, view) { HomeFocus(shell, snapshot.rows) { action(it) } } - DisposableEffect(shell, focus) { - shell.contentFocus = focus + DisposableEffect(shell, focus, active) { + if (active) shell.contentFocus = focus onDispose { if (shell.contentFocus === focus) shell.contentFocus = null } } LaunchedEffect(snapshot.rows, view) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index d1827758..4f29d5f1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -86,9 +86,10 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In private fun TileOverlay(title: HomeTitle) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { val metadata = WolfskinTokens.CardMetadata - Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth() - .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))) - .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { + Column(Modifier.fillMaxWidth() + .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = WolfskinTokens.Title.cardOverlayOpacity)))) + .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, + bottom = H.cardOverlayBottom + metadata.bottom + metadata.separator + metadata.resumeLine)) { Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 93c55c85..322437be 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -39,10 +39,11 @@ fun LibraryScreen( cachedRatings: Set = emptySet(), ratingsReachable: Boolean = true, offlineArt: ((String) -> ImageBitmap?)? = null, - view: RememberedView = shell.view, + view: RememberedView = screenView(shell), registerFocus: Boolean = true, ) { val density = LocalDensity.current + val entryActive = LocalScreenActive.current val context = LocalContext.current val active = !shell.navFocused && !shell.pickerVisible val items = grid.items @@ -50,8 +51,8 @@ fun LibraryScreen( val count = if (grid.catalogue) items.size else grid.source.size var plateKey by remember { mutableStateOf(null) } val ratingsReader = LocalScreenRatings.current - DisposableEffect(shell, grid, registerFocus) { - if (registerFocus) shell.contentFocus = grid + DisposableEffect(shell, grid, registerFocus, entryActive) { + if (registerFocus && entryActive) shell.contentFocus = grid onDispose { if (registerFocus && shell.contentFocus === grid) shell.contentFocus = null } } LaunchedEffect(grid) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index 7da2fba8..3f5be1e8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -86,10 +86,11 @@ class PersonState(initialPerson: TitlePerson, initialCredits: List, p /** Portrait, biography and the full continuous grid use the shell's remembered frame. */ @Composable fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { - val view = shell.view + val view = screenView(shell) + val entryActive = LocalScreenActive.current LaunchedEffect(view, state.savedAt) { view.savedAt = state.savedAt } val owner = androidx.compose.runtime.remember(shell) { { shell.navFocused = false } } - LaunchedEffect(state) { shell.navFocused = false } + LaunchedEffect(state, entryActive) { if (entryActive) shell.navFocused = false } Box(modifier.fillMaxSize().background(C.bg)) { androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides owner) { ShellSurface(shell, handlesContentKeys = false) { PersonPage(state, shell, it) } @@ -101,7 +102,8 @@ fun PersonSurface(state: PersonState, shell: ShellState, modifier: Modifier = Mo @Composable private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier = Modifier) { val density = LocalDensity.current - val list = shell.view.listState + val list = screenView(shell).listState + val entryActive = LocalScreenActive.current var wheelPending by remember { mutableStateOf(false) } val columns = WolfskinTokens.Behaviour.gridColumns.toInt() LaunchedEffect(state.index / columns, shell.navFocused) { @@ -127,7 +129,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier if (key != ShellKey.Ok) return@onPreviewKeyEvent true } key?.let { state.key(it, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) } ?: false - }.focusable()) { + }.focusable(enabled = entryActive)) { LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { item(key = "person-header", contentType = "header") { Box(Modifier.height(L.row_phead)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt new file mode 100644 index 00000000..2947bdd3 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt @@ -0,0 +1,20 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember + +internal val LocalScreenView = compositionLocalOf { null } +internal val LocalScreenActive = compositionLocalOf { true } + +/** Overlapping navigation entries retain their own viewport; only the current entry owns input. */ +@Composable +fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { + remember(shell, shell.person.key, route) { shell.prepareDestinationView(route) } + CompositionLocalProvider(LocalScreenView provides shell.rememberedView(route), LocalScreenActive provides active, content = content) +} + +/** Direct surface previews use the current view when no navigation entry is installed. */ +@Composable +internal fun screenView(shell: ShellState): RememberedView = LocalScreenView.current ?: shell.view diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index c770a3ab..ecae94c8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -116,6 +116,9 @@ class ShellState( fun rememberedView(destination: String): RememberedView = views[person.key to destination] ?: RememberedView() + /** Register an entry before composition; rememberedView also serves read-only snapshot observers. */ + internal fun prepareDestinationView(destination: String) { retainView(destination) } + /** External intents and upstream pops remain authoritative over the screen. */ fun enterDestination(destination: String) { retainView(destination) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 603942b2..f7dbfb1d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -61,6 +61,8 @@ fun ShellSurface( ) { val focus = remember { FocusRequester() } val contentFocus = remember { FocusRequester() } + val active = LocalScreenActive.current + val view = screenView(state) val focusManager = LocalFocusManager.current LaunchedEffect(state.toast) { val message = state.toast @@ -69,30 +71,32 @@ fun ShellSurface( if (state.toast == message) state.toast = null } } - LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) { - if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() - else contentFocus.requestFocus() + LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys, active) { + if (active) { + if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus() + else contentFocus.requestFocus() + } } Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell") .semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" } .focusRequester(focus) .onPreviewKeyEvent { - if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false + if (active && (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys)) state.handleKey(it) else false } .onKeyEvent { - if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { + if (active && it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) { if (!focusManager.moveFocus(FocusDirection.Up)) { state.navFocused = true state.navIndex = state.currentTab } true } else false - }.focusable()) { + }.focusable(enabled = active)) { if (state.pickerVisible) { WhosWatching(state) } else { if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) - NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) + NavBar(state, view.listState.firstVisibleItemIndex > 0 || view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) if (state.requestsVisible) ComingInSheet(state) overlay() } 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..6a604daf 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 @@ -51,7 +51,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** The shared shell remains authoritative; title sheets overlay it and trap all input. */ @Composable fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { - val view = shell.view + val view = screenView(shell) LaunchedEffect(view, state.data.savedAt) { view.savedAt = state.data.savedAt } Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> // No content target exists during an initial load; nav Down must not enter a skeleton. @@ -87,15 +87,16 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism @Composable fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { val density = LocalDensity.current - val list = shell.view.listState + val list = screenView(shell).listState + val entryActive = LocalScreenActive.current 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 - LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { - if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() + LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible, entryActive) { + if (entryActive && state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() } val displayedRows = remember(state.data) { buildList { @@ -143,7 +144,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie if (key != ShellKey.Ok) return@onPreviewKeyEvent true } key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) - }.focusable()) { + }.focusable(enabled = entryActive)) { // This remains visible behind the scrolled rows; it is not an off-screen header image. TitleBackdrop(state.data.item, Modifier.fillMaxSize()) Box(Modifier.fillMaxSize().drawBehind { drawRect(C.bg.copy(alpha = dim)) }) 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..842cd98d 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 @@ -586,7 +586,7 @@ object WolfskinTokens { val factsGap = 11.0.dp val overviewBottom = 15.0.dp val cardPadding = 11.0.dp - val cardOverlayTop = 27.0.dp + val cardOverlayTop = 18.0.dp val cardOverlayBottom = 9.0.dp val sectionTop = 20.0.dp val sectionAvatar = 28.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 323b52ff..18f75917 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -110,12 +110,12 @@ class HomeCaptureTest { @Test fun st019_picksUnavailable() = capture("st-019", mode = "picks-unavailable", row = "trending") @Test fun st020_missingArt() = capture("st-020", person = "shared", mode = "missing", row = "lib-movies") @Test fun st096_widestTilePlate() = capture("st-096", person = "mindel", row = "new", column = 4) - @Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "nu") + @Test fun st100_episodePlate() = capture("st-100", person = "mindel", row = "cw") @Test fun st099_posterPlate() = capture("st-099", row = "lib-movies", column = 1) - @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) + @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "cw", column = 2) @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st103_heroRatings() = capture("st-103") - @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") + @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "cw") @Test fun focusHeroDetails() = capture("focus-hero-details", column = 1) @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt new file mode 100644 index 00000000..241da584 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeGradientTest.kt @@ -0,0 +1,68 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Bright artwork cannot reappear between a landscape title scrim and the card's solid state edge. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeGradientTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun focusedAndUnfocusedTileAndWideScrimsReachTheBottomWithoutDarkeningTheUpperImage() { + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(android.graphics.Color.WHITE) } + val art = mapOf("solid" to bitmap.asImageBitmap()) + val title = HomeTitle("episode", "Episode", "episode", backdrop = "solid", seriesTitle = "Series") + var row by mutableStateOf(HomeRow("gradient", HomeRowKind.Shelf, cards = listOf(title), cardKind = HomeCardKind.Tile)) + var focused by mutableStateOf(false) + val memory = HomeViewMemory() + rule.setContent { WolfskinTheme { CompositionLocalProvider(LocalTitleBitmaps provides art) { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) { + HomeShelf(row, memory, focused, 0, emptyList(), {}, {}) + } + } } } + val directory = kotlin.io.path.createTempDirectory("wolfskin-gradient-").toFile() + try { + for (kind in listOf(HomeCardKind.Tile, HomeCardKind.Wide)) for (selected in listOf(false, true)) { + rule.runOnIdle { row = row.copy(cardKind = kind); focused = selected } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + val bounds = rule.onNodeWithTag("home-card:gradient:0").fetchSemanticsNode().boundsInRoot + val file = File(directory, "$kind-$selected.png") + rule.onRoot().captureRoboImage(file.absolutePath) + val rendered = BitmapFactory.decodeFile(file.absolutePath) + try { + val scale = bounds.height / row.cardHeight.value + val x = (bounds.right - WolfskinTokens.Home.cardPadding.value * scale * 2).toInt() + val bottom = (bounds.bottom - WolfskinTokens.CardMetadata.bottom.value * scale - scale).toInt() + val upper = (bounds.top + bounds.height * .4f).toInt() + assertTrue("$kind/$selected image leaks below the scrim", android.graphics.Color.red(rendered.getPixel(x, bottom)) < 80) + assertTrue("$kind/$selected scrim starts too high", android.graphics.Color.red(rendered.getPixel(x, upper)) > 245) + } finally { rendered.recycle() } + } + } finally { directory.deleteRecursively() } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index a23faa73..50f40ac0 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -98,10 +98,10 @@ class HomeKeysTest { var action: HomeAction? = null rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action = it } } } } press("DOWN") - rule.onNodeWithTag("home-card:nu:2").performMouseInput { enter(center) } + rule.onNodeWithTag("home-card:cw:2").performMouseInput { enter(center) } rule.waitForIdle() - assertEquals("nu", snapshot.rows[shell.view.row].id) - assertEquals(2, shell.view.cards["nu"]) + assertEquals("cw", snapshot.rows[shell.view.row].id) + assertEquals(2, shell.view.cards["cw"]) assertNull(action) } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt new file mode 100644 index 00000000..f3ed1662 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeNavigationTest.kt @@ -0,0 +1,175 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import androidx.compose.ui.MotionDurationScale +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File + +/** Directional navigation follows stable rows through page returns and asynchronous replacement. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class HomeNavigationTest { + private val motionScale = object : MotionDurationScale { override val scaleFactor = 1f } + @get:Rule val rule = createAndroidComposeRule(effectContext = motionScale) + private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + private val cards = listOf(HomeTitle("episode", "Episode", "episode", seriesTitle = "Series", season = 1, episode = 2)) + private val rows = listOf( + HomeRow("hero", HomeRowKind.Hero, cards = cards), + HomeRow("cw", HomeRowKind.Shelf, "Continue watching", cards, HomeCardKind.Wide), + HomeRow("nu", HomeRowKind.Shelf, "Next up", cards, HomeCardKind.Tile), + HomeRow("new", HomeRowKind.Shelf, "New in your library", cards, HomeCardKind.Tile), + HomeRow("pending", HomeRowKind.Shelf, skeleton = true), + HomeRow("section", HomeRowKind.Section), + ) + (0..5).map { HomeRow("row-$it", HomeRowKind.Shelf, "Shelf $it", cards) } + private var snapshot by mutableStateOf(HomeSnapshot(rows)) + private var title by mutableStateOf(null) + private var outgoing by mutableStateOf(null) + private var retainOutgoing = false + + @Test fun downScrollAfterBackIsMonotonicAtSixteenMillisecondFrames() { + retainOutgoing = true + mount() + press(Key.DirectionDown) + press(Key.DirectionCenter) + press(Key.Back) + rule.runOnIdle { outgoing = null } + rule.waitForIdle() + rule.mainClock.autoAdvance = false + rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) } + val directory = File(System.getProperty("wolfskin.captureDirectory"), "home-scroll-frames").apply { mkdirs() } + val series = mutableListOf("milliseconds,index,offset") + var previousIndex = shell.view.listState.firstVisibleItemIndex + var previousOffset = shell.view.listState.firstVisibleItemScrollOffset + val start = previousIndex to previousOffset + repeat((WolfskinTokens.Motion.scroll + 64) / 16) { frame -> + rule.mainClock.advanceTimeBy(16) + rule.waitForIdle() + val list = shell.view.listState + val index = list.firstVisibleItemIndex + val offset = list.firstVisibleItemScrollOffset + assertTrue("Scroll must not snap backward at frame $frame", index > previousIndex || index == previousIndex && offset >= previousOffset) + series += "${(frame + 1) * 16},$index,$offset" + rule.onRoot().captureRoboImage(File(directory, "frame-${frame.toString().padStart(2, '0')}.png").absolutePath) + previousIndex = index + previousOffset = offset + } + File(directory, "position.csv").writeText(series.joinToString("\n")) + assertNotEquals("Down must actually scroll after Back", start, previousIndex to previousOffset) + assertEquals(2, shell.view.row) + rule.mainClock.autoAdvance = true + walkDown(start = 2) + } + + @Test fun outgoingEpisodeDuringBackCannotTakeOverTheHomeScroller() { + retainOutgoing = true + mount() + press(Key.DirectionDown) + press(Key.DirectionDown) + press(Key.DirectionCenter) + rule.onNodeWithTag("title-page").assertIsDisplayed() + press(Key.Back) + rule.runOnIdle { outgoing = null } + rule.waitForIdle() + assertEquals(2, shell.view.row) + walkDown(start = 2) + } + + @Test fun downAdvancesEveryRowAndScrollsAfterEpisodeBack() { + mount() + walkDown() + repeat(rows.count { it.focusable } - 1) { press(Key.DirectionUp) } + press(Key.DirectionDown) + val saved = shell.view + press(Key.DirectionCenter) + rule.onNodeWithTag("title-page").assertIsDisplayed() + press(Key.Back) + assertSame(saved, shell.view) + assertEquals(1, shell.view.row) + walkDown(start = 1) + } + + @Test fun refreshReorderInsertionRemovalAndSkeletonReadyKeepDownProgress() { + mount() + press(Key.DirectionDown) + press(Key.DirectionDown) + assertEquals("nu", snapshot.rows[shell.view.row].id) + rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(rows[0], rows[3], rows[1], rows[2]) + rows.drop(4)) } + rule.waitForIdle() + assertEquals("nu", snapshot.rows[shell.view.row].id) + rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.map { if (it.id == "pending") it.copy(cards = cards, skeleton = false) else it }) } + press(Key.DirectionDown) + assertEquals("pending", snapshot.rows[shell.view.row].id) + rule.runOnIdle { snapshot = snapshot.copy(rows = listOf(HomeRow("insert", HomeRowKind.Shelf, cards = cards)) + snapshot.rows) } + rule.waitForIdle() + assertEquals("pending", snapshot.rows[shell.view.row].id) + rule.runOnIdle { snapshot = snapshot.copy(rows = snapshot.rows.filter { it.id != "pending" }) } + rule.waitForIdle() + assertFalse(shell.navFocused) + walkDown(start = shell.view.row) + } + + @Test fun loadingToReadyThenRefreshDoesNotTrapFocusOnPendingRows() { + snapshot = HomeSnapshot(buildHomeRows(HomeLibrary(), emptyList(), loading = true)) + mount() + assertTrue(shell.navFocused) + rule.runOnIdle { snapshot = HomeSnapshot(rows) } + rule.waitForIdle() + assertFalse(shell.navFocused) + walkDown() + } + + private fun mount() { + rule.setContent { WolfskinTheme { + val page = title + if (page != null) ScreenEntry(shell, "title/episode", true) { TitleSurface(page, shell) } + else ScreenEntry(shell, "home", true) { ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action -> + if (action is HomeAction.Details) { + shell.enterDestination("title/episode") + title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "episode", held = true)), shell.person) { effect -> + if (effect == TitleAction.Pop) { + if (retainOutgoing) outgoing = title + shell.enterDestination("home") + title = null + } + } + } + } } } + outgoing?.let { ScreenEntry(shell, "title/episode", false) { TitleSurface(it, shell) } } + } } + rule.waitForIdle() + } + + private fun walkDown(start: Int = 0) { + val indices = snapshot.rows.indices.filter { it >= start && snapshot.rows[it].focusable } + for (index in indices) { + if (index != start) press(Key.DirectionDown) + assertEquals("Down must advance to ${snapshot.rows[index].id}", index, shell.view.row) + if (snapshot.rows[index].kind == HomeRowKind.Shelf) { + val bounds = rule.onNodeWithTag("home-card:${snapshot.rows[index].id}:0").fetchSemanticsNode().boundsInRoot + assertTrue("Focused card must clear navigation: $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * rule.density.density) + assertTrue("Focused card must clear screen bottom: $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * rule.density.density) + } + } + press(Key.DirectionDown) + assertEquals(indices.last(), shell.view.row) + } + + private fun press(key: Key) { + rule.onRoot().performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt index 9fa7e9a9..280e4cae 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowsTest.kt @@ -44,7 +44,26 @@ class HomeRowsTest { @Test fun firstReadSkeletonHasNoFocusableContent() { val rows = buildHomeRows(HomeLibrary(), continuation, loading = true) - assertEquals(listOf(HomeRowKind.Hero, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf, HomeRowKind.Shelf), rows.map { it.kind }) assertTrue(rows.all { !it.focusable }) } + + @Test fun mergedWatchRowPrefersResumePerSeriesAndInterleavesByWatchRecency() { + val inProgress = next.copy(id = "resume-a", seriesId = "a", resumeSeconds = 40.0, lastPlayedAt = 30) + val olderResume = inProgress.copy(id = "older-a", lastPlayedAt = 10) + val nextSameSeries = next.copy(id = "next-a", seriesId = "a", lastPlayedAt = 50) + val nextOtherSeries = next.copy(id = "next-b", seriesId = "b", lastPlayedAt = 40) + val film = resume.copy(id = "film", resumeSeconds = 20.0, lastPlayedAt = 20) + val unknown = next.copy(id = "unknown", seriesId = "c") + val library = HomeLibrary(listOf(inProgress, olderResume, film), listOf(nextSameSeries, nextOtherSeries, nextOtherSeries, unknown)) + assertEquals(listOf("next-b", "resume-a", "film", "unknown"), continueWatching(library).map { it.id }) + val rows = buildHomeRows(library, continuation) + assertFalse(rows.any { it.id == "nu" }) + assertEquals(listOf("next-b", "film", "unknown"), rows.single { it.id == "cw" }.cards.map { it.id }) + } + + @Test fun oldCachedEpisodesWithoutSeriesIdsDeduplicateBySeriesName() { + val checkpoint = next.copy(id = "resume", seriesTitle = "Same series", resumeSeconds = 10.0) + val following = next.copy(id = "next", seriesTitle = "Same series") + assertEquals(listOf(checkpoint), continueWatching(HomeLibrary(listOf(checkpoint), listOf(following)))) + } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt new file mode 100644 index 00000000..7504b73f --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ScreenEntryNavigationTest.kt @@ -0,0 +1,102 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** A retained outgoing title cannot steal another screen's viewport or Android input on Back. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class ScreenEntryNavigationTest { + @get:Rule val rule = createAndroidComposeRule() + private val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false } + private val credits = (0..35).map { TitleItem("credit-$it", "Credit $it", "movie") } + private val grid = LibraryGridState("Library", credits.map { LibraryTitle(it.id, it.name, "movie") }).apply { toolbar = false } + private val person = PersonState(TitlePerson("actor", "Actor"), credits, {}) + private val title = TitleState(TitlePageData(TitleItem("parent", "Parent", "movie"), cast = listOf(TitlePerson("actor", "Actor")), related = credits.take(10)), shell.person) {} + private val fixture = discoverTestFixture() + private val snapshot = fixture.snapshot("justin") + private val discover = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, "justin"), CoroutineScope(Dispatchers.Main.immediate), {}, {}, fixture::service) + private var childVisible by mutableStateOf(false) + private var outgoingVisible by mutableStateOf(false) + private lateinit var child: TitleState + + @Test fun libraryBackRestoresCardAndDownReachesFinalGridRow() = verify("movies") + @Test fun discoverBackRestoresShelfAndDownReachesFinalShelf() = verify("discover") + @Test fun personBackRestoresCreditAndDownReachesFinalGridRow() = verify("person/actor") + @Test fun titleBackRestoresRowAndDownReachesRelatedShelf() = verify("title/parent") + + private fun verify(route: String) { + shell.enterDestination(route) + if (route == "discover") discover.focus(discover.firstShelf, 0) + child = TitleState(TitlePageData(TitleItem("child", "Child episode", "episode", held = true)), shell.person) { action -> + if (action == TitleAction.Pop) { + shell.enterDestination(route) + childVisible = false + outgoingVisible = true + } + } + rule.setContent { WolfskinTheme { + if (!childVisible) ScreenEntry(shell, route, true) { host(route) } + if (childVisible || outgoingVisible) ScreenEntry(shell, "title/child", childVisible) { TitleSurface(child, shell) } + } } + rule.waitForIdle() + val view = shell.view + val before = position(route) + rule.runOnIdle { shell.enterDestination("title/child"); childVisible = true } + press(Key.Back) + rule.runOnIdle { outgoingVisible = false } + rule.waitForIdle() + assertSame(view, shell.view) + assertEquals(before, position(route)) + repeat(maxOf(credits.size, discover.rowCount)) { + val previous = position(route) + press(Key.DirectionDown) + assertTrue("$route Down cannot move backward: $previous -> ${position(route)}", position(route) >= previous) + } + val expected = when { + route == "movies" -> (grid.items.lastIndex / grid.columns) * grid.columns + route == "discover" -> discover.rowCount - 1 + route.startsWith("person/") -> (credits.lastIndex / WolfskinTokens.Behaviour.gridColumns.toInt()) * WolfskinTokens.Behaviour.gridColumns.toInt() + else -> title.rows.lastIndex + } + assertEquals("$route must reach its last row after Back", expected, position(route)) + assertTrue("$route must scroll its last row into composition", view.listState.firstVisibleItemIndex > 0) + } + + @Composable private fun host(route: String) { + when { + route == "movies" -> ShellSurface(shell) { LibraryScreen(shell, grid, it) } + route == "discover" -> ShellSurface(shell) { DiscoverScreen(discover, it) } + route.startsWith("person/") -> PersonSurface(person, shell) + else -> TitleSurface(title, shell) + } + } + + private fun position(route: String): Int = when { + route == "movies" -> grid.index + route == "discover" -> discover.focusedRow + route.startsWith("person/") -> person.index + else -> title.rows.indexOf(title.row) + } + + private fun press(key: Key) { + rule.onRoot().performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + } +} diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index cb81f2a5..50c24868 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -411,7 +411,7 @@ --home-factsGap: 22.0px; --home-overviewBottom: 30.0px; --home-cardPadding: 22.0px; - --home-cardOverlayTop: 54.0px; + --home-cardOverlayTop: 36.0px; --home-cardOverlayBottom: 18.0px; --home-sectionTop: 40.0px; --home-sectionAvatar: 56.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index a45b7f17..0dbf3324 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,8 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py -eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json +72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json 192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +695e086fd771ee463411a28e8f241637c3c86d13e9474995abaea27a1feff1b7 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json -d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css +71905adcd89991f8ce41859cce7714fcf382a19de25538e5272a84e88f070b07 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 03/10] 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 From e6ddf1d7e55d6c4538f646cf2841028bea0be343 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 16:41:07 +0100 Subject: [PATCH 04/10] Show episode ratings and animate title readiness without empty space --- .../wolfskin/tv/TitleRound2Test.kt | 24 ++++- data/tv-spec/spec.md | 2 +- docker/README.md | 13 ++- .../wolfskin/design/TitleHeader.kt | 13 ++- .../justinvisser/wolfskin/design/TitlePage.kt | 11 ++ .../wolfskin/design/TitleState.kt | 2 - .../wolfskin/design/MotionSurfaceTest.kt | 17 ++- .../design/TitleReadinessMotionTest.kt | 100 ++++++++++++++++++ 8 files changed, 159 insertions(+), 23 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt 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 ff2edf66..91c9387e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleRound2Test.kt @@ -101,22 +101,36 @@ class TitleRound2Test { 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.onNodeWithText("Ep", useUnmergedTree = true).assertIsDisplayed() + rule.onAllNodesWithText("7.4", useUnmergedTree = true).assertCountEquals(1) + rule.onNode(hasText("7.4") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed() rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() press(Key.DirectionDown) assertEquals("seasons", state.row) rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() } - @Test fun seriesKeepsItsSeasonsShortcutAndSecondaryRequestUsesTheMenu() { + @Test fun seriesUsesDownToReachSeasonsWithoutARedundantShortcut() { render("Andor") - rule.onNodeWithTag("title-option-seasons").assertIsDisplayed() + rule.onNodeWithTag("title-option-seasons").assertDoesNotExist() menu() rule.onNodeWithText("Add for someone").assertIsDisplayed() press(Key.Back) - press(Key.DirectionRight) - press(Key.DirectionCenter) + press(Key.DirectionDown) assertEquals("seasons", state.row) + rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed() + } + + @Test fun episodeFactsUseTheEpisodeSetRatherThanTheSeriesSet() { + render("episode-episode-1-2025-s1e1", "shared") { data -> + data.copy(item = data.item.copy(ratings = listOf(LibraryRating("rtCritics", 20.0, "Rotten"))), + watchTarget = data.watchTarget.copy(ratings = listOf(LibraryRating("rtCritics", 84.0, "Fresh"), LibraryRating("tmdb", 8.7, episode = true))), + episodes = data.episodes.distinctBy { it.id }) + } + rule.onAllNodesWithText("84%", useUnmergedTree = true).assertCountEquals(1) + rule.onAllNodesWithText("8.7", useUnmergedTree = true).assertCountEquals(1) + rule.onNodeWithText("20%", useUnmergedTree = true).assertDoesNotExist() + rule.onNode(hasText("8.7") and hasAnyAncestor(hasTestTag("title-facts")), useUnmergedTree = true).assertIsDisplayed() } @Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() { diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..7660ed8e 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -74,7 +74,7 @@ Row reveals are grouped rather than the browser prototype's per-card staging: th The title page's scroll dim is a draw-only colour overlay, not an alpha-animated full-screen layer. Discover scrolls its backdrop through placement offsets rather than a full-screen translation layer. Initial loading animated layers are Home **2**, Library **up to 3** lazy rows, Discover **2**, title header **1**, person **2**; pending title episode/cast shelves add **1 each**, and each pending Discover lane owns **1**. Deep Home loading is bounded to **4** visible shelf layers. These are animated layers, not independent pulse clocks or every static display-list node: one shared pulse clock drives the loading screen without adding a layer. A hero hand-off adds at most **2** bounded owners (outgoing art and copy). Focus changes can animate **2** card transforms, **1** caption and at most **2** rating plates; artwork fades are painter draws without additional compositing layers. Late title metadata adds at most **4** bounded alpha layers: title ratings, play-target ratings, genres and overview. No animated owner is full-screen. -The series readiness band reserves **56 px (28 dp)** from its first content paint, including when empty or when readiness later disappears. Ratings remain inside the fixed-height facts band. The series header's measured height therefore cannot change when late ratings/readiness arrive; overview, actions, season and episode anchors retain their coordinates. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. +The approved title follow-up (2026-11-07) supersedes the fixed readiness reservation: an absent readiness line occupies **0 px**, while a present line occupies **56 px (28 dp)**. Late arrival/removal animates that height over the **200 ms late-line duration with token ease-out**; reduced motion snaps. Overview, actions and following rows move monotonically with that band; an already-pinned focused row stays pinned through same-remeasure scroll compensation. Ratings remain inside the fixed-height facts band and appear once: an explicit episode page uses its opened episode's own set, including the episode score, rather than the series-only set. Series and episode pages have no redundant Seasons shortcut; Down reaches the season chips. Skeletons have no click/hover focus targets; pending episode/cast rows remain absent from the key-navigation state until real cards exist. Initial loading keeps nav focus and Back still pops the title/person route. Discover key traversal skips pending lanes in both directions. If every remaining lane is pending, Down stays on the real filter chips and Back returns to nav; a skeleton must never create an empty-column focus range. diff --git a/docker/README.md b/docker/README.md index 5198772e..c8dae34c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -648,7 +648,10 @@ retain the approved frames except for the requested feedback changes: 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. + following section. Missing readiness lines collapse completely on every title + kind; late lines expand/collapse over the 200 ms late-line token with ease-out, + or snap with reduced motion. Already-pinned rows compensate in the same + remeasure rather than bouncing through a second focus-follow animation. 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 @@ -720,9 +723,11 @@ 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. +use the shared 4 px straddle focus ring. Neither series nor episode details have +a redundant Seasons shortcut; Down reaches the season chips directly. Ratings +appear only in the header facts line, not again beside progress. Explicit episode +pages use the opened episode's own set, including its `Ep` score; movie and series +pages keep their own sets. Episode shelf skeletons use the same width, height and pitch as ready cards. Person filmography and related-title shelves remain posters; Home continuation 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 85c224bf..f7cfe54f 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 @@ -1,5 +1,9 @@ package org.justinvisser.wolfskin.design +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.getValue +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.platform.testTag @@ -35,6 +39,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } val item = state.data.item + val factsItem = state.data.watchTarget.takeIf { it.kind == "episode" } ?: item + val readinessHeight by animateDpAsState( + if (state.data.requestedStage == null) WolfskinTokens.Shell.zero else WolfskinTokens.Motion.readinessSlot, + tween(if (reducedMotion()) 0 else WolfskinTokens.Motion.lateLineFade, easing = WolfskinTokens.Motion.easeOut), + label = "title-readiness-height") val episode = when { state.row == "episodes" -> state.data.episodes.getOrNull(state.column) state.data.watchTarget.kind == "episode" -> state.data.watchTarget @@ -56,10 +65,10 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie item.year?.let { Text(it.toString(), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) } if (item.kind == "movie" && item.runtimeSeconds > 0) Text(item.runtime, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) if (item.kind == "tv" && item.seasons > 0) Text("${item.seasons} season${if (item.seasons == 1) "" else "s"}", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body)) - if (state.data.connection != TitleConnection.SignedOut) TitleRatings(item.ratings) + if (state.data.connection != TitleConnection.SignedOut) TitleRatings(factsItem.ratings) if (item.genres.isNotEmpty()) Text(item.genres.take(3).joinToString(", "), color = C.muted, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body).lateLineFade(item.genres)) } - Box(Modifier.fillMaxWidth().height(if (item.kind == "tv" || state.data.requestedStage != null) WolfskinTokens.Motion.readinessSlot else WolfskinTokens.Shell.zero).testTag("title-readiness-slot")) { + Box(Modifier.fillMaxWidth().height(readinessHeight).clipToBounds().testTag("title-readiness-slot")) { state.data.requestedStage?.let { stage -> val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap).lateLineFade(stage), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { 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 4a79a147..c423ad3e 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 @@ -120,7 +120,18 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val position by remember(list, rowOffsets) { derivedStateOf { rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset } } + val headerPixels = with(density) { headerHeight.roundToPx() } + val previousHeaderPixels = remember(list, item.id, density) { intArrayOf(headerPixels) } + val compensateHeader = previousHeaderPixels[0] != headerPixels && !shell.navFocused && !list.isScrollInProgress && + list.firstVisibleItemIndex == 0 && list.firstVisibleItemScrollOffset > 0 && + list.layoutInfo.visibleItemsInfo.any { it.key == state.row && kotlin.math.abs(it.offset - with(density) { L.pinnedLine.roundToPx() }) <= 1 } + // Compensate in the same remeasure; an asynchronous scroll would chase the animated header. + androidx.compose.runtime.SideEffect { + if (compensateHeader) list.requestScrollToItem(0, (list.firstVisibleItemScrollOffset + headerPixels - previousHeaderPixels[0]).coerceAtLeast(0)) + previousHeaderPixels[0] = headerPixels + } LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) { + if (compensateHeader) return@LaunchedEffect if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val y = rowOffsets[index] 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 fad34b31..1d363e19 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 @@ -193,7 +193,6 @@ class TitleState( "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", "add" -> onAction(TitleAction.Request(data.item, id != "request")) @@ -302,7 +301,6 @@ fun titleOptions(state: TitleState): List = buildList { 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 || (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 { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt index 12d6ddad..47dac75a 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/MotionSurfaceTest.kt @@ -44,20 +44,19 @@ class MotionSurfaceTest { @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) private val viewer = ShellPerson("justin", "Justin", "J", false) - @Test fun lateSeriesReadinessAndRatingsLeaveActionsOverviewAndEpisodesAtTheSameCoordinates() { + @Test fun reducedMotionReadinessSnapsAndRemovingItRestoresTheCollapsedStack() { val shell = shell() val series = TitleItem("series", "A series", "tv", seasons = 2, held = true, overview = "Series overview") val episode = TitleItem("episode", "Pilot", "episode", season = 1, episode = 1, overview = "Episode overview") val state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} - rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } - rule.waitForIdle() + rule.setContent { CompositionLocalProvider(LocalReducedMotion provides true) { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } } val before = bounds() - for (stage in listOf("Ready", "Downloading", null)) { - rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Mindel", item = series.copy(ratings = listOf(LibraryRating("rt", 88.0))))) } - rule.mainClock.advanceTimeBy(3400) - rule.waitForIdle() - assertEquals("Late metadata cannot change reserved header geometry", before, bounds()) - } + assertEquals(0f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f) + rule.runOnIdle { state.update(state.data.copy(requestedStage = "Ready", requestActor = "Mindel")) } + assertEquals(56f, rule.onNodeWithTag("title-readiness-slot").fetchSemanticsNode().boundsInRoot.height, 0.1f) + bounds().zip(before).forEach { (after, initial) -> assertEquals(56f, after.top - initial.top, 1f) } + rule.runOnIdle { state.update(state.data.copy(requestedStage = null)) } + assertEquals(before, bounds()) } @Test fun titleSkeletonIgnoresDownAndBackPopsWithoutOpeningThePicker() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt new file mode 100644 index 00000000..01bc734a --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/TitleReadinessMotionTest.kt @@ -0,0 +1,100 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.MotionDurationScale +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** Readiness changes move real title content continuously, without reserving an empty band. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TitleReadinessMotionTest { + private val animatorScale = object : MotionDurationScale { override var scaleFactor by mutableFloatStateOf(1f) } + @get:Rule val rule = createAndroidComposeRule(effectContext = animatorScale) + private lateinit var state: TitleState + + private fun render() { + val viewer = ShellPerson("justin", "Justin", "J", false) + val shell = ShellState(listOf(viewer), viewer.key).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/series") } + val series = TitleItem("series", "A series", "tv", seasons = 1, held = true, overview = "A series overview") + val episode = TitleItem("episode", "Pilot", "episode", held = true, season = 1, episode = 1, overview = "Episode overview") + state = TitleState(TitlePageData(series, playTarget = episode, episodes = listOf(episode), connection = TitleConnection.SignedIn), viewer) {} + rule.setContent { WolfskinTheme { TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp)) } } + rule.mainClock.advanceTimeBy(500) + rule.waitForIdle() + } + + private fun positions(): List = listOf("title-readiness-slot", "title-overview", "title-actions", "title-shelf:episodes").mapIndexed { index, tag -> + val bounds = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + if (index == 0) bounds.height else bounds.top + } + + private fun frames(stage: String?, name: String, count: Int = 22): List> { + val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-readiness-motion/$name").apply { mkdirs() } + rule.runOnIdle { state.update(state.data.copy(requestedStage = stage, requestActor = "Justin")); androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() } + val values = List(count) { index -> + rule.waitForIdle() + positions().also { + rule.onRoot().captureRoboImage(File(directory, "${index.toString().padStart(2, '0')}.png").absolutePath) + rule.mainClock.advanceTimeBy(16) + rule.waitForIdle() + } + } + File(directory, "samples.csv").writeText("timeMs,slotHeightPx,overviewTopPx,actionsTopPx,episodesTopPx\n" + values.mapIndexed { index, row -> "${index * 16},${row.joinToString(",")}" }.joinToString("\n")) + return values + } + + @Test fun lateReadinessExpansionAndRemovalStayMonotonicAndMoveTheWholeStack() { + render() + val collapsed = positions() + assertEquals(0f, collapsed[0], 0.1f) + rule.mainClock.autoAdvance = false + val opening = frames("Ready", "expand") + val expanded = opening.last() + assertEquals(56f, expanded[0], 0.1f) + expanded.drop(1).zip(collapsed.drop(1)).forEach { (after, before) -> assertEquals(56f, after - before, 1f) } + val closing = frames(null, "collapse") + assertEquals(collapsed, closing.last()) + for ((samples, direction) in listOf(opening to 1f, closing to -1f)) { + samples.zipWithNext().forEach { (before, after) -> before.zip(after).forEach { (first, second) -> + assertTrue("Readiness content must not reverse or overshoot: $samples", (second - first) * direction >= -0.1f) + assertTrue("Height change must not jump in one frame", kotlin.math.abs(second - first) < 56f) + } } + samples.forEach { assertTrue(it[0] in 0f..56f) } + } + assertFalse(state.options.any { it.id == "seasons" }) + rule.mainClock.autoAdvance = true + } + + @Test fun aPinnedEpisodeShelfDoesNotBounceWhenReadinessChangesAboveIt() { + render() + rule.runOnIdle { state.focus("episodes", 0) } + rule.mainClock.advanceTimeBy(500) + rule.waitForIdle() + val pinned = positions().last() + rule.mainClock.autoAdvance = false + for (samples in listOf(frames("Ready", "pinned", count = 34), frames(null, "pinned-collapse", count = 34))) { + samples.forEach { assertEquals("A header change cannot bounce a pinned focused shelf", pinned, it.last(), 1f) } + } + assertEquals("episodes", state.row) + rule.mainClock.autoAdvance = true + } +} From 9cfe9362eeeb74c41bde93320b005469b0b6f11a Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 18:02:17 +0100 Subject: [PATCH 05/10] Fix picker access and finish Wolfskin on root Back --- .../wholphin/services/NavigationManager.kt | 11 +- .../wolfskin/tv/WolfskinBackStackTest.kt | 147 ++++++++++++++ .../wolfskin/tv/WolfskinRootExitTest.kt | 184 ++++++++++++++++++ .../justinvisser/wolfskin/tv/WolfskinApp.kt | 10 +- .../wolfskin/tv/WolfskinBackHandler.kt | 28 +++ data/tv-spec/spec.md | 4 +- docker/README.md | 22 ++- .../wolfskin/design/ShellState.kt | 16 +- .../wolfskin/design/WhosWatching.kt | 9 +- .../wolfskin/design/PickerNavigationTest.kt | 71 +++++++ .../wolfskin/design/ShellKeysTest.kt | 10 - 11 files changed, 481 insertions(+), 31 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 87095f8b..ef211d7e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.services import androidx.navigation3.runtime.NavBackStack import com.github.damontecres.wholphin.ui.nav.Destination +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import org.acra.ACRA import timber.log.Timber import javax.inject.Inject @@ -15,6 +17,11 @@ class NavigationManager @Inject constructor() { var backStack: MutableList = NavBackStack(Destination.Home()) + private val exits = Channel(Channel.CONFLATED) + val exitRequests = exits.receiveAsFlow() + + /** Finish the containing task; never expose an older setup/user entry. */ + fun requestExit() { exits.trySend(Unit) } /** * Go to the specified [com.github.damontecres.wholphin.ui.nav.Destination] @@ -41,12 +48,14 @@ class NavigationManager } /** - * Go to the previous page + * Go to the previous page, or exit when there is no parent. */ fun goBack() { synchronized(this) { if (backStack.size > 1) { backStack.removeLastOrNull() + } else { + requestExit() } } log() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt new file mode 100644 index 00000000..31f71803 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinBackStackTest.kt @@ -0,0 +1,147 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService +import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage +import com.github.damontecres.wholphin.ui.setup.InstallUpdateState +import com.github.damontecres.wholphin.ui.setup.UpdateViewModel +import com.github.damontecres.wholphin.util.LoadingState +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.* +import org.junit.Before +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.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Real full-screen pages keep their local Back ladder before task-level exit. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WolfskinBackStackTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var activity: ComponentActivity + private val navigation = NavigationManager() + private val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin") { + when (it) { + ShellAction.Pop -> navigation.goBack() + ShellAction.Exit -> navigation.requestExit() + else -> Unit + } + }.apply { pickerVisible = false; pickerLaunch = false; navFocused = true } + + @Before fun initialize() { activity = rule.activity } + + @Test fun singletonUpdatesBackFinishesInsteadOfBecomingADeadEnd() { + navigation.replace(Destination.UpdateApp) + val model = updatesModel() + rule.setContent { + rememberWolfskinBackHandler(shell, navigation) + WolfskinTheme { InstallUpdatePage(preferences, viewModel = model) } + } + rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed() + back() + assertTrue(activity.isFinishing) + assertEquals(listOf(Destination.UpdateApp), navigation.backStack.toList()) + } + + @Test fun pushedUpdatesPopsSettingsThenPickerBeforeRootExit() { + navigation.navigateTo(Destination.Wolfskin("settings")) + navigation.navigateTo(Destination.UpdateApp) + shell.enterDestination("settings") + shell.navFocused = false + val model = updatesModel() + rule.setContent { + rememberWolfskinBackHandler(shell, navigation) + WolfskinTheme { + if (navigation.backStack.last() == Destination.UpdateApp) InstallUpdatePage(preferences, viewModel = model) + else ShellSurface(shell) { SettingsList(shell, it) } + } + } + rule.onNodeWithText("No release found! Check the update URL.").assertIsDisplayed() + back() + assertFalse(activity.isFinishing) + assertEquals(listOf(Destination.Home(), Destination.Wolfskin("settings")), navigation.backStack.toList()) + back() + assertFalse(activity.isFinishing) + assertTrue(shell.pickerVisible) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + back() + assertFalse(shell.pickerVisible) + assertTrue(shell.navFocused) + assertFalse(activity.isFinishing) + back() + assertTrue(activity.isFinishing) + } + + @Test fun unsavedHomeSettingsPromptsBeforeDiscardCanExitTheTask() { + navigation.replace(Destination.HomeSettings) + val model = mockk(relaxed = true) + every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success)) + every { model.serverPluginActive } returns MutableStateFlow(false) + every { model.settingsChanged } returns MutableStateFlow(true) + every { model.navigationManager } returns navigation + every { model.discardChanges() } returns Job().apply { complete() } + val images = mockk(relaxed = true) + val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true)) + rule.setContent { + rememberWolfskinBackHandler(shell, navigation) + WolfskinTheme { + CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) { + HomeSettingsPage(preferences, Modifier.fillMaxSize(), viewModel = model) + } + } + } + back() + assertFalse("Back must not discard unsaved settings or finish the task", activity.isFinishing) + assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList()) + val discard = rule.onNodeWithText("Discard").assertIsDisplayed().assertIsEnabled() + discard.performSemanticsAction(SemanticsActions.RequestFocus) { it() } + discard.assertIsFocused().performKeyInput { pressKey(Key.DirectionCenter) } + rule.waitUntil(10_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + activity.isFinishing + } + assertEquals(listOf(Destination.HomeSettings), navigation.backStack.toList()) + } + + private fun updatesModel(): UpdateViewModel = mockk(relaxed = true).also { model -> + every { model.state } returns MutableStateFlow(InstallUpdateState(loading = LoadingState.Success)) + every { model.navigationManager } returns navigation + every { model.updater.hasPermissions() } returns true + } + + private fun back() { + rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + rule.mainClock.advanceTimeBy(400) + if (!activity.isFinishing) rule.waitForIdle() + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt new file mode 100644 index 00000000..4e19a94a --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinRootExitTest.kt @@ -0,0 +1,184 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinServerUsers +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Actual Wolfskin NavDisplay and Android Back finish the activity instead of exposing upstream setup. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WolfskinRootExitTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var activity: ComponentActivity + private lateinit var model: WolfskinShellViewModel + private lateinit var conductarr: ConductarrSession + private val navigation = NavigationManager() + private val session = WolfskinSession() + private var oldFixtures = false + private var oldScreens = true + + @Before fun initialize() { + activity = rule.activity + ConductarrPreference.initialize(activity) + WolfskinScreenPreference.initialize(activity) + oldFixtures = ConductarrPreference.fixtures + oldScreens = WolfskinScreenPreference.enabled + ConductarrPreference.updateFixtures(true) + WolfskinScreenPreference.enabled = true + } + + @After fun cleanup() { + if (::model.isInitialized) model.viewModelScope.cancel() + if (::conductarr.isInitialized) conductarr.close() + ConductarrPreference.updateFixtures(oldFixtures) + WolfskinScreenPreference.enabled = oldScreens + } + + @Test fun homeContentThenNavBackFinishesTheActivity() = verifyRoot("home") + @Test fun discoverContentThenNavBackFinishesTheActivity() = verifyRoot("discover") + @Test fun moviesContentThenNavBackFinishesTheActivity() = verifyRoot("movies") + @Test fun showsContentThenNavBackFinishesTheActivity() = verifyRoot("shows") + @Test fun animeContentThenNavBackFinishesTheActivity() = verifyRoot("anime") + + @Test fun hardwareBackUsesTheSameContentNavExitLadder() { + mount() + val shell = session.state!! + rule.runOnIdle { shell.pickerVisible = false; shell.pickerLaunch = false } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } + assertTrue(shell.navFocused) + assertFalse(activity.isFinishing) + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back) } + rule.mainClock.advanceTimeBy(400) + assertTrue(activity.isFinishing) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + } + + @Test fun coldPickerBackFinishesWithoutSelectingAnAvatarOrShowingUpstreamUi() { + mount() + assertTrue(session.state!!.pickerLaunch) + back() + assertTrue("Back from the cold picker must finish the activity", activity.isFinishing) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + } + + @Test fun reopenedPickerBackDismissesToNavAndTheFollowingBackExits() { + mount() + val shell = session.state!! + rule.runOnIdle { shell.pickerVisible = false; shell.focusNav(shell.tabs.size); shell.key(ShellKey.Ok) } + assertTrue(shell.pickerVisible) + back() + assertFalse(shell.pickerVisible) + assertTrue(shell.navFocused) + assertFalse(activity.isFinishing) + back() + assertTrue(activity.isFinishing) + } + + @Test fun comingInSheetDismissesBeforeContentNavAndExit() { + mount() + val shell = session.state!! + rule.runOnIdle { + shell.pickerVisible = false + shell.pickerLaunch = false + shell.navFocused = false + shell.openRequests() + } + assertTrue(shell.requestsVisible) + back() + assertFalse(shell.requestsVisible) + assertFalse(shell.navFocused) + assertFalse(activity.isFinishing) + back() + assertTrue(shell.navFocused) + assertFalse(activity.isFinishing) + back() + assertTrue(activity.isFinishing) + } + + private fun verifyRoot(route: String) { + mount() + val shell = session.state!! + val destination = if (route == "home") Destination.Home() else Destination.Wolfskin(route) + rule.runOnIdle { + shell.pickerVisible = false + shell.pickerLaunch = false + shell.enterDestination(route) + navigation.replace(destination) + } + rule.mainClock.advanceTimeBy(1200) + rule.waitForIdle() + rule.waitUntil(10_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + shell.contentFocus?.hasContent == true && + (route !in LIBRARY_DESTINATIONS || shell.rememberedView(route).library?.status == LibraryStatus.Ready) + } + rule.runOnIdle { shell.navFocused = false } + back() + assertFalse("The first Back must not finish $route content", activity.isFinishing) + assertTrue("The first Back must move $route content to nav", shell.navFocused) + assertEquals(listOf(destination), navigation.backStack.toList()) + back() + assertTrue("Back from $route nav must finish the activity", activity.isFinishing) + assertEquals("Exit cannot detour to old Home", listOf(destination), navigation.backStack.toList()) + } + + private fun mount() { + val server = JellyfinServer(UUID.randomUUID(), "Fixture", "https://example.invalid", null) + val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = null) + val repository = mockk(relaxed = true) + coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user)) + val libraries = mockk(relaxed = true) + coEvery { libraries.readDestinations(user.id) } returns emptyMap() + conductarr = ConductarrSession(MemoryCacheStore()) + model = WolfskinShellViewModel(activity, session, repository, navigation, mockk(relaxed = true), + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + val home = HomeFixture(activity).snapshot("justin") + rule.setContent { + WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, + homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} }) + } + rule.waitUntil(10_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + session.state?.people?.size == 3 && model.signedInPerson == "justin" + } + rule.waitForIdle() + } + + private fun back() { + rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + rule.mainClock.advanceTimeBy(400) + if (!activity.isFinishing) rule.waitForIdle() + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index 2eb874eb..074f312e 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -1,7 +1,6 @@ package org.justinvisser.wolfskin.tv import android.content.Context -import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -201,6 +200,7 @@ class WolfskinShellViewModel @Inject constructor( } } ShellAction.Pop -> navigation.goBack() + ShellAction.Exit -> navigation.requestExit() is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.Person -> selectPerson(action.person, server) ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } @@ -271,7 +271,9 @@ fun WolfskinApp( } } val loading = remember(user.id) { - ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading } + ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()) { + if (it == ShellAction.Exit) navigationManager.requestExit() + }.apply { peopleStatus = PeopleStatus.Loading } } val state = viewModel.session.state ?: loading val discover = rememberDiscoverState( @@ -285,6 +287,7 @@ fun WolfskinApp( }, ) RequestsRefresh(state, viewModel::refreshRequests) + val onBack = rememberWolfskinBackHandler(state, navigationManager) WolfskinTheme { CompositionLocalProvider(LocalShellState provides state, LocalDiscoverState provides discover, org.justinvisser.wolfskin.design.LocalScreenRatings provides if (identity?.signedIn == true && !ConductarrPreference.fixtures) remember(identity?.client) { ConductarrRatings(identity!!.client) } else null) { @@ -300,7 +303,7 @@ fun WolfskinApp( } NavDisplay( backStack = navigationManager.backStack, - onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, + onBack = onBack, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { @@ -321,7 +324,6 @@ fun WolfskinApp( if (destination.fullScreen) { DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) } else { - BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt new file mode 100644 index 00000000..8fd409fc --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinBackHandler.kt @@ -0,0 +1,28 @@ +package org.justinvisser.wolfskin.tv + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.findActivity +import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.design.ShellState + +/** Own root Back even when NavDisplay has no parent; child menus/dialogs retain first refusal. */ +@Composable +internal fun rememberWolfskinBackHandler(shell: ShellState, navigation: NavigationManager): () -> Unit { + val activity = checkNotNull(LocalContext.current.findActivity()) { "Wolfskin root requires an Activity" } + LaunchedEffect(navigation, activity) { + navigation.exitRequests.collect { activity.finishAndRemoveTask() } + } + val onBack: () -> Unit = remember(shell, navigation) { + { + if (navigation.backStack.lastOrNull()?.fullScreen == true) navigation.goBack() + else shell.key(ShellKey.Back) + } + } + BackHandler(onBack = onBack) + return onBack +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index a67848fd..38f097ae 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -4,7 +4,9 @@ ## Who’s watching -The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person. +Round-2 correction: the Settings gear is centred beneath the **last avatar**, independent of selection. Down from **any avatar** reaches it; Up returns to the avatar that supplied focus, including after visiting Settings and returning. Left/Right still traverse only avatars. This supersedes the revision-5 whole-row centring and final-avatar-only Down rule. The 4 px accent straddle ring, 1.08 scale and focus-only Settings caption are unchanged. + +Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end. ## Focus visibility diff --git a/docker/README.md b/docker/README.md index 3ddcfda1..41e41061 100644 --- a/docker/README.md +++ b/docker/README.md @@ -227,7 +227,9 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. -The small gear centred beneath the complete people row opens Settings with Requests focused. +The small gear beneath the last avatar opens Settings with Requests focused. +Down from any avatar reaches it; Up returns to the originating avatar, including +after a Settings visit. Left/Right avatar traversal is unchanged. Settings groups hand off to existing upstream pages; Requests opens the household Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent rollout switch: turning it off renders upstream Home and library destinations with @@ -236,6 +238,20 @@ Settings list. Native Up traversal stays in content until the first focusable ro then enters the nav. Turning the switch on renders Wolfskin Home and Libraries. Discover and Requests are integrated Wolfskin surfaces; title hand-offs remain labelled. +Root content Back focuses the nav; another Back exits the Android task via +`finishAndRemoveTask`, including Discover, Movies, Shows and Anime without a Home +detour. Cold picker Back exits; a reopened picker dismisses to nav first. Pushed +pages pop normally. Child dialogs, title menus, request sheets and unsaved-setting +prompts keep Back priority. Singleton full-screen pages also exit; they cannot +fall through to the older setup/user UI. + +`rememberWolfskinBackHandler` owns Android Back at the app root, including a +single-entry `NavDisplay`; full-screen hand-offs use the same `NavigationManager` +exit event rather than exposing the parent setup stack. `WolfskinRootExitTest` +mounts actual Wolfskin Home/Discover/library surfaces and covers picker, +hardware Back and Coming-in sheet precedence. `WolfskinBackStackTest` mounts +the real Updates and Home Settings pages to cover parent pops and unsaved edits. + Saved Jellyfin identities use the existing upstream session. Conductarr supplies household labels, requester capabilities and library audiences after sign-in. The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used @@ -655,7 +671,9 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and [native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) retain the approved frames except for the requested feedback changes: -- The picker gear is centred under everyone and uses the picker-person ring. +- Round 2 supersedes revision 5: the picker gear sits under the last avatar, + is reachable with Down from every avatar, and returns Up to its origin. + It retains the picker-person ring. - Shelf tracks share second-last-slot lookahead, count clamping and settled offsets. Home records completed frame positioning rather than an animation that may still be cancelled; first mount restores the intended shelf item. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index ecae94c8..b18715bd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -15,6 +15,7 @@ sealed interface ShellAction { data class Person(val person: ShellPerson) : ShellAction data class HandOff(val group: String) : ShellAction data object Pop : ShellAction + data object Exit : ShellAction data object RetryPeople : ShellAction data object RetryRequests : ShellAction data class RequestTitle(val request: ComingInRequest) : ShellAction @@ -196,7 +197,7 @@ class ShellState( when (key) { ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0) ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex) - ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true + ShellKey.Down -> gearFocused = true ShellKey.Ok -> choosePerson(people[pickerIndex]) ShellKey.Back -> closePicker() else -> Unit @@ -220,7 +221,8 @@ class ShellState( } private fun closePicker() { - if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size } + if (pickerLaunch) onAction(ShellAction.Exit) + else { pickerVisible = false; navFocused = true; navIndex = tabs.size } } private fun navKey(key: ShellKey) { @@ -288,7 +290,7 @@ class ShellState( onAction(ShellAction.RequestTitle(request)) } - /** Back never discards a root view, including the card below a focused nav. */ + /** Back retains root view memory; a second root Back exits instead of detouring through Home. */ fun back() { if (requestsVisible) { closeRequests(); return } val returnRoute = requestReturnRoute @@ -306,17 +308,13 @@ class ShellState( pickerVisible = true pickerLaunch = false gearFocused = true - pickerIndex = people.lastIndex } else if (route !in tabs) { onAction(ShellAction.Pop) } else if (!navFocused) { navFocused = true navIndex = currentTab - } else if (route != "home") { - route = "home" - rootRoute = "home" - navIndex = 0 - onAction(ShellAction.Destination(route)) + } else { + onAction(ShellAction.Exit) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 224469bc..b140deb2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -20,12 +20,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag 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.Shell as S -/** The picker covers the nav and exposes Settings only below the final person. */ +/** The picker covers the nav; Down from any person reaches Settings below the final avatar. */ @Composable fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize().background(C.bg)) { @@ -42,7 +43,7 @@ private fun PeoplePicker(state: ShellState) { Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) { state.people.forEachIndexed { index, person -> val focused = !state.gearFocused && state.pickerIndex == index - Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { + Column(Modifier.width(L.pickerAvatar).testTag("picker-person:${person.key}").focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { state.focusPerson(index); state.key(ShellKey.Ok) }, horizontalAlignment = Alignment.CenterHorizontally) { Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, @@ -52,8 +53,8 @@ private fun PeoplePicker(state: ShellState) { } } } - val left = (S.stageWidth - L.gear) / 2 - Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) + val left = (S.stageWidth - L.gear + (L.pickerAvatar + L.pickerGap) * (state.people.size - 1)) / 2 + Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt new file mode 100644 index 00000000..35916d32 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt @@ -0,0 +1,71 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Every profile reaches Settings vertically without losing its horizontal return position. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class PickerNavigationTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun downFromEveryAvatarReachesSettingsAndUpReturnsToTheOrigin() { + val state = ShellState(fixturePeople(), "justin") + rule.setContent { WolfskinTheme { ShellSurface(state) } } + for (index in state.people.indices) { + rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false } + press(Key.DirectionDown) + assertTrue("Down from profile $index must reach Settings", state.gearFocused) + press(Key.DirectionUp) + assertFalse(state.gearFocused) + assertEquals("Up must return to profile $index", index, state.pickerIndex) + } + } + + @Test fun settingsBackThenUpKeepsTheAvatarThatOpenedTheGear() { + val state = ShellState(fixturePeople(), "justin") + rule.setContent { WolfskinTheme { ShellSurface(state) } } + for (index in state.people.indices) { + rule.runOnIdle { state.pickerIndex = index; state.gearFocused = false } + press(Key.DirectionDown) + press(Key.DirectionCenter) + assertEquals("settings", state.route) + press(Key.Back) + assertTrue(state.pickerVisible) + assertTrue(state.gearFocused) + press(Key.DirectionUp) + assertEquals(index, state.pickerIndex) + } + } + + @Test fun settingsGearIsCenteredUnderTheFinalAvatarForAnyProfileCount() { + val state = ShellState(fixturePeople(), "justin") + rule.setContent { WolfskinTheme { ShellSurface(state) } } + for (count in 1..5) { + val people = (0 until count).map { ShellPerson("person-$it", "Person $it", "$it", false) } + rule.runOnIdle { state.updatePeople(people); state.gearFocused = true } + rule.mainClock.advanceTimeBy(400) + val last = rule.onNodeWithTag("picker-person:${people.last().key}").fetchSemanticsNode().boundsInRoot + val gear = rule.onNodeWithTag("picker-settings").fetchSemanticsNode().boundsInRoot + assertEquals("Settings must stay under the last profile for $count people", last.center.x, gear.center.x, .1f) + assertTrue("Settings must be below the avatars", gear.top > last.bottom) + } + } + + private fun press(key: Key) { + rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } + rule.mainClock.advanceTimeBy(400) + rule.waitForIdle() + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt index 77052d6f..7b032dce 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -60,16 +60,6 @@ class ShellKeysTest { assertEquals(2, state.settingsRow) } - @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { - replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3) - assertFalse(state.gearFocused) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) } - assertTrue(state.gearFocused) - rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) } - assertFalse(state.gearFocused) - assertEquals(2, state.pickerIndex) - } - @Test fun rootBackAndNavDownPreserveViewAndWrap() { replay(listOf(Key.DirectionCenter), 1) val view = state.view From 5811ab05556266ffba8b86e211b1684b2b96dd94 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 18:18:50 +0100 Subject: [PATCH 06/10] Synchronize saved-picker test with Android main queue --- .../org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt | 7 ++++++- docker/README.md | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index 1fb29d31..b7eb73de 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -69,7 +69,12 @@ class SavedPeoplePickerTest { WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, homeContent = { _, _, _, _ -> }) } - rule.waitUntil(5_000) { entered.get() && session.state?.people?.size == 3 } + rule.waitUntil(5_000) { + // IO completion publishes Compose state on Android's queue, not the test frame clock. + androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + entered.get() && session.state?.people?.size == 3 + } users.forEach { rule.onNodeWithText(it.name!!).assertIsDisplayed() } rule.runOnIdle { assertTrue(session.state!!.pickerVisible) diff --git a/docker/README.md b/docker/README.md index c5a3c4c1..c908c6bf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -43,6 +43,12 @@ 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. +`SavedPeoplePickerTest` publishes pending Compose snapshots and pumps Android's +main queue while awaiting the saved household, just like the session-picker +await above. Its five-second deadline, blocked first network response and saved +identity assertions are unchanged; the virtual frame clock alone does not drain +an IO continuation queued on Android's host looper. + ## Saved-person launch and DNS Launch restores an unprotected saved Jellyfin login from local storage before From c5dbe910766a17fd85d6205b7ab724632314fd44 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 18:52:30 +0100 Subject: [PATCH 07/10] Start new Home rows leftmost and gate outgoing Back handlers --- .../wolfskin/tv/TitleDestination.kt | 3 +- data/tv-spec/spec.md | 2 + docker/README.md | 9 ++- .../justinvisser/wolfskin/design/HomeFocus.kt | 26 +------- .../wolfskin/design/ScreenEntry.kt | 4 ++ .../wolfskin/design/HomeKeysTest.kt | 9 ++- .../wolfskin/design/HomeRowEntryTest.kt | 61 +++++++++++++++++++ 7 files changed, 82 insertions(+), 32 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index 4db5512d..c1342442 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.input.key.type import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.justinvisser.wolfskin.design.DiscoverOverlay import org.justinvisser.wolfskin.design.ShellKey +import org.justinvisser.wolfskin.design.isScreenEntryActive import org.justinvisser.wolfskin.design.readShellKey import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.ViewModel @@ -339,7 +340,7 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } val person = viewModel.person.takeIf { title != null } - BackHandler(enabled = title != null) { + BackHandler(enabled = isScreenEntryActive() && title != null) { if (requests?.overlay != null) requests.key(ShellKey.Back) else title?.back(shell) } Box(modifier.onPreviewKeyEvent { event -> diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index f9a897b7..1f765f22 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -24,6 +24,8 @@ The lane retains the `cw` identity. Legacy local, server, web and plugin Home se Every navigation entry owns its own viewport even while an outgoing page and incoming page coexist during push/Back. Only the active entry may acquire Android focus or register the shell's logical key controller. An outgoing title/person page must never rebind to Home's `LazyListState` when the shared route changes. Down skips non-focusable/pending rows, reaches the final real row, and scrolls it into view after Back and refresh. +An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation. + Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band. The title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp; the approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. diff --git a/docker/README.md b/docker/README.md index c908c6bf..fb980012 100644 --- a/docker/README.md +++ b/docker/README.md @@ -392,9 +392,12 @@ a manually scrolled saved view. The next arrow re-enters visible content when scrolling moved focus off-screen. Pointing changes focus without activation, cards activate on key-up, and held OK events cannot activate twice. -Between shelves, an unvisited row selects the nearest visible card center even -when card widths differ. A visited row restores its column. Horizontal movement -keeps the track still while focus remains in its visible middle. +Between Home shelves, an unvisited row starts at its leftmost card. A visited row +restores its column and first-visible position, including after leaving and +returning to the Home tab; Home memory is not reset on tab leave. Library keeps +standard grid navigation. Horizontal movement keeps the track still while focus +remains in its visible middle. Title/person Android Back handlers use the same +active-entry ownership flag, so outgoing pages cannot consume a second Back. The initial skeleton parks focus on the nav. When the first content arrives, an untouched fresh entry focuses hero button zero; intervening nav input prevents that automatic transfer. Home, Libraries and Discover use the same `ShellContentFocus` diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt index 1078d8db..548292fd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeFocus.kt @@ -1,7 +1,5 @@ package org.justinvisser.wolfskin.design -import kotlin.math.abs - /** Home navigation mutates the shell's per-person view, including each row's remembered column. */ class HomeFocus( private val shell: ShellState, @@ -38,8 +36,7 @@ class HomeFocus( while (next in rows.indices && !rows[next].focusable) next += direction if (next in rows.indices) { val target = rows[next] - val nearest = nearest(current, target) - focus(next, view.cards[target.id] ?: nearest) + focus(next, view.cards[target.id] ?: 0) } else if (direction < 0) { view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) view.home.resetScroll++ @@ -77,27 +74,6 @@ class HomeFocus( else -> Unit } } - - private fun nearest(from: HomeRow, to: HomeRow): Int { - if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt() - val fromFirst = if (from.kind == HomeRowKind.Shelf) view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0 - val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2 - val first = (view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0)) - val last = minOf(to.count - 1, first + to.focusSlots - 1) - var best = first - var distance = Float.POSITIVE_INFINITY - for (index in first..last) { - val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x) - if (candidate < distance) { best = index; distance = candidate } - } - return best - } - - private fun pitch(row: HomeRow): Float = when (row.cardKind) { - HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value - HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value - HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value - } } internal val HomeRow.focusSlots: Int get() = when (cardKind) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt index 2947bdd3..503f5338 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ScreenEntry.kt @@ -8,6 +8,10 @@ import androidx.compose.runtime.remember internal val LocalScreenView = compositionLocalOf { null } internal val LocalScreenActive = compositionLocalOf { true } +/** Outgoing entries cannot consume Back while their exit animation remains composed. */ +@Composable +fun isScreenEntryActive(): Boolean = LocalScreenActive.current + /** Overlapping navigation entries retain their own viewport; only the current entry owns input. */ @Composable fun ScreenEntry(shell: ShellState, route: String, active: Boolean, content: @Composable () -> Unit) { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt index 50f40ac0..a8bcc7a9 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeKeysTest.kt @@ -105,13 +105,15 @@ class HomeKeysTest { assertNull(action) } - @Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() { + @Test fun rememberedColumnsClampToShortRowsAndTheFinalSeeAll() { val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards) val short = long.copy(id = "short", cards = cards.take(2), total = 2) val tail = short.copy(id = "tail", total = 20) shell.view.cards[long.id] = cards.lastIndex + shell.view.cards[short.id] = cards.lastIndex + shell.view.cards[tail.id] = 2 var action: HomeAction? = null rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } } @@ -132,17 +134,18 @@ class HomeKeysTest { assertEquals(tail.id, (action as HomeAction.SeeAll).row.id) } - @Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() { + @Test fun visitedMixedWidthRowsKeepColumnsAndTrackWhenMovingInsideTheMiddle() { val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false } val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards) val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile) shell.view.cards[posters.id] = 3 + shell.view.cards[tiles.id] = 2 rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} } } } press("DOWN") - assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id]) + assertEquals(2, shell.view.cards[tiles.id]) repeat(4) { press("RIGHT") } val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left press("LEFT") diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt new file mode 100644 index 00000000..61382594 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeRowEntryTest.kt @@ -0,0 +1,61 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.foundation.lazy.LazyListState +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Home starts new rows at zero and retains visited positions independently of other root tabs. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class HomeRowEntryTest { + private val people = listOf(ShellPerson("justin", "Justin", "J", true)) + private val cards = List(12) { HomeTitle("item-$it", "Film $it", "movie") } + + @Test fun unvisitedRowsStartLeftmostAcrossCardWidths() { + for (from in HomeCardKind.entries) for (to in HomeCardKind.entries) { + val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false } + val source = HomeRow("source", HomeRowKind.Shelf, cards = cards, cardKind = from) + val target = source.copy(id = "target", cardKind = to) + val focus = HomeFocus(shell, listOf(source, target)) {} + focus.focus(0, 3) + focus.key(ShellKey.Down) + assertEquals("$from to $to", target.id, focus.row?.id) + assertEquals("$from to $to must start leftmost", 0, focus.column) + assertEquals(0, shell.rememberedView("home").home.firstCards[target.id]) + } + } + + @Test fun visitedColumnsAndFirstVisiblePositionsSurviveRowChangesAndRootTabLeave() { + val shell = ShellState(people, "justin").apply { pickerVisible = false; navFocused = false } + val source = HomeRow("source", HomeRowKind.Shelf, cards = cards) + val rows = listOf(source, source.copy(id = "target", cardKind = HomeCardKind.Tile)) + val focus = HomeFocus(shell, rows) {} + focus.focus(0, 8) + focus.key(ShellKey.Left) + val view = shell.rememberedView("home") + val first = view.home.firstCards.getValue(source.id) + val track = LazyListState(first, 19) + view.home.tracks[source.id] = track + focus.key(ShellKey.Down) + focus.key(ShellKey.Up) + assertEquals(7, focus.column) + assertEquals(first, view.home.firstCards[source.id]) + + shell.focusNav(shell.tabs.indexOf("discover")); shell.key(ShellKey.Ok) + shell.focusNav(shell.tabs.indexOf("home")); shell.key(ShellKey.Ok) + val restored = HomeFocus(shell, rows) {} + assertSame(view, shell.rememberedView("home")) + assertEquals(7, restored.column) + restored.key(ShellKey.Down) + restored.key(ShellKey.Up) + assertEquals(7, restored.column) + assertEquals(first, view.home.firstCards[source.id]) + assertSame(track, view.home.tracks[source.id]) + assertEquals(first, track.firstVisibleItemIndex) + assertEquals(19, track.firstVisibleItemScrollOffset) + } +} From a27e922675e2142907408bd75e97270e319cd48c Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 19:19:45 +0100 Subject: [PATCH 08/10] Cover outgoing title and person Back ownership --- .../wolfskin/tv/TitleEntryBackTest.kt | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt new file mode 100644 index 00000000..5f5d22dc --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleEntryBackTest.kt @@ -0,0 +1,107 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.nav.Destination +import io.mockk.coEvery +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.design.* +import org.justinvisser.wolfskin.tv.read.PersonRead +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** A loaded outgoing destination remains composed after pop but cannot consume the incoming Home Back. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TitleEntryBackTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var viewModel: WolfskinTitleViewModel + private lateinit var conductarr: ConductarrSession + + @After fun dispose() { + if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() + if (::conductarr.isInitialized) conductarr.close() + } + + @Test fun outgoingLoadedTitleCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = false) + @Test fun outgoingLoadedPersonCannotConsumeTheNextHomeBack() = verifyOutgoingBack(personPage = true) + + private fun verifyOutgoingBack(personPage: Boolean) { + val activity = rule.activity + val navigation = NavigationManager() + val viewer = ShellPerson("justin", "Justin", "J", false) + val shell = ShellState(listOf(viewer), viewer.key) { + if (it == ShellAction.Exit) navigation.requestExit() + }.apply { pickerVisible = false; pickerLaunch = false; navFocused = false } + val id = UUID.fromString("00000000-0000-0000-0000-000000000031") + val route = if (personPage) personRoute(id.toString()) else titleRoute(id.toString()) + val destination = Destination.Wolfskin(route) + val facade = mockk() + if (personPage) { + coEvery { facade.readPersonSaved(id) } returns null + coEvery { facade.readPerson(id, 0) } returns PersonRead(TitlePerson(id.toString(), "Outgoing actor"), + listOf(TitleItem("credit", "Credit", "movie"))) + } else { + coEvery { facade.readSaved(id, any()) } returns null + coEvery { facade.read(id, any(), any()) } returns TitlePageData(TitleItem(id.toString(), "Outgoing film", "movie", held = true)) + } + conductarr = ConductarrSession(MemoryCacheStore()) + viewModel = WolfskinTitleViewModel(activity, facade, mockk(), navigation, conductarr, + mockk(relaxed = true)) + shell.enterDestination(route) + navigation.navigateTo(destination) + val home = HomeSnapshot(listOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(HomeTitle("home-film", "Home film", "movie"))))) + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + rule.setContent { + WolfskinTheme { + rememberWolfskinBackHandler(shell, navigation) + val current = navigation.backStack.last() + LaunchedEffect(current) { if (current is Destination.Home) shell.enterDestination("home") } + Box { + ScreenEntry(shell, "home", current is Destination.Home) { HomeScreen(shell, home) {} } + // Retain the real outgoing destination just as NavDisplay does during its exit animation. + ScreenEntry(shell, route, current == destination) { + TitleDestination(route, shell, preferences, viewModel = viewModel, fixtures = false) + } + } + } + } + rule.waitUntil(5_000) { + Snapshot.sendApplyNotifications() + Shadows.shadowOf(Looper.getMainLooper()).idle() + (if (personPage) viewModel.person != null else viewModel.title?.data?.status == TitleReadStatus.Ready) && !shell.navFocused + } + rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + rule.waitForIdle() + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + assertFalse(shell.navFocused) + rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + rule.mainClock.advanceTimeBy(16) + assertTrue("The incoming Home must receive Back while the outgoing entry is retained", shell.navFocused) + assertFalse("A second Back must not finish the task through the outgoing entry", activity.isFinishing) + assertEquals(listOf(Destination.Home()), navigation.backStack.toList()) + rule.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + rule.mainClock.advanceTimeBy(16) + assertTrue("Only the following Back from Home nav should exit", activity.isFinishing) + } +} From 7c67596cb6a7ac125c1510c5c20462f46aa43a75 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 19:38:53 +0100 Subject: [PATCH 09/10] Restore approved picker Settings centring --- data/tv-spec/spec.md | 12 +++++++++--- docker/README.md | 14 ++++++++++---- .../justinvisser/wolfskin/design/WhosWatching.kt | 4 ++-- .../wolfskin/design/PickerNavigationTest.kt | 5 +++-- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 1f765f22..ec13f225 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -4,10 +4,16 @@ ## Who’s watching -Round-2 correction: the Settings gear is centred beneath the **last avatar**, independent of selection. Down from **any avatar** reaches it; Up returns to the avatar that supplied focus, including after visiting Settings and returning. Left/Right still traverse only avatars. This supersedes the revision-5 whole-row centring and final-avatar-only Down rule. The 4 px accent straddle ring, 1.08 scale and focus-only Settings caption are unchanged. +The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. + +Down from any avatar reaches the gear; Up returns to its origin, including after visiting Settings. Left/Right avatar traversal is unchanged. + +Source — Justin (2026-11-07): “the settings icon moved, but pressing down only works from the last profile icon. I want it to always move there when pressing down in that screen”. Root content Back moves to the nav; the next Back **finishes and removes Wolfskin’s Android task**, returning to the launcher without visiting an old Home/setup interface. This applies to Home, Discover, Movies, Shows and Anime. Cold-launch picker Back exits; a reopened picker first dismisses to its opener’s nav. Pushed title/person/settings/Updates pages pop their parent stack, and menus/sheets/unsaved-settings prompts get first refusal. A singleton full-screen page exits rather than becoming a dead end. +Source — Justin (2026-11-07): “pressing back enough times should close wolfskin, currently there is no way to go back to projectivity apart from weird methods via the old ui”. + ## Focus visibility “Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule. @@ -18,7 +24,7 @@ Library and Person grids retain six columns and the existing Level 2 row spacing ## Home continuation and navigation — round 2 amendment -Home has one **Continue watching** lane for in-progress films/episodes and next-up episodes. Use the existing landscape-tile geometry, order by most recent actual watch activity, and show a series once: its resumable episode takes precedence over its next-up episode. Unknown activity dates retain source order after known dates. The non-rotating hero keeps its resume → next-up → newest → pick precedence and is not duplicated in the lane. +Justin asked for one **Continue watching** lane merging in-progress films/episodes and next-up episodes. **Implementation choice:** reuse the existing Next up landscape-tile geometry for the merged lane, rather than the old Resume/wide geometry. Order by most recent actual watch activity and show a series once: its resumable episode takes precedence over its next-up episode. Unknown activity dates retain source order after known dates. The non-rotating hero keeps its resume → next-up → newest → pick precedence and is not duplicated in the lane. The lane retains the `cw` identity. Legacy local, server, web and plugin Home settings merge saved Resume/Next up rows at the first watch row's position and preserve that row's view options. Existing serialized types remain readable; new settings offer only Continue watching. @@ -26,7 +32,7 @@ Every navigation entry owns its own viewport even while an outgoing page and inc An unvisited Home row starts at its leftmost card rather than inheriting horizontal alignment from the previous row. Visited rows retain their remembered column and first-visible position across row changes, title Back and root-tab leave/return. Home does not adopt Discover’s tab-leave memory reset; Library keeps standard grid navigation. Outgoing title/person entries cannot intercept Android Back during their exit animation. -Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band. The title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp; the approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. +Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band, as Justin requested. **Implementation choice for the amount:** title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp. The existing approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. ## Episode artwork and dimensions diff --git a/docker/README.md b/docker/README.md index fb980012..f36b7f64 100644 --- a/docker/README.md +++ b/docker/README.md @@ -233,9 +233,13 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. -The small gear beneath the last avatar opens Settings with Requests focused. +The small gear centred beneath the complete people row opens Settings with Requests focused. Down from any avatar reaches it; Up returns to the originating avatar, including after a Settings visit. Left/Right avatar traversal is unchanged. +The navigation requirement comes from Justin's words: “the settings icon moved, +but pressing down only works from the last profile icon. I want it to always +move there when pressing down in that screen”. The approved gear placement stays +centred beneath the complete people row. Settings groups hand off to existing upstream pages; Requests opens the household Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent rollout switch: turning it off renders upstream Home and library destinations with @@ -251,6 +255,9 @@ pages pop normally. Child dialogs, title menus, request sheets and unsaved-setti prompts keep Back priority. Singleton full-screen pages also exit; they cannot fall through to the older setup/user UI. +Source — Justin: “pressing back enough times should close wolfskin, currently +there is no way to go back to projectivity apart from weird methods via the old ui”. + `rememberWolfskinBackHandler` owns Android Back at the app root, including a single-entry `NavDisplay`; full-screen hand-offs use the same `NavigationManager` exit event rather than exposing the parent setup stack. `WolfskinRootExitTest` @@ -680,9 +687,8 @@ The [revision-5 amendment](../data/tv-spec/spec.md) and [native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html) retain the approved frames except for the requested feedback changes: -- Round 2 supersedes revision 5: the picker gear sits under the last avatar, - is reachable with Down from every avatar, and returns Up to its origin. - It retains the picker-person ring. +- The picker gear retains its approved whole-row centring and picker-person ring. + Down from every avatar reaches it; Up returns to the originating avatar. - Shelf tracks share second-last-slot lookahead, count clamping and settled offsets. Home records completed frame positioning rather than an animation that may still be cancelled; first mount restores the intended shelf item. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index b140deb2..18e8c341 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -26,7 +26,7 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S -/** The picker covers the nav; Down from any person reaches Settings below the final avatar. */ +/** The picker covers the nav; Down from any person reaches the centred Settings gear. */ @Composable fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize().background(C.bg)) { @@ -53,7 +53,7 @@ private fun PeoplePicker(state: ShellState) { } } } - val left = (S.stageWidth - L.gear + (L.pickerAvatar + L.pickerGap) * (state.people.size - 1)) / 2 + val left = (S.stageWidth - L.gear) / 2 Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).testTag("picker-settings").focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringPickerRing) .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt index 35916d32..61a05809 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/PickerNavigationTest.kt @@ -49,16 +49,17 @@ class PickerNavigationTest { } } - @Test fun settingsGearIsCenteredUnderTheFinalAvatarForAnyProfileCount() { + @Test fun settingsGearIsCenteredUnderTheWholePeopleRowForAnyProfileCount() { val state = ShellState(fixturePeople(), "justin") rule.setContent { WolfskinTheme { ShellSurface(state) } } for (count in 1..5) { val people = (0 until count).map { ShellPerson("person-$it", "Person $it", "$it", false) } rule.runOnIdle { state.updatePeople(people); state.gearFocused = true } rule.mainClock.advanceTimeBy(400) + val first = rule.onNodeWithTag("picker-person:${people.first().key}").fetchSemanticsNode().boundsInRoot val last = rule.onNodeWithTag("picker-person:${people.last().key}").fetchSemanticsNode().boundsInRoot val gear = rule.onNodeWithTag("picker-settings").fetchSemanticsNode().boundsInRoot - assertEquals("Settings must stay under the last profile for $count people", last.center.x, gear.center.x, .1f) + assertEquals("Settings must stay centred under the whole row for $count people", (first.center.x + last.center.x) / 2, gear.center.x, .1f) assertTrue("Settings must be below the avatars", gear.top > last.bottom) } } From f375d88030abd14bf7b6b262792af6f5e20b3acf Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sat, 7 Nov 2026 19:46:17 +0100 Subject: [PATCH 10/10] Reserve Home hero primary action space --- data/tv-spec/spec.md | 4 ++ .../wolfskin/design/HomeHeader.kt | 20 +++++-- .../wolfskin/design/HomeHeroActionsTest.kt | 53 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index ec13f225..adcb0c6c 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -34,6 +34,10 @@ An unvisited Home row starts at its leftmost card rather than inheriting horizon Landscape-card bottom gradients extend through the reserved state-line area, ending at the solid bottom backing with no exposed artwork band, as Justin requested. **Implementation choice for the amount:** title-gradient top padding is **36 px / 18 dp**, down from 54 px / 27 dp. The existing approved 92% bottom opacity is unchanged. Captions and state lines retain their existing geometry. +Home hero actions retain the approved primary + secondary Details layout: [st-007, Mindel’s Resume hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-007-mindel-resume-in-the-hero.jpg) and [st-006, Justin’s Play-next hero](https://pages.justinvisser.org/wolfskin/tv-spec/img/st-006-justin-no-resume-hero-falls-back-to-next-up.jpg). Cached Home can paint before fresh watch data verifies playback. During that interval, measure and reserve the real primary button’s footprint without placing, drawing or enabling it; keep Details in its final secondary position and available for browsing. Playback readiness reveals the primary with the existing 200 ms late-line fade, never a lateral layout shift. The copy layer and button/spacing tokens do not change. + +Source — Justin (2026-11-07): “the resume button for the home hero item only appears after a second or two, it appearing causes the details button to shift places, this needs to be fixed as well”. + ## Episode artwork and dimensions diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt index 8057e033..e191c804 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeHeader.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.Layout import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text @@ -46,10 +47,21 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List + val button = measurables.single().measure(constraints) + // Reserve the real primary width; an unplaced button is neither drawn nor interactive. + layout(button.width, button.height) { if (row.watchReady) button.placeRelative(0, 0) } + } + val detailsColumn = if (detailsOnly || !row.watchReady) 0 else 1 + PickerButton("Details", focused && column == detailsColumn, detailsOnly, "about", onFocus = { onHover(detailsColumn) }, textStyle = T.button) { onButton(detailsColumn) } } } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt new file mode 100644 index 00000000..57552aac --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeHeroActionsTest.kt @@ -0,0 +1,53 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Fresh watch data may enable playback, but cannot move an already-visible Details action. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HomeHeroActionsTest { + @get:Rule val rule = createComposeRule() + + @Test fun latePlaybackReadinessKeepsDetailsBoundsForResumePlayAndEpisodeLabels() { + val title = HomeTitle("arrival", "Arrival", "movie", overview = "A linguist investigates a mysterious arrival.") + val episode = title.copy(id = "episode", type = "episode", season = 3, episode = 7) + val directory = File(System.getProperty("wolfskin.captureDirectory"), "hero-actions").apply { mkdirs() } + val current = mutableStateOf(HomeRow("hero", HomeRowKind.Hero, cards = listOf(title), heroKind = "resume", watchReady = false)) + rule.setContent { WolfskinTheme { + Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { + HomeHero(current.value, false, 0, emptyList(), {}, {}) + } + } } + for ((kind, item) in listOf("resume" to episode, "new" to title, "next" to episode)) { + rule.runOnIdle { current.value = current.value.copy(cards = listOf(item), heroKind = kind, watchReady = false) } + rule.mainClock.advanceTimeBy(1000) + val before = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot + val primary = (if (kind == "resume") "Resume" else "Play") + if (item.isEpisode) " ${item.episodeLabel}" else "" + rule.onNodeWithText(primary).assertIsNotDisplayed() + rule.onRoot().captureRoboImage(File(directory, "$kind-pending.png").absolutePath) + rule.runOnIdle { current.value = current.value.copy(watchReady = true) } + rule.mainClock.advanceTimeBy(400) + val after = rule.onNodeWithText("Details").fetchSemanticsNode().boundsInRoot + rule.onRoot().captureRoboImage(File(directory, "$kind-ready.png").absolutePath) + assertEquals("Late $primary must not move Details", before, after) + } + } +}