Round 2 release candidate: Home, Discover, trailer, crash fix and stock screens #64
22 changed files with 515 additions and 92 deletions
|
|
@ -40,6 +40,7 @@
|
|||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ class PersonPickerSessionTest {
|
|||
private fun awaitLoaded(user: JellyfinUser) {
|
||||
try {
|
||||
rule.waitUntil(10_000) {
|
||||
// Setup navigation writes Compose state asynchronously; publish it before pumping replacement composition.
|
||||
androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications()
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle()
|
||||
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
|
||||
models.last().conductarr.identity.value?.signedIn == true &&
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class TitleCaptureTest {
|
|||
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
|
||||
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
|
||||
data.episodes.forEach { item ->
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
|
||||
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Title.episodeCardWidth.value * 2).toInt(), (WolfskinTokens.Title.episodeCardHeight.value * 2).toInt())) }
|
||||
}
|
||||
}
|
||||
return requests.distinctBy { it.first }.associate { (path, width, height) ->
|
||||
|
|
@ -170,7 +170,10 @@ class TitleCaptureTest {
|
|||
@Test fun st044_versions() = capture(44) { state, shell -> open(state, shell, "version") }
|
||||
@Test fun st045_rememberedVersion() = capture(45) { state, _ -> state.versionId = "v2"; option(state, "version") }
|
||||
@Test fun st046_tracks() = capture(46) { state, shell -> open(state, shell, "tracks") }
|
||||
@Test fun st047_mediaInfo() = capture(47) { state, shell -> open(state, shell, "more") }
|
||||
@Test fun st047_mediaInfo() = capture(47) { state, shell ->
|
||||
open(state, shell, "menu")
|
||||
state.clickSheet(0)
|
||||
}
|
||||
@Test fun st048_trailerNoApp() = capture(48) { state, _ -> option(state, "trailer"); state.toast = "No YouTube app is available to play this trailer." }
|
||||
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
|
||||
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
|
||||
|
|
@ -216,4 +219,10 @@ class TitleCaptureTest {
|
|||
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
|
||||
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
|
||||
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
|
||||
@Test fun round2Menu() = capture(120) { state, shell -> open(state, shell, "menu") }
|
||||
@Test fun round2MussoliniResume() = capture(121, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) })
|
||||
@Test fun round2Episodes() = capture(122, "episode-episode-1-2025-s1e1", "shared", transform = { it.copy(episodes = it.episodes.distinctBy { episode -> episode.id }) }) { state, _ -> state.focus("episodes", 0) }
|
||||
@Test fun round2NotHeldSeries() = capture(123, "90 Day Fiancé: Before the 90 Days")
|
||||
@Test fun round2HeldPosterFallback() = capture(124, "The Girl Who Leapt Through Time", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||
@Test fun round2NotHeldShortOverview() = capture(125, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(backdrop = null, isPosterBackdrop = true, overview = "Short overview.")) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ import androidx.compose.ui.test.onNodeWithText
|
|||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.performSemanticsAction
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
|
@ -36,7 +33,6 @@ import org.justinvisser.wolfskin.design.TitleSurface
|
|||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
|
|
@ -284,15 +280,6 @@ class TitleKeysTest {
|
|||
press(Key.DirectionDown)
|
||||
assertEquals(expected, state.row)
|
||||
}
|
||||
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
|
||||
render("Andor")
|
||||
val layouts = mutableListOf<TextLayoutResult>()
|
||||
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
|
||||
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
|
||||
val layout = layouts.single()
|
||||
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
|
||||
assertEquals(1, layout.lineCount)
|
||||
}
|
||||
|
||||
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
|
||||
render(transform = { it.copy(status = TitleReadStatus.Loading) })
|
||||
|
|
@ -315,26 +302,6 @@ class TitleKeysTest {
|
|||
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
|
||||
}
|
||||
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() {
|
||||
render()
|
||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) }
|
||||
press(Key.DirectionCenter)
|
||||
val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2
|
||||
assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f)
|
||||
val lastSubtitle = rule.onNodeWithText("Dutch", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
assertEquals("Fourteen stacked lines do not accumulate native pixel rounding", 526.2f, lastSubtitle.top - container.top, 1f)
|
||||
}
|
||||
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Test fun filmHeaderKeepsItsFixedCssStack() {
|
||||
render()
|
||||
assertEquals(358f, rule.onNodeWithTag("title-facts").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||
assertEquals(410f, rule.onNodeWithTag("title-overview").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
|
||||
}
|
||||
|
||||
|
||||
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.io.File
|
||||
|
||||
/** Native title interactions keep secondary actions, focus and section geometry intact. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class TitleRound2Test {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val effects = mutableListOf<TitleAction>()
|
||||
private lateinit var state: TitleState
|
||||
private lateinit var shell: ShellState
|
||||
|
||||
private fun fixture() = TitleFixture(rule.activity.assets.open("wolfskin-titles.json").bufferedReader().use { JSONObject(it.readText()) })
|
||||
private fun render(title: String = "Locke", person: String = "justin", transform: (TitlePageData) -> TitlePageData = { it }) {
|
||||
val fixture = fixture()
|
||||
shell = ShellState(fixture.people, person).apply { pickerVisible = false; pickerLaunch = false; enterDestination("title/test") }
|
||||
state = TitleState(transform(fixture.title(title, person)), shell.person, effects::add)
|
||||
rule.setContent { WolfskinTheme {
|
||||
BackHandler { state.back(shell) }
|
||||
TitleSurface(state, shell, Modifier.requiredSize(960.dp, 540.dp))
|
||||
} }
|
||||
}
|
||||
private fun press(key: Key) = rule.onRoot().performKeyInput { pressKey(key) }
|
||||
private fun menu() {
|
||||
rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||
press(Key.DirectionCenter)
|
||||
rule.mainClock.advanceTimeBy(300)
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun menuBackRestoresItsButtonAndKeepsMediaInfoAndRequestReachable() {
|
||||
render()
|
||||
menu()
|
||||
val opener = state.column
|
||||
press(Key.Back)
|
||||
assertNull(state.sheet)
|
||||
assertEquals(opener, state.column)
|
||||
assertFalse(shell.navFocused)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
press(Key.DirectionDown)
|
||||
press(Key.DirectionCenter)
|
||||
assertNull(state.sheet)
|
||||
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||
assertEquals(opener, state.column)
|
||||
}
|
||||
|
||||
@Test fun notHeldMovieKeepsRequestAndHideOutsideTheMenu() {
|
||||
render("The Amazing Spider-Man")
|
||||
rule.onNodeWithTag("title-option-request").assertIsDisplayed()
|
||||
rule.onNodeWithTag("title-option-hide").assertIsDisplayed()
|
||||
menu()
|
||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleAction.Request(state.data.item, true), effects.single())
|
||||
}
|
||||
|
||||
@Test fun movieCastStacksAfterActionsInsteadOfAReservedHeaderHeight() {
|
||||
render("The Amazing Spider-Man")
|
||||
val actions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
val cast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||
val gap = cast.top - actions.bottom
|
||||
assertTrue("Cast follows the actions without the old fixed header reservation", gap > 0 && cast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||
rule.runOnIdle { state.update(state.data.copy(requestedStage = "Searching", requestActor = "Justin")) }
|
||||
val requestedActions = rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot
|
||||
val requestedCast = rule.onNodeWithTag("title-cast").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue(requestedActions.bottom < requestedCast.top)
|
||||
assertTrue("A readiness line cannot move Cast backwards or recreate the old reservation", requestedCast.top >= cast.top && requestedCast.top < WolfskinTokens.Layout.row_dhead.value * 2)
|
||||
}
|
||||
|
||||
@Test fun mussoliniResumeHasOnlyFactsRatingsAndNoRedundantSeasonsAction() {
|
||||
render("episode-episode-1-2025-s1e1", "shared") { data -> data.copy(episodes = data.episodes.distinctBy { it.id }) }
|
||||
assertTrue(state.data.playTarget!!.resumeSeconds > 0)
|
||||
rule.onNodeWithTag("title-progress").assertIsDisplayed()
|
||||
rule.onAllNodesWithContentDescription("rtCritics", useUnmergedTree = true).assertCountEquals(1)
|
||||
rule.onNodeWithText("Ep", useUnmergedTree = true).assertDoesNotExist()
|
||||
rule.onNodeWithTag("title-option-seasons").assertDoesNotExist()
|
||||
press(Key.DirectionDown)
|
||||
assertEquals("seasons", state.row)
|
||||
rule.onNode(hasText("Season 1") and hasClickAction()).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun seriesKeepsItsSeasonsShortcutAndSecondaryRequestUsesTheMenu() {
|
||||
render("Andor")
|
||||
rule.onNodeWithTag("title-option-seasons").assertIsDisplayed()
|
||||
menu()
|
||||
rule.onNodeWithText("Add for someone").assertIsDisplayed()
|
||||
press(Key.Back)
|
||||
press(Key.DirectionRight)
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals("seasons", state.row)
|
||||
}
|
||||
|
||||
@Test fun episodeSkeletonAndReadySlotsMatchWhileMetadataFitsOneLine() {
|
||||
render("Andor") { it.copy(episodesStatus = TitleReadStatus.Loading) }
|
||||
rule.runOnIdle { shell.view.listState.requestScrollToItem(2) }
|
||||
rule.waitForIdle()
|
||||
val skeleton = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||
rule.runOnIdle { state.update(state.data.copy(episodesStatus = TitleReadStatus.Ready)) }
|
||||
val ready = rule.onNodeWithTag("title-episode-slot-0").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(skeleton, ready)
|
||||
assertEquals(448f, ready.width, 0.1f)
|
||||
assertEquals("Wider cards retain the approved taller episode art", 280f, ready.height, 0.1f)
|
||||
rule.runOnIdle { state.focus("episodes", 0) }
|
||||
val episode = state.data.episodes.first()
|
||||
rule.onNodeWithText(episode.episodeLabel + " · " + episode.runtime).assertIsDisplayed()
|
||||
rule.onNode(hasText(episode.seriesName!!) and hasAnyAncestor(hasTestTag("title-episode-slot-0")), useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun revokedRequestsDisappearFromMenuWithoutRemovingMediaInfo() {
|
||||
render()
|
||||
menu()
|
||||
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||
rule.onNodeWithText("Add for someone").assertDoesNotExist()
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.MediaInfo, state.sheet)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun shortOverviewMoviesKeepCastBelowThePosterForHeldAndNotAddedTitles() {
|
||||
render("The Amazing Spider-Man") { it.copy(item = it.item.copy(overview = "Short overview.", backdrop = null, isPosterBackdrop = true)) }
|
||||
for (held in listOf(false, true)) {
|
||||
rule.runOnIdle { state.update(state.data.copy(item = state.data.item.copy(held = held))) }
|
||||
val poster = rule.onNodeWithTag("title-poster-backdrop").fetchSemanticsNode().boundsInRoot
|
||||
val firstActor = rule.onNodeWithTag("title-cast-${state.data.cast.first().id}").fetchSemanticsNode().boundsInRoot
|
||||
assertTrue("Cast clears the poster on held=$held: $firstActor vs $poster", firstActor.top >= poster.bottom + 20f - 0.5f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun revokingTheOnlyMenuActionClosesAnOtherwiseEmptyMenu() {
|
||||
render("The Amazing Spider-Man")
|
||||
menu()
|
||||
rule.runOnIdle { state.update(state.data.copy(requestsAllowed = false)) }
|
||||
assertNull(state.sheet)
|
||||
rule.mainClock.advanceTimeBy(300)
|
||||
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test fun mediaInfoKeepsTableTextSeparatedInsteadOfAccumulatingOverlap() {
|
||||
render()
|
||||
menu()
|
||||
press(Key.DirectionCenter)
|
||||
rule.onNodeWithText("Container", useUnmergedTree = true).assertIsDisplayed()
|
||||
rule.onNodeWithText("Dutch", useUnmergedTree = true).assertIsDisplayed()
|
||||
val text = rule.onAllNodes(SemanticsMatcher.keyIsDefined(androidx.compose.ui.semantics.SemanticsActions.GetTextLayoutResult) and hasAnyAncestor(hasTestTag("title-sheet")), useUnmergedTree = true)
|
||||
.fetchSemanticsNodes().map { it.boundsInRoot }.filter { it.width > 0 && it.height > 0 }
|
||||
text.forEachIndexed { index, first -> text.drop(index + 1).forEach { second ->
|
||||
if (minOf(first.right, second.right) > maxOf(first.left, second.left)) {
|
||||
assertTrue("Media info text must never collide vertically: $first vs $second", minOf(first.bottom, second.bottom) <= maxOf(first.top, second.top))
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
@Test fun menuToMediaInfoNeverDoubleDimsOrPopsAndBackKeepsOneScrim() {
|
||||
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||
menu()
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-info-motion").apply { mkdirs() }
|
||||
rule.runOnIdle { state.clickSheet(0) }
|
||||
var waited = 0
|
||||
while (rule.onAllNodesWithTag("title-sheet", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
waited += 16
|
||||
}
|
||||
val records = mutableListOf<String>()
|
||||
fun frame(phase: String, index: Int): Pair<Int, Int> {
|
||||
val path = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(path.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(path.absolutePath)
|
||||
fun brightness(x: Int, y: Int): Int {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
return android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
}
|
||||
return try {
|
||||
(brightness(1900, 900) to brightness(100, 900)).also { records.add("$phase,${index * 16},${it.first},${it.second}") }
|
||||
} finally { bitmap.recycle() }
|
||||
}
|
||||
val info = List(14) { index ->
|
||||
rule.waitForIdle()
|
||||
frame("info", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||
}
|
||||
info.forEach { assertEquals("No extra scrim or disappearing overlay may darken or pop over Media info", info.last(), it) }
|
||||
rule.runOnIdle { state.back(shell) }
|
||||
repeat(14) { index ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
rule.waitForIdle()
|
||||
assertEquals("Returning to Menu keeps exactly one scrim", info.last().second, frame("back", index).second)
|
||||
}
|
||||
rule.onNodeWithTag("title-menu", useUnmergedTree = true).assertExists()
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,sheetBrightness,leftBrightness\n" + records.joinToString("\n"))
|
||||
rule.mainClock.autoAdvance = true
|
||||
}
|
||||
|
||||
@Test fun menuFadeFramesStayMonotonicAndNeverTranslate() {
|
||||
render { it.copy(item = it.item.copy(backdrop = null, poster = null), cast = emptyList(), related = emptyList()) }
|
||||
rule.mainClock.autoAdvance = false
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "title-menu-motion").apply { mkdirs() }
|
||||
rule.runOnIdle { state.click("header", state.options.indexOfFirst { it.id == "menu" }) }
|
||||
var waited = 0
|
||||
while (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isEmpty() && waited <= WolfskinTokens.Motion.base + 100) {
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
rule.waitForIdle()
|
||||
waited += 16
|
||||
}
|
||||
val bounds = rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val samples = mutableListOf<String>()
|
||||
fun frame(phase: String, index: Int): Int {
|
||||
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val image = BitmapFactory.decodeFile(file.absolutePath)
|
||||
val brightness = try {
|
||||
val pixel = image.getPixel((bounds.left + 40).toInt(), (bounds.top + 6).toInt())
|
||||
android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
|
||||
} finally { image.recycle() }
|
||||
samples.add("$phase,${index * 16},$brightness,${bounds.left},${bounds.top}")
|
||||
return brightness
|
||||
}
|
||||
fun assertMonotonic(values: List<Int>) {
|
||||
assertNotEquals("Fade must visibly change the menu pixel", values.first(), values.last())
|
||||
val direction = if (values.last() > values.first()) 1 else -1
|
||||
values.zipWithNext().forEach { (before, after) ->
|
||||
assertTrue("Menu alpha must not flicker or snap back: $values", (after - before) * direction >= -1)
|
||||
}
|
||||
}
|
||||
val entering = List(14) { index ->
|
||||
rule.waitForIdle()
|
||||
assertEquals(bounds, rule.onNodeWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot)
|
||||
frame("open", index).also { rule.mainClock.advanceTimeBy(16); rule.waitForIdle() }
|
||||
}
|
||||
assertMonotonic(entering)
|
||||
rule.runOnIdle { state.back(shell) }
|
||||
val leaving = mutableListOf<Int>()
|
||||
repeat(9) { index ->
|
||||
rule.waitForIdle()
|
||||
if (rule.onAllNodesWithTag("title-menu", useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty()) leaving.add(frame("close", index))
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
}
|
||||
assertMonotonic(leaving)
|
||||
rule.onNodeWithTag("title-menu").assertDoesNotExist()
|
||||
File(directory, "samples.csv").writeText("phase,timeMs,brightness,leftPx,topPx\n" + samples.joinToString("\n"))
|
||||
rule.mainClock.autoAdvance = true
|
||||
press(Key.DirectionCenter)
|
||||
assertEquals(TitleSheet.Menu, state.sheet)
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,22 @@ Upstream enables JVM unit tests only for debug variants; there is no
|
|||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||||
task without changing upstream's test configuration.
|
||||
|
||||
### Full-suite test diagnostics
|
||||
|
||||
The Gradle daemon heap (`org.gradle.jvmargs` in `gradle.properties`) is separate
|
||||
from the forked test-worker heap. The default test worker uses 512 MiB; changing
|
||||
the daemon's `-Xmx` does not increase it. A larger worker heap did not resolve
|
||||
the person-picker fixture's missing Compose snapshot notifications.
|
||||
|
||||
Gradle 9.8.2 runs previously failed test classes first on the next invocation.
|
||||
An immediate green `make test` rerun can therefore hide an order-sensitive
|
||||
failure rather than prove a fix. For comparisons, clear only generated binary
|
||||
test-result history (`build/test-results/<task>/binary` in each module) before
|
||||
each run and record actual class-start order; XML file listing order is not
|
||||
execution order. Keep worker heap and parallelism unchanged between samples.
|
||||
`PersonPickerSessionTest` must also pass late in the full app JVM, not just alone
|
||||
or promoted to the front by previous failures.
|
||||
|
||||
## Saved-person launch and DNS
|
||||
|
||||
Launch restores an unprotected saved Jellyfin login from local storage before
|
||||
|
|
@ -627,18 +643,21 @@ retain the approved frames except for the requested feedback changes:
|
|||
that may still be cancelled; first mount restores the intended shelf item.
|
||||
Discover uses all six approved poster slots. Person keeps the final column
|
||||
at full poster width.
|
||||
- Episode images choose episode Primary, then Thumb, then the already-read
|
||||
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
|
||||
Episode context uses the episode overview, with two summary lines on the
|
||||
focused card. Held-series headers measure their copy/actions and leave 40 px
|
||||
before seasons instead of reserving a fixed empty 900 px header.
|
||||
- Episode images choose episode Primary, then Thumb, then the already-read series Backdrop.
|
||||
Title episode cards now use 448×280 px, preserving the taller approved crop.
|
||||
Focused summaries ellipsise to one line, the show-name overlay is absent, and
|
||||
`S1 E3 · duration` plus the watched check share one line.
|
||||
Ready title headers measure their copy/actions and leave 40 px before the
|
||||
following section; series retain the empty 56 px readiness reservation.
|
||||
Poster-only movies floor that stack so Cast circles clear the poster by at
|
||||
least 20 px, including held movie fallbacks and short overviews.
|
||||
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
|
||||
gap. Home heading geometry is unchanged.
|
||||
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
|
||||
420×630. The shared request builder replaces conflicting Jellyfin size/encoding
|
||||
parameters: cards use WebP quality 80, while hero backdrops/sheets retain JPEG
|
||||
quality 96. Poster/tile/wide/episode requests are 256×384, 400×225, 544×306 and
|
||||
400×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||
448×280; Jellyfin fill preserves aspect ratio before the UI crops. Tags and
|
||||
authentication query parameters remain intact. TMDB uses w342 for posters and
|
||||
portraits, w780 for landscape cards and w1280 for hero backdrops, never original.
|
||||
Lossy WebP decoding is guaranteed below minSdk 23; API 28+ additionally checks
|
||||
|
|
@ -690,6 +709,26 @@ pop a pushed title/person from content **or** nav. Initial loading has no conten
|
|||
target: Down stays in nav, while Back can still leave the title. A missing rating
|
||||
set emits no layout node, so it cannot reserve an invisible extra facts gap.
|
||||
|
||||
The title Menu replaces the old More/media-info icon action and contains Media
|
||||
info plus Add for someone when requests are permitted. It is shared by held
|
||||
movie, series and episode pages; non-held pages expose Add for someone in the
|
||||
same menu while Request/status and Not interested/Show again stay in the row.
|
||||
Version, tracks, watched and trailer controls keep their existing positions.
|
||||
Back from Media info returns to the menu; Back from the menu restores its
|
||||
opening button. The compact menu fades in for 160 ms and out for 100 ms using
|
||||
the motion ease-out token, with reduced-motion fades capped at 80 ms. Only the
|
||||
bounded menu panel owns an alpha layer; the scrim is static. Opening another
|
||||
sheet immediately removes the menu panel and scrim, preventing double darkening.
|
||||
An empty menu closes if its last permitted action disappears. Controls
|
||||
use the shared 4 px straddle focus ring. Episode details omit the redundant
|
||||
Seasons shortcut; series pages retain it. Ratings appear only in the header
|
||||
facts line, not again beside progress, on movie, series and episode pages.
|
||||
|
||||
Episode shelf skeletons use the same width, height and pitch as ready cards.
|
||||
Person filmography and related-title shelves remain posters; Home continuation
|
||||
tiles are a separate component and retain their series-name context.
|
||||
|
||||
|
||||
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
|
||||
person credits and playback mutations to one boundary. `ConductarrTitlePages`
|
||||
adds real detail, cast, related titles, performer/filmography, readiness/receipts
|
||||
|
|
@ -714,7 +753,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.
|
||||
|
|
@ -998,12 +1040,30 @@ Keep and back up this file across clean builds, disposable worktrees, container
|
|||
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
||||
are public; this key is for local debug/optimized builds, not release signing.
|
||||
|
||||
**One-time reinstall:** the first build with the stable key still needs one
|
||||
uninstall+reinstall on the box because the signer changes once; later ones
|
||||
install in place with `adb install -r`, retaining sign-ins and settings.
|
||||
The initial uninstall removes app data, so sign in again once after that switch.
|
||||
Deleting/replacing the persistent key or choosing a different override changes
|
||||
the signer again and requires another reinstall.
|
||||
### Preserve household TV data
|
||||
|
||||
Install and update the household TV app only in place with `adb install -r`,
|
||||
using the same package and persistent debug signer. Its certificate SHA-256 is:
|
||||
|
||||
```text
|
||||
182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d
|
||||
```
|
||||
|
||||
Never run `adb uninstall`, `pm uninstall` or `pm clear`, or switch signers on
|
||||
the household box: clearing/removing the app deletes saved people, sign-ins
|
||||
and settings, and a different signer cannot update the existing app in place.
|
||||
Do not delete or replace the persistent key. If an uninstall is unavoidable,
|
||||
use `adb uninstall -k <package>` to keep app data, then reinstall the same
|
||||
package with the same signer.
|
||||
|
||||
The manifest sets `android:hasFragileUserData="true"` so Android 10+ can offer
|
||||
a **keep app data** choice during interactive uninstall. Choose it when
|
||||
available; older Android versions ignore the attribute. This is a retention
|
||||
prompt, not an automatic backup or a guarantee against data clearing.
|
||||
|
||||
For an in-place update, compare `dumpsys package <pkg>` before and after:
|
||||
`firstInstallTime` must remain unchanged. The guarded installer that checks
|
||||
the signer and this timestamp lives in the operator's tooling, not this repo.
|
||||
|
||||
|
||||
## Release credentials
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -113,7 +114,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))
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||
|
||||
/** A compact modal keeps secondary actions reachable without growing the header row. */
|
||||
@Composable
|
||||
internal fun TitleMenu(state: TitleState, shell: ShellState) {
|
||||
val visible = state.sheet == TitleSheet.Menu && !shell.pickerVisible
|
||||
val focus = remember { FocusRequester() }
|
||||
val reduced = reducedMotion()
|
||||
val opacity = remember { Animatable(0f) }
|
||||
LaunchedEffect(visible, reduced) {
|
||||
if (visible) focus.requestFocus()
|
||||
opacity.animateTo(if (visible) 1f else 0f,
|
||||
tween(motionDuration(if (visible) WolfskinTokens.Motion.base else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut))
|
||||
}
|
||||
if ((visible || opacity.value > 0f) && (state.sheet == null || state.sheet == TitleSheet.Menu)) {
|
||||
Box(Modifier.fillMaxSize().background(C.sheetScrim)
|
||||
.clickable { if (visible) state.back(shell) }
|
||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||
val key = readShellKey(event)
|
||||
visible && key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||
}.focusable()) {
|
||||
Column(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, D.heroSheetTop).width(D.actionMenuWidth)
|
||||
.graphicsLayer { alpha = opacity.value }
|
||||
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||
.padding(D.receiptSide).testTag("title-menu"), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
|
||||
Text("Menu", color = C.fg, style = WolfskinTokens.Type.rowHeading)
|
||||
state.menuOptions.forEachIndexed { index, option ->
|
||||
val focused = visible && state.sheetIndex == index
|
||||
TitleControl(option, focused,
|
||||
Modifier.fillMaxWidth(),
|
||||
onFocus = { if (visible) state.sheetIndex = index }, overArtwork = false) {
|
||||
if (visible) state.clickSheet(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
|
|||
var wheelPending by remember { mutableStateOf(false) }
|
||||
val focus = remember { FocusRequester() }
|
||||
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
|
||||
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
|
||||
state.data.item.kind == "tv" && state.data.item.seasons > 0
|
||||
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
|
||||
val compactHeader = state.data.status == TitleReadStatus.Ready
|
||||
val item = state.data.item
|
||||
val posterFloor = if (item.kind == "movie" && item.poster != null && (item.backdrop == null || item.isPosterBackdrop))
|
||||
D.heroSheetTop + L.heroSheetHeight + D.posterCastGap - L.rowHeadingBand else WolfskinTokens.Shell.zero
|
||||
val headerHeight = if (compactHeader) measuredHeaderHeight.coerceAtLeast(posterFloor) else L.row_dhead
|
||||
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
|
||||
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
|
||||
}
|
||||
|
|
@ -154,7 +157,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,13 @@ class TitleState(
|
|||
var sheet by mutableStateOf<TitleSheet?>(null)
|
||||
private set
|
||||
var sheetIndex by mutableStateOf(0)
|
||||
private var mediaInfoFromMenu = false
|
||||
val menuOptions: List<TitleOption> by derivedStateOf { buildList {
|
||||
if (target != null) add(TitleOption("info", "Media info", "", icon = false))
|
||||
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) {
|
||||
add(TitleOption("add", "Add for someone", "users", icon = false))
|
||||
}
|
||||
} }
|
||||
var season by mutableStateOf(initial.selectedSeason ?: initial.playTarget?.season?.takeIf { it in initial.seasonNumbers } ?: initial.seasonNumbers.firstOrNull() ?: 1)
|
||||
var versionId by mutableStateOf(initial.selectedVersion)
|
||||
var audioIndex by mutableStateOf(initial.selectedAudio)
|
||||
|
|
@ -65,6 +72,7 @@ class TitleState(
|
|||
if (!pendingSubtitle) subtitleIndex = value.selectedSubtitle
|
||||
if (row !in rows) { row = "header"; column = 0 }
|
||||
column = column.coerceAtMost(rowSize(row).minus(1).coerceAtLeast(0))
|
||||
if (sheet == TitleSheet.Menu && menuOptions.isEmpty()) sheet = null
|
||||
}
|
||||
|
||||
/** Pointer focus and D-pad use the same row memory. */
|
||||
|
|
@ -140,7 +148,7 @@ class TitleState(
|
|||
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||
fun back(shell: ShellState) {
|
||||
when {
|
||||
sheet != null -> sheet = null
|
||||
sheet != null -> closeSheet()
|
||||
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
|
||||
else -> onAction(TitleAction.Pop)
|
||||
}
|
||||
|
|
@ -182,13 +190,13 @@ class TitleState(
|
|||
"play", "restart" -> if (data.watchReady) target?.let { onAction(TitleAction.Play(it, id == "restart")) }
|
||||
"version" -> { sheetIndex = data.versions.indexOf(version).coerceAtLeast(0); sheet = TitleSheet.Version }
|
||||
"tracks" -> { sheetIndex = 0; sheet = TitleSheet.Tracks }
|
||||
"more" -> { sheetIndex = 0; sheet = TitleSheet.MediaInfo }
|
||||
"menu" -> { sheetIndex = 0; sheet = TitleSheet.Menu }
|
||||
"watched" -> if (data.watchTarget.kind == "tv") { sheetIndex = 0; sheet = TitleSheet.ConfirmWatched } else toggleWatched()
|
||||
"trailer" -> data.trailerUrl?.let { onAction(TitleAction.Trailer(it)) }
|
||||
"seasons" -> focus("seasons", data.seasonNumbers.indexOf(season).coerceAtLeast(0))
|
||||
"retry" -> onAction(TitleAction.Retry)
|
||||
"back" -> onAction(TitleAction.Pop)
|
||||
"request", "other", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||
"request", "add" -> onAction(TitleAction.Request(data.item, id != "request"))
|
||||
"status" -> onAction(TitleAction.Requests(data.item))
|
||||
"hide", "unhide" -> {
|
||||
val hidden = id == "hide"
|
||||
|
|
@ -205,9 +213,10 @@ class TitleState(
|
|||
}
|
||||
|
||||
private fun sheetKey(key: ShellKey) {
|
||||
if (key == ShellKey.Back) { sheet = null; return }
|
||||
if (key == ShellKey.Back) { closeSheet(); return }
|
||||
val current = version
|
||||
val size = when (sheet) {
|
||||
TitleSheet.Menu -> menuOptions.size
|
||||
TitleSheet.Version -> data.versions.size
|
||||
TitleSheet.Tracks -> (current?.audio?.size ?: 0) + (current?.subtitles?.size ?: 0) + 2
|
||||
TitleSheet.ConfirmWatched, TitleSheet.HiddenReceipt -> 2
|
||||
|
|
@ -217,6 +226,16 @@ class TitleState(
|
|||
ShellKey.Up, ShellKey.Left -> sheetIndex = (sheetIndex - 1).coerceAtLeast(0)
|
||||
ShellKey.Down, ShellKey.Right -> sheetIndex = (sheetIndex + 1).coerceAtMost(size - 1)
|
||||
ShellKey.Ok -> when (sheet) {
|
||||
TitleSheet.Menu -> menuOptions.getOrNull(sheetIndex)?.let { option ->
|
||||
if (option.id == "info") {
|
||||
mediaInfoFromMenu = true
|
||||
sheetIndex = 0
|
||||
sheet = TitleSheet.MediaInfo
|
||||
} else {
|
||||
sheet = null
|
||||
activateOption(option.id)
|
||||
}
|
||||
}
|
||||
TitleSheet.Version -> {
|
||||
val selected = data.versions[sheetIndex]
|
||||
versionId = selected.id
|
||||
|
|
@ -234,11 +253,19 @@ class TitleState(
|
|||
onAction(TitleAction.Hide(data.item, false))
|
||||
} else onAction(TitleAction.Pop)
|
||||
}
|
||||
else -> sheet = null
|
||||
else -> closeSheet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeSheet() {
|
||||
if (sheet == TitleSheet.MediaInfo && mediaInfoFromMenu) {
|
||||
mediaInfoFromMenu = false
|
||||
sheetIndex = 0
|
||||
sheet = TitleSheet.Menu
|
||||
} else sheet = null
|
||||
}
|
||||
|
||||
private fun selectTrack(current: TitleVersion) {
|
||||
when {
|
||||
sheetIndex < current.audio.size -> audioIndex = current.audio[sheetIndex].index
|
||||
|
|
@ -274,14 +301,13 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
|||
val series = data.watchTarget.kind == "tv"
|
||||
add(TitleOption("watched", "Mark ${if (series) "series " else ""}as ${if (state.watched) "unwatched" else "watched"}", if (state.watched) "eye" else "eyeoff", if (data.watchTarget.kind == "episode") data.watchTarget.episodeLabel else "", on = state.watched))
|
||||
if (data.trailerUrl != null) add(TitleOption("trailer", "Play trailer", "trailer"))
|
||||
if (target != null) add(TitleOption("more", "More", "more", "Media info"))
|
||||
if (item.kind == "tv" && item.seasons > 0) add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
||||
if (data.connection == TitleConnection.SignedIn && data.requestsAllowed) add(TitleOption("add", "Add for someone", "users", icon = false))
|
||||
if (target != null || (data.connection == TitleConnection.SignedIn && data.requestsAllowed)) add(TitleOption("menu", "Menu", "more"))
|
||||
if (item.kind == "tv" && item.seasons > 0 && data.watchTarget.kind != "episode") add(TitleOption("seasons", "Seasons", "tv", icon = false))
|
||||
} else if (data.connection != TitleConnection.SignedIn || !data.requestsAllowed) {
|
||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||
} else {
|
||||
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
|
||||
add(TitleOption("other", "For someone else", "users", icon = false))
|
||||
add(TitleOption("menu", "Menu", "more"))
|
||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
|
||||
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -262,7 +262,7 @@ object WolfskinTokens {
|
|||
const val heroRequestHeight = 1080
|
||||
const val heroPosterRequestWidth = 420
|
||||
const val heroPosterRequestHeight = 630
|
||||
const val episodeRequestWidth = 400
|
||||
const val episodeRequestWidth = 448
|
||||
const val episodeRequestHeight = 280
|
||||
const val castRequestWidth = 200
|
||||
const val castRequestHeight = 200
|
||||
|
|
@ -451,8 +451,11 @@ object WolfskinTokens {
|
|||
const val sheetOpacity = 0.5f
|
||||
}
|
||||
object Title {
|
||||
val episodeCardWidth = 224.0.dp
|
||||
val episodeCardHeight = 140.0.dp
|
||||
val actionMenuWidth = 260.0.dp
|
||||
val seriesHeaderGap = 20.0.dp
|
||||
val posterCastGap = 10.0.dp
|
||||
val lineGap = 11.0.dp
|
||||
val lineLift = -2.0.dp
|
||||
val overviewGap = 17.0.dp
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
{
|
||||
"title": {
|
||||
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
|
||||
"episodeCardWidth": {"dp": 224, "px": 448, "source": "tv-round2: episode width +12%"},
|
||||
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: preserve taller episode cards"},
|
||||
"actionMenuWidth": {"dp": 260, "px": 520, "source": "tv-round2: compact title action menu"},
|
||||
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
|
||||
"posterCastGap": {"dp": 10, "px": 20, "source": "tv-round2: minimum poster-to-cast clearance"},
|
||||
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
|
||||
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
|
||||
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},
|
||||
|
|
|
|||
|
|
@ -290,8 +290,11 @@
|
|||
--lift-heroY: 30.0px;
|
||||
--lift-sheetBlur: 80.0px;
|
||||
--lift-sheetX: -30.0px;
|
||||
--title-episodeCardWidth: 448.0px;
|
||||
--title-episodeCardHeight: 280.0px;
|
||||
--title-actionMenuWidth: 520.0px;
|
||||
--title-seriesHeaderGap: 40.0px;
|
||||
--title-posterCastGap: 20.0px;
|
||||
--title-lineGap: 22.0px;
|
||||
--title-lineLift: -4.0px;
|
||||
--title-overviewGap: 34.0px;
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json
|
||||
0d69fccfbfada103268bfc38186205cf5f94daeb345a00a6aec585609e7ddb1e src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
|
||||
a68f336e8b4373e97a7acaeb02383b4a40dc9f7cc6f6e5413341f14fb2d308eb tokens.css
|
||||
b1cf8f8caea9bfacb7994e0ba39e71ffab33452f6f5227a524ba71874b045b4d tokens.json
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue