Reserve Home hero primary action space
This commit is contained in:
parent
7c67596cb6
commit
f375d88030
3 changed files with 73 additions and 4 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HomeRati
|
|||
Text(copy.overview, color = C.muted, style = T.body, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.width(L.heroOverviewMax).exactLineBox(T.body))
|
||||
Spacer(Modifier.height(H.overviewBottom))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
|
||||
val detailsOnly = row.heroKind == "pick" || !row.watchReady
|
||||
val primary = if (detailsOnly) "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && column == 0, true, if (detailsOnly) "about" else "playback", onFocus = { onHover(0) }, textStyle = T.button) { onButton(0) }
|
||||
if (!detailsOnly) PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, textStyle = T.button) { onButton(1) }
|
||||
val detailsOnly = row.heroKind == "pick"
|
||||
if (!detailsOnly) Layout(
|
||||
modifier = if (row.watchReady) Modifier.lateLineFade(title.id) else Modifier,
|
||||
content = {
|
||||
val primary = (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
|
||||
PickerButton(primary, focused && row.watchReady && column == 0, true, "playback",
|
||||
onFocus = { if (row.watchReady) onHover(0) }, textStyle = T.button) { if (row.watchReady) onButton(0) }
|
||||
},
|
||||
) { measurables, constraints ->
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue