Compare commits

..

21 commits

Author SHA1 Message Date
6fc10b7af2 Merge pull request 'Build the approved TV Updates settings UI' (#65) from fm/tv-updater-ui into main 2026-11-07 22:04:40 +01:00
9061384ad1 Merge main for the updater UI review
All checks were successful
review/code-claude Code APPROVE: single state store, no implicit install
review/design-claude Design PASS: 18 frame pairs match approved
2026-11-07 21:37:07 +01:00
9bbb4bab25 Integrate persistent updater metadata and failure operations 2026-11-07 21:36:20 +01:00
57cf17aaad Merge pull request 'Add verified household TV updates and release tooling' (#60) from fm/tv-updater-core into main 2026-11-07 21:33:42 +01:00
94a69de25f Integrate the published updater core 2026-11-07 20:02:04 +01:00
83ebf25d9f Keep updater navigation motion bounded 2026-11-07 18:33:36 +01:00
37ad87d058 Match native updater frames and preserve D-pad focus behavior 2026-11-07 17:55:09 +01:00
2b8c729f62 Compile updater fixtures through the existing app test source set 2026-11-07 17:17:20 +01:00
271cee2cea Merge pull request 'Merge upstream Wholphin v1.1.1' (#53) from fm/wolfskin-upstream-1-1-1 into main 2026-11-07 17:05:40 +01:00
ee3f3ae11e Preserve check state after refreshing installed identity 2026-11-07 16:55:14 +01:00
19df50c0f1 Build the approved television Updates screen 2026-11-07 16:27:55 +01:00
7a0cbdf909 Retain candidate metadata across updater states 2026-11-07 16:14:58 +01:00
e189d9318a Merge current main into upstream integration
All checks were successful
review/code-claude Reviewed at 00e8e83b; delta is a clean merge of main (diff vs main still 2 files, +12); full gate green except the known WatchReturnTest flake
2026-11-07 16:02:58 +01:00
19bb587c95 Merge pull request 'Implement option B card metadata (stacked on #51)' (#54) from fm/tv-card-meta-build into main 2026-11-07 16:01:06 +01:00
165a3f5e7d Merge main into the card metadata branch
All checks were successful
review/code-claude Reviews carried: clean merge of main into c2f7ccc4
review/design-claude Reviews carried: clean merge of main into c2f7ccc4
2026-11-07 15:52:30 +01:00
c2f7ccc438 Fix focused card captions and leave loading bands blank
All checks were successful
review/code-claude Blocker fixed on c2f7ccc4, delta reviewed
review/design-claude Blocker fixed on c2f7ccc4, delta reviewed
2026-11-07 03:53:45 +01:00
fe44a3c24f Implement option B card metadata and solid bottom edges
Some checks failed
review/code-claude Blocking: CardCaption focus-only caption invisible after first focus move in a shelf
review/design-claude Design review: 1 blocking (unframed skeleton caption bars); other differences are renderer/fixture artifacts
2026-11-07 03:06:26 +01:00
00e8e83bb6 Document upstream v1.1.1 setup fix
All checks were successful
review/code-claude No blocking findings on 00e8e83b; waits for Justin go
2026-11-07 02:31:05 +01:00
b80b67f736 v1.1.1
-----BEGIN SSH SIGNATURE-----
 U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K
 Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
 AAAAQMNewXnxMn5HlyJcrG3vlqPg4lBjF2q9uyedLQQ2S+G4PZQf1XxPTy4q9DKBcT8UIN
 XSrDmvm0HJci43S6FMTgE=
 -----END SSH SIGNATURE-----

Merge tag 'v1.1.1' into fm/wolfskin-upstream-1-1-1

v1.1.1

# -----BEGIN SSH SIGNATURE-----
# U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgrqffh93Kw9xITbVISXK/jkih9K
# Q1NSQeC6TBqiLCg2oAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5
# AAAAQMNewXnxMn5HlyJcrG3vlqPg4lBjF2q9uyedLQQ2S+G4PZQf1XxPTy4q9DKBcT8UIN
# XSrDmvm0HJci43S6FMTgE=
# -----END SSH SIGNATURE-----
# gpg verification failed.
2026-11-07 02:16:52 +01:00
Damontecres
159edc875d
Release v1.1.1 2026-11-06 15:37:29 -05:00
Damontecres
bd46ff3e03
Fix adding Seerr server if plugin is not installed (#2179)
## Description
If the server plugin is not installed, trying to add Seerr server will
show an endless loading page. This PR fixes that.

### Related issues
Fixes #2177

### Testing
Emulator against server with & without the plugin

## Screenshots
N/A

## AI or LLM usage
None
2026-11-05 17:13:09 -05:00
67 changed files with 1969 additions and 318 deletions

View file

@ -8,6 +8,14 @@ Wolfskin is an Android TV client for one household. It is released through Forge
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
## Wolfskin TV updates
Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button.
Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state.
Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available.
---
# Wholphin - an OSS Android TV client for Jellyfin

View file

@ -52,6 +52,9 @@ class SwitchSeerrViewModel
_state.update { it.copy(loading = LoadingState.Success) }
}
}
} else {
// Nothing to check so set success
_state.update { it.copy(loading = LoadingState.Success) }
}
}

View file

@ -68,11 +68,16 @@ import javax.inject.Singleton
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DeviceModule::class, AppModule::class],
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
)
object TestModule {
val testDispatcher = StandardTestDispatcher()
@Provides
@Singleton
fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController =
org.justinvisser.wolfskin.tv.update.FakeUpdateController()
@Provides
@Singleton
fun deviceInfo(

View file

@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)

View file

@ -69,7 +69,7 @@ class AnimeRootRemovalTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()

View file

@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest {
val client = session.identity.value!!.client
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
requireNotNull(model.onForeground(server, user)).join()
assertNull(libraries.audiences)
@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest {
session.authenticate()
val libraries = LibraryReadFacade(mockk())
val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
val identity = session.identity.value!!
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(

View file

@ -91,7 +91,7 @@ class FirstSignInNavigationTest {
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {

View file

@ -85,7 +85,7 @@ class PersonPickerSessionTest {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -87,7 +87,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()

View file

@ -63,7 +63,7 @@ class SavedPeoplePickerTest {
val session = WolfskinSession()
val navigation = NavigationManager()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true),
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
try {
rule.setContent {
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),

View file

@ -66,7 +66,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
val load = launch { model.load(server, user) }
try {
withTimeout(5_000) { savedPeopleStarted.await() }
@ -121,7 +121,7 @@ class ShellStartupTimingTest {
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)

View file

@ -0,0 +1,108 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.design.ShellKey
/** Behavior boundaries are controller outcomes, not incidental UI wording. */
class UpdateBehaviorTest {
@Test fun everyOutcomeHasOnlyExplicitUserActions() {
val expected = listOf(
UpdateUiState.Idle to listOf(UpdateCommand.Check),
UpdateUiState.Checking to listOf(UpdateCommand.Back),
UpdateUiState.UpToDate to listOf(UpdateCommand.Check),
UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check),
updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes),
UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel),
UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes),
UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel),
UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel),
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
expected.forEach { (state, commands) ->
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
}
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
}
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
val installed = "1.1.1"
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
assertEquals(presentUpdate(UpdateUiState.Checking, null),
presentUpdate(UpdateUiState.Checking, null, installed))
}
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
listOf(null, 0L, -1L).forEach { total ->
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
assertNull(ui.fraction)
}
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
}
@Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() {
val controller = FakeUpdateController(updateReleaseFixture())
val focus = UpdateFocus(controller, {}, {}, {}, {})
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
focus.key(ShellKey.Right)
focus.key(ShellKey.Down)
assertTrue(focus.autoFocused)
assertTrue(controller.autoCheck.value)
focus.key(ShellKey.Ok)
assertFalse(controller.autoCheck.value)
focus.key(ShellKey.Up)
assertFalse(focus.autoFocused)
assertEquals(1, focus.actionIndex)
assertFalse(controller.autoCheck.value)
}
@Test fun notesTrapFocusAndBackRestoresNotesAction() {
val controller = FakeUpdateController(updateReleaseFixture())
var backs = 0
var navs = 0
val steps = mutableListOf<Int>()
val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add)
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
focus.key(ShellKey.Right); focus.key(ShellKey.Ok)
listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key)
assertTrue(focus.notesOpen)
assertEquals(listOf(-1, 1), steps)
assertEquals(0, navs)
assertTrue(controller.autoCheck.value)
focus.key(ShellKey.Back)
assertFalse(focus.notesOpen)
assertEquals(1, focus.actionIndex)
assertEquals(0, backs)
focus.key(ShellKey.Up)
assertEquals(1, navs)
focus.key(ShellKey.Back)
assertEquals(1, backs)
}
@Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() {
val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100))
val focus = UpdateFocus(controller, {}, {}, {}, {})
fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) }
update(controller.state.value)
focus.key(ShellKey.Down)
update(UpdateUiState.Downloading(90, 100))
assertTrue(focus.autoFocused)
update(UpdateUiState.Ready("1.1.1"))
assertFalse(focus.autoFocused)
focus.key(ShellKey.Right)
update(UpdateUiState.PermissionNeeded)
update(UpdateUiState.InstallCancelled)
assertEquals(0, focus.actionIndex)
assertFalse(focus.autoFocused)
}
}
/** Same release facts as the approved television frames. */
fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026",
"Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.")

View file

@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.semantics.SemanticsProperties
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
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.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpdatesKeysTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("updates")
settingsRow = 1
}
private lateinit var controller: FakeUpdateController
private val reduced = mutableStateOf(false)
private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File)
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
}
private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) {
controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture())
if (settings) shell.enterDestination("settings")
rule.setContent {
CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier ->
UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier)
}
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun capture(name: String) {
val target = directory ?: return
target.mkdirs()
rule.waitForIdle()
val file = File(target, "$name.png")
file.parentFile?.mkdirs()
rule.onRoot().captureRoboImage(file.absolutePath)
}
private fun press(keys: List<Key>) {
keys.forEach { key ->
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
rule.mainClock.advanceTimeBy(320)
rule.waitForIdle()
}
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
shell.settingsRow = 0
launch(UpdateUiState.Checking, settings = true)
repeat(9) { press(listOf(Key.DirectionDown)) }
assertEquals(9, shell.settingsRow)
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
capture("classic")
press(listOf(Key.DirectionCenter))
assertEquals("classic", shell.handOff)
}
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
launch(updateReleaseFixture())
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
assertTrue(shell.contentOverlayVisible)
assertFalse(shell.navFocused)
assertTrue(controller.autoCheck.value)
press(listOf(Key.Back))
assertFalse(shell.contentOverlayVisible)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
press(listOf(Key.Back))
assertEquals("settings", shell.route)
assertEquals(1, shell.settingsRow)
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
}
@Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() {
launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" }))
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown))
val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
assertTrue(range.value() > 0f)
assertFalse(shell.navFocused)
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
press(listOf(Key.Back))
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
}
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
launch(updateReleaseFixture())
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
assertTrue(controller.autoCheck.value)
press(listOf(Key.DirectionUp))
assertTrue(shell.navFocused)
press(listOf(Key.DirectionDown))
assertFalse(shell.navFocused)
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
assertTrue(controller.autoCheck.value)
}
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
launch(UpdateUiState.UpToDate)
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
rule.mainClock.advanceTimeBy(320)
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
rule.mainClock.advanceTimeBy(320)
rule.onNodeWithTag("update-progress").assertIsDisplayed()
}
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
launch(UpdateUiState.Checking)
val target = directory
if (target != null) {
repeat(113) { frame ->
rule.mainClock.advanceTimeBy(16)
capture("motion/checking-%03d".format(frame))
}
}
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
rule.mainClock.advanceTimeByFrame()
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
if (target != null) {
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
}
press(listOf(Key.DirectionDown))
rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) }
rule.mainClock.advanceTimeBy(16)
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
rule.runOnIdle { reduced.value = true }
rule.mainClock.advanceTimeBy(96)
capture("motion/reduced-000")
rule.mainClock.advanceTimeBy(1600)
capture("motion/reduced-100")
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
}
}

View file

@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor(
val seeAll: WolfskinSeeAllLists,
val conductarr: ConductarrSession,
artwork: WolfskinArtwork,
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
) : ViewModel() {
init { artwork.configure(context) }
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
@ -195,7 +196,7 @@ class WolfskinShellViewModel @Inject constructor(
is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when {
action.route == "settings" -> navigation.navigateTo(target)
action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target)
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target)
}
@ -262,6 +263,7 @@ fun WolfskinApp(
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) }
LaunchedEffect(identity?.signedIn, identity?.person) {
@ -303,7 +305,13 @@ fun WolfskinApp(
onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) },
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination ->
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
NavEntry(
destination,
contentKey = "${destination}_${server.id}_${user.id}",
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
} else emptyMap(),
) {
// Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current
val discover = LocalDiscoverState.current
@ -329,11 +337,15 @@ fun WolfskinApp(
} else TitleDestination(route, state, preferences)
} else {
ShellSurface(state, handlesContentKeys = upstream == null,
settingsInstalledVersion = installedVersion.versionName,
overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier ->
when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates,
onBack = { state.navFocused = false; navigationManager.goBack() },
onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -8,6 +8,7 @@ interface UpdateController {
val state: StateFlow<UpdateUiState>
val autoCheck: StateFlow<Boolean>
val installedVersion: StateFlow<InstalledVersion>
/** Candidate facts survive download/installer transitions and verified-file restoration. */
val release: StateFlow<UpdateUiState.Available?>
fun check(manual: Boolean)

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.justinvisser.wolfskin.design.ShellContentFocus
import org.justinvisser.wolfskin.design.ShellKey
/** Action focus survives progress emissions; release notes own every key until Back. */
class UpdateFocus(
private val controller: UpdateController,
private val back: () -> Unit,
private val classic: () -> Unit,
private val nav: () -> Unit,
private val scrollNotes: (Int) -> Unit,
) : ShellContentFocus {
override val cardKey: String? = null
var actionIndex by mutableIntStateOf(0)
private set
var autoFocused by mutableStateOf(false)
private set
var notesOpen by mutableStateOf(false)
private set
private var actions = emptyList<UpdateAction>()
private var previousState: Class<*>? = null
/** Only a changed operation may retire an action; progress cannot steal focus. */
fun update(state: UpdateUiState, value: List<UpdateAction>) {
if (previousState != state.javaClass) {
actionIndex = 0
notesOpen = false
if (state == UpdateUiState.InstallCancelled || state is UpdateUiState.Ready) autoFocused = false
}
previousState = state.javaClass
actions = value
actionIndex = actionIndex.coerceAtMost(actions.lastIndex.coerceAtLeast(0))
}
fun focusAction(index: Int) { actionIndex = index; autoFocused = false }
fun activate(index: Int) {
focusAction(index)
when (actions[index].command) {
UpdateCommand.Back -> back()
UpdateCommand.Check -> controller.check(manual = true)
UpdateCommand.Download -> controller.download()
UpdateCommand.Notes -> notesOpen = true
UpdateCommand.Cancel -> controller.cancel()
UpdateCommand.Install -> controller.install()
UpdateCommand.Retry -> controller.retry()
UpdateCommand.Classic -> classic()
}
}
fun toggleAuto() { autoFocused = true; controller.setAutoCheck(!controller.autoCheck.value) }
override fun key(key: ShellKey): Boolean {
if (notesOpen) {
when (key) {
ShellKey.Back -> notesOpen = false
ShellKey.Up -> scrollNotes(-1)
ShellKey.Down -> scrollNotes(1)
else -> Unit
}
return true
}
when (key) {
ShellKey.Back -> back()
ShellKey.Up -> if (autoFocused) autoFocused = false else nav()
ShellKey.Down -> autoFocused = true
ShellKey.Left -> if (!autoFocused) actionIndex = (actionIndex - 1).coerceAtLeast(0)
ShellKey.Right -> if (!autoFocused) actionIndex = (actionIndex + 1).coerceAtMost(actions.lastIndex.coerceAtLeast(0))
ShellKey.Ok -> if (autoFocused) toggleAuto() else if (actions.isNotEmpty()) activate(actionIndex)
}
return true
}
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith
import androidx.navigation3.ui.NavDisplay
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
internal val updatesRouteTransitions =
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import java.text.NumberFormat
/** Screen commands remain explicit; no state transition implicitly installs an APK. */
enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic }
/** An action is a user command, not a focusable progress indicator. */
data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false)
/** Plain copy for the process controller's state; bytes and notes remain real controller data. */
data class UpdatePresentation(
val title: String = "",
val summary: String = "",
val paragraphs: List<String> = emptyList(),
val actions: List<UpdateAction>,
val progress: Boolean = false,
val fraction: Float? = null,
val byteProgress: String? = null,
)
/** Decimal megabytes match release sizes; unknown totals never create a percentage. */
fun formatUpdateBytes(bytes: Long): String = NumberFormat.getNumberInstance().apply {
minimumFractionDigits = 1
maximumFractionDigits = 1
}.format(bytes / 1_000_000.0) + " MB"
/** Metadata uses the controller's locale-formatted release date. */
fun updateMetadata(release: UpdateUiState.Available?): String = release?.let {
"${it.versionName} · ${formatUpdateBytes(it.bytes)} · ${it.dateText}"
}.orEmpty()
/** Every controller outcome is distinct from a successful compatible version comparison. */
fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updatedVersion: String? = null): UpdatePresentation {
val back = UpdateAction("Back", UpdateCommand.Back)
val notes = UpdateAction("Release notes", UpdateCommand.Notes)
val retry = UpdateAction("Retry", UpdateCommand.Retry, true)
val metadata = updateMetadata(release)
return when (state) {
UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation)
?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation)
?: UpdatePresentation("Up to date", "No newer release for this TV.",
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.",
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state),
state.notes.lineSequence().filter { it.isNotBlank() }.take(3).toList(),
listOf(UpdateAction("Download", UpdateCommand.Download, true), notes))
is UpdateUiState.Downloading -> {
val total = state.bytesTotal?.takeIf { it > 0 }
val fraction = total?.let { (state.bytesDone.toDouble() / it).coerceIn(0.0, 1.0).toFloat() }
val bytes = total?.let { "${formatUpdateBytes(state.bytesDone).removeSuffix(" MB")} / ${formatUpdateBytes(it)}" }
?: formatUpdateBytes(state.bytesDone)
UpdatePresentation(fraction?.let { "${(it * 100).toInt()}%" }.orEmpty(), metadata,
actions = listOf(UpdateAction("Cancel download", UpdateCommand.Cancel)), progress = true, fraction = fraction, byteProgress = bytes)
}
is UpdateUiState.Ready -> UpdatePresentation("Ready to install", metadata.ifEmpty { state.versionName },
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Install update", UpdateCommand.Install, true), notes))
UpdateUiState.PermissionNeeded -> UpdatePresentation("Allow updates on this TV", "Enable Wolfskin in Install unknown apps, then return.",
actions = listOf(UpdateAction("Open TV settings", UpdateCommand.Install, true), UpdateAction("Not now", UpdateCommand.Cancel)))
UpdateUiState.HandingOff -> UpdatePresentation("Continue in Android", "Confirm the update on the system screen.",
listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel)))
UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata,
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes))
is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back))
UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.",
actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true)))
UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.",
listOf("The download is kept. Classic settings may offer the same system limitation."),
listOf(retry, UpdateAction("Classic settings (trial)", UpdateCommand.Classic)))
}
}
private fun updatedPresentation(version: String) = UpdatePresentation(
"Updated to $version", "Sign-ins and settings kept.",
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
)
private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String {
if (operation == UpdateOperation.Install) return "Install failed"
return when (kind) {
UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage ->
if (operation == UpdateOperation.Check) "Check failed" else "Download failed"
UpdateFailure.InvalidMetadata ->
if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed"
UpdateFailure.UnsupportedAbi -> "Update unavailable"
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
}
}

View file

@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import org.justinvisser.wolfskin.design.reducedMotion
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
/** Draw-only bounded track: changing bytes never moves logical or Android focus. */
@Composable
fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) {
val reduced = reducedMotion()
val phase = if (reduced) 0f else {
val transition = rememberInfiniteTransition(label = "update-progress")
val animated by transition.animateFloat(0f, 1f,
infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen,
easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight")
animated
}
Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress")
.semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) {
val radius = CornerRadius(U.progressRadius.toPx())
drawRoundRect(C.surface3, cornerRadius = radius)
val width = size.width * (fraction ?: U.scanFraction)
val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase)
clipRect {
drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius)
if (!reduced && fraction != null && width > 0f) {
clipRect(0f, 0f, width, size.height) {
val start = width * (-1f + 2f * phase)
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent),
startX = start, endX = start + width), size = Size(width, size.height))
}
}
}
}
}

View file

@ -0,0 +1,202 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv.update
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import kotlinx.coroutines.launch
import org.justinvisser.wolfskin.design.Avatar
import org.justinvisser.wolfskin.design.CssColumn
import org.justinvisser.wolfskin.design.PickerButton
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.justinvisser.wolfskin.design.exactLineBox
import org.justinvisser.wolfskin.design.focusScale
import org.justinvisser.wolfskin.design.motionDuration
import org.justinvisser.wolfskin.design.reducedMotion
import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor
import org.justinvisser.wolfskin.design.rowReveal
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.UpdateTypography as T
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */
@Composable
fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) {
val state by controller.state.collectAsStateWithLifecycle()
val installed by controller.installedVersion.collectAsStateWithLifecycle()
val release by controller.release.collectAsStateWithLifecycle()
val autoCheck by controller.autoCheck.collectAsStateWithLifecycle()
val initialInstalledCode = remember { installed.versionCode }
val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode }
val presentation = remember(state, release, updatedVersion) {
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate })
}
val scroll = rememberScrollState()
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val reduced = reducedMotion()
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
val focus = remember(controller, shell) {
UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction ->
scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction,
tween(scrollDuration, easing = M.easeOut)) }
}
}
LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) }
LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() }
DisposableEffect(shell, focus) {
shell.contentFocus = focus
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false }
}
LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen }
Box(modifier.fillMaxSize().testTag("updates-screen")) {
Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(shell.person))
Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
}
Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
if (focus.notesOpen) {
NotesPanel(release, scroll)
} else {
UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass)
AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto)
}
}
}
@Composable
private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) {
val shape = RoundedCornerShape(U.radius)
CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity)
.background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) {
if (presentation.title.isNotEmpty()) {
Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg)
Spacer(Modifier.height(U.titleBottom))
}
if (presentation.summary.isNotEmpty()) {
Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom))
}
presentation.paragraphs.forEachIndexed { index, paragraph ->
Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom))
}
if (presentation.progress) {
Spacer(Modifier.height(U.progressTop))
UpdateProgress(presentation.fraction)
presentation.byteProgress?.let {
Spacer(Modifier.height(U.progressBottom))
Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
}
}
Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) {
presentation.actions.forEachIndexed { index, action ->
Box(Modifier.testTag("update-action-${action.command}")) {
PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary,
textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) }
}
}
}
}
}
@Composable
private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow)
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check")
.semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" }
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle)
.padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg)
Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted)
}
Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) {
Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset)
.size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape))
}
}
}
@Composable
private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) {
val shape = RoundedCornerShape(U.radius)
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() }
val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal)
CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
.drawWithCache {
val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f
val radius = U.radius.toPx()
val inset = U.border.toPx() + ring / 2f
onDrawWithContent {
drawContent()
drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring),
CornerRadius(radius + ring / 2f), style = Stroke(ring))
drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2),
CornerRadius(radius - inset), style = Stroke(ring))
}
}
.background(C.surface, shape).border(U.border, C.border, shape)
.verticalScroll(scroll).padding(U.padding + U.border)) {
Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg)
Spacer(Modifier.height(U.notesListTop))
paragraphs.forEachIndexed { index, paragraph ->
Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) {
Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted)
Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted)
}
if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap))
}
Spacer(Modifier.height(U.notesListBottom))
Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
}
}

View file

@ -1,6 +1,6 @@
# Wolfskin TV specification — revision 6
2026-11-06. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`. Revision-5 feedback below still applies. Unchanged static geometry, navigation, typography and colours remain governed by the approved frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
## Who’s watching
@ -83,6 +83,29 @@ Discover key traversal skips pending lanes in both directions. If every remainin
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
## Card metadata and bottom state line — revision 6, option B
Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration.
Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer.
| Geometry at 1920×1080 | Pixels |
|---|---:|
| Library row pitch | 524 |
| Home poster shelf pitch | 632 |
| Home wide / tile shelf pitch | 522 / 441 |
| Initial library art row tops | 372 / 896 |
| Pinned library art row tops / second-row bottom | 168 / 692 / 1076 |
| Focus ring to caption / caption to next focused row ring | 20.48 / 26.48 |
One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them.
Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line.
Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid.
Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers.
## Sources and verification
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.

View file

@ -345,6 +345,15 @@ not the working tree. An uncommitted merge still builds with the old version nam
Neither an upstream merge nor its validation build authorizes a release or TV
installation.
## Upstream v1.1.1 integration
The patch release makes manual Seerr setup available when the optional companion
plugin is absent: setup is marked ready immediately rather than remaining pending.
With the plugin installed, an empty settings response or a failed settings read
still reaches the existing ready-state fallback; plugin URL prefill is unchanged.
There are no new Room migrations (schema remains 41), player, layout, session,
DNS/client, dependency, branding or signing changes relative to v1.1.0.
## Wolfskin shell
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
@ -680,9 +689,9 @@ Down clamps a short final title row before entering the separate Try again / Sho
more row; an empty slot cannot activate recovery. An unavailable library hides
its unknown item count.
Caption separators use 40% of the existing muted colour's alpha. The rail follows
the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px,
overriding the spec table's generic caption style for this component.
Library titles and compact subtitles remain visible at rest, with one-line,
card-width ellipsis rather than a 640 px focused caption. The letter rail uses
bold 24 px letters, with the focused letter bold 28 px.
Library and Settings header initials use the muted colour over the person's fill;
navigation and picker avatars retain their dark initials.
@ -726,12 +735,35 @@ Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
Poster progress uses the independent 18 px title-card inset, not caption spacing:
the track remains 220 px wide on a 256 px poster. Library focus-follow restarts
when the effective column count, focused row or header geometry changes, retaining
deep focus across live snapshots that add or remove the letter rail.
`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged
focused index across the 199↔200-title boundary.
Cards use the approved option-B bottom state line instead of a separate inset
resume bar: watched is 3 px accent; resume is 6 px proportional accent over
`color.progressTrack`; unwatched and Discover have no line. Opaque background
backs the entire strip, including its bottom 2 px and the 2 px separator above
the fill. Artwork is clipped out of the backing band before painting it so
rounded-corner antialiasing cannot leak poster pixels into the lowest row.
The separately drawn focus ring stays above the line.
Film subtitles are year + duration + a 24 px watched check, episode subtitles
are their own duration + check, and series subtitles are year + check with no
single runtime. Resuming cards have no check. The poster caption band remains
96 px; Home poster captions are focus-only. Home landscapes retain titles on
the art and reserve 64 px for focused metadata below it, making wide/tile shelf
pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px.
Loading rows reserve the same blank caption bands and share one pulse layer;
only art-sized blocks are painted, with no title/subtitle placeholder pills.
`CardBottomPixelTest` samples full native bottom rows on four real posters at
rest and individually focused, including white Annie Hall and dark Locke.
`CardGeometryTest` measures 210/327 px caption fitting, card-width titles,
24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks
that every reserved loading-caption pixel remains background-coloured.
`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused
caption changes title directly or while moving across a Home shelf.
The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/)
contains the same illustrative watch-state data as the approved frames and
records renderer/fixture artifacts; unapproved design changes block merge.
`LibrarySpacingRegressionTest` retains the deep focused index across the
199↔200-title letter-rail boundary.
`LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and

View file

@ -35,7 +35,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
}
val checkTokenDrift by tasks.registering {
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
doLast {
file("tokens.sha256").readLines().forEach { line ->
val (expected, name) = line.split(" ", limit = 2)

View file

@ -121,9 +121,10 @@ def main() -> None:
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "update-tokens.json").read_text()))
kotlin, css = render_tokens(tokens)
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"]
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py"]
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))

View file

@ -114,12 +114,6 @@
"dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"captionWidth": {
"$type": "dimension",
"px": 640,
"dp": 320.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"plateTop": {
"$type": "dimension",
"px": 14,
@ -240,18 +234,6 @@
"dp": 30.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressInset": {
"$type": "dimension",
"px": 18,
"dp": 9.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"progressBottom": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289"
},
"endTop": {
"$type": "dimension",
"px": 40,
@ -316,7 +298,6 @@
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" },
"posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" }
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }
}
}

View file

@ -367,9 +367,6 @@
"chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"},
"chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"},
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
"captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"},
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},

View file

@ -0,0 +1,47 @@
DejaVu Sans and DejaVu Sans Bold
https://dejavu-fonts.github.io/
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.

View file

@ -10,18 +10,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.Text
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.constrainHeight
/** Captions rise inside the reserved band without changing measured geometry. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha = remember(title, facts) { Animatable(0f) }
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
@ -32,47 +25,10 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
if (visible) entryProgress.floatValue = value
}
}
val titleStyle = WolfskinTokens.Type.button
val factsStyle = WolfskinTokens.Type.caption
Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) {
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle)
}
}
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
@Composable
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) {
val parts = remember(facts) { facts.split(" · ") }
val muted = WolfskinTokens.Color.muted
val colour = WolfskinTokens.Color.accentText
Layout(
content = {
parts.forEachIndexed { index, part ->
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style))
}
},
modifier = Modifier.fillMaxWidth().clipToBounds(),
) { children, constraints ->
val gap = WolfskinTokens.Library.captionFactsGap.roundToPx()
val basis = IntArray(children.size)
var fixed = gap * (children.size - 1).coerceAtLeast(0)
var flexible = 0
fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast)
children.forEachIndexed { index, child ->
val width = child.maxIntrinsicWidth(constraints.maxHeight)
basis[index] = width
if (shrinks(index)) flexible += width else fixed += width
}
val available = (constraints.maxWidth - fixed).coerceAtLeast(0)
val ratio = if (flexible > available) available.toFloat() / flexible else 1f
val measured = children.mapIndexed { index, child ->
val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index]
child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0))
}
layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) {
var x = 0
measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap }
}
Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */
@Composable
fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean,
modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) {
val transition = if (focusOnly) {
val alpha = remember(title) { Animatable(0f) }
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
val reduced = reducedMotion()
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
LaunchedEffect(alpha, focused, reduced) {
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
if (focused) entryProgress.floatValue = value
}
}
Modifier.graphicsLayer {
this.alpha = alpha.value
translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue)
}
} else Modifier
Column(modifier.then(transition)) {
if (showTitle) {
val style = if (focused || focusOnly) M.titleFocus else M.titleRest
Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style,
modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
CardMetadata(year, duration, watched)
}
}
/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */
@Composable
fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) {
Layout(content = {
if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted)
}, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints ->
val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) }
val gaps = (children.size - 1).coerceAtLeast(0)
val textWidth = sizes.sum()
val gap = when {
textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx()
textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx()
else -> M.minimumGap.roundToPx()
}
val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) }
val height = with(this) { M.subtitle.lineHeight.toPx().toInt() }
layout(constraints.maxWidth, height) {
var left = 0
measured.forEach { child ->
child.placeRelative(left, (height - child.height) / 2)
left += child.width + gap
}
}
}
}

View file

@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }

View file

@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
} else if (lane.pending) {
items(slots, key = { "pending:$it" }) {
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
PosterCaptionSkeleton()
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
@ -80,15 +80,16 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
}
}
}
if (!services && active && !lane.pending) {
if (!services && !lane.pending) {
val item = lane.titles.getOrNull(column)
Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
)
val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW)
if (item != null) {
val card = item.libraryCard(state)
CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true)
} else Caption(
if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
active, captionModifier)
}
}
}
@ -108,7 +109,3 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick)
}
internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String {
val card = title.libraryCard(state)
return (if (hidden) "Hidden from your picks · " else "") + card.facts(true)
}

View file

@ -11,6 +11,9 @@ import androidx.tv.material3.Icon
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"updates" -> R.drawable.wolfskin_update
"settings-list" -> R.drawable.wolfskin_settings_list
"settings-right" -> R.drawable.wolfskin_settings_chevron
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"play", "playback" -> R.drawable.wolfskin_play

View file

@ -38,7 +38,6 @@ data class HomeTitle(
}
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left"
val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}"
val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ")
}
/** Only observed, range-checked scores reach this model; formatting follows the TV locale. */

View file

@ -119,7 +119,7 @@ fun HomeScreen(
private fun rowHeight(row: HomeRow) = when (row.kind) {
HomeRowKind.Hero -> L.row_hero
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter
HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex
@ -36,7 +34,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth
val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
@ -50,39 +48,36 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))
val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList()
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = !row.skeleton && focused && column == index
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index")
.then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (title == null && !row.skeleton) {
if (row.skeleton) {
PosterCaptionSkeleton(width, height, caption)
} else if (title == null) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else {
} else {
val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat()
TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height,
watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) {
TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster)
if (row.cardKind != HomeCardKind.Poster) TileOverlay(title)
if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) }
if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent))
}
HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
}
}
}
}
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
if (!row.skeleton) {
val title = row.cards.getOrNull(column)
val availability = when {
title == null -> null
row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
title.played -> "Watched"
title.resumeSeconds > 0 -> title.remaining
else -> null
}
val facts = remember(title, row.total, availability) {
(title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty()
}
Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis)
val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width)
if (title == null) {
if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier)
} else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year,
title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime },
!row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier,
focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster)
}
}
}
@ -90,9 +85,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
@Composable
private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
Column(Modifier.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)) {
Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
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)) {
Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}

View file

@ -21,19 +21,24 @@ fun personColor(key: String): Color = when (key.lowercase()) {
else -> WolfskinTokens.Color.accent
}
/** Updates has its own locked avatar swatch; other destinations retain household colours. */
fun resolveUpdateAvatarColor(person: ShellPerson): Color =
if (person.key.equals("justin", ignoreCase = true)) WolfskinTokens.Update.avatarJustin else personColor(person.key)
/** The same mark anchors the picker and the persistent navigation. */
@Composable
fun Wordmark(modifier: Modifier = Modifier) {
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
Row(modifier) {
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
}
}
/** Initials are drawn over opaque person colour at the exact slot size. */
@Composable
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) {
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus,
backgroundColor: Color = personColor(person.key)) {
Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = letterColor, style = textStyle)
}
}

View file

@ -18,6 +18,7 @@ interface PosterContent {
val poster: String?
val ratings: List<LibraryRating>
val progress: Float
val posterWatched: Boolean
val badge: String?
}
@ -40,18 +41,15 @@ data class LibraryTitle(
) : PosterContent {
override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie"
override val posterWatched: Boolean get() = played
val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let {
val minutes = (it + 30) / 60
if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m"
} else null
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0
fun facts(catalogue: Boolean): String = buildList {
year?.let { add(it.toString()) }
add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series")
if (catalogue) { if (availability.isNotEmpty()) add(availability) }
else if (played) add("Watched")
else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left")
}.joinToString(" · ")
}
/** Ordered rating entries are formatted after the active TV locale is known. */

View file

@ -25,9 +25,12 @@ fun LibraryPoster(
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
showWatchState: Boolean = !catalogue,
) {
val poster = title.poster
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
val progress = if (showWatchState) title.progress else 0f
TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched,
progress = if (progress >= 1f) 0f else progress) {
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) {
@ -39,13 +42,6 @@ fun LibraryPoster(
onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) {
val held = title.badge == "held"
val color = if (title.badge == "failed") C.fail else C.accent

View file

@ -121,7 +121,7 @@ fun LibraryScreen(
LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
repeat(grid.columns) { PosterCaptionSkeleton() }
}
}
}
@ -139,6 +139,7 @@ fun LibraryScreen(
val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
Box(Modifier.width(K.posterW)) {
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope {
@ -150,15 +151,13 @@ fun LibraryScreen(
}
}
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt)
CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration,
!grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused,
Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW))
}
}
}
}
if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) {
val title = items.getOrNull(grid.index)
if (title != null) Caption(title.title, title.facts(grid.catalogue), true,
Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth),
accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f)
}
}
}
if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
@ -240,7 +239,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) {
CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide))
Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused,
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW))
}
}

View file

@ -46,7 +46,7 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
Column {
Spacer(Modifier.height(heading))
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) }
repeat(K.posterSlots) { PosterCaptionSkeleton() }
}
}
}
@ -55,3 +55,13 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
/** Loading reserves the ready caption band without painting unapproved text placeholders. */
@Composable
internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW,
height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) {
Column(Modifier.width(width)) {
SkeletonBlock(width, height)
Spacer(Modifier.height(band))
}
}

View file

@ -40,15 +40,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
fun navBoxes(tabs: List<String>): List<NavBox> {
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
val updates = WolfskinTokens.Update
var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap
return tabs.map { tab ->
val width = when (tab) {
"home" -> S.navHomeWidth
"discover" -> S.navDiscoverWidth
"movies" -> S.navMoviesWidth
"shows" -> S.navShowsWidth
else -> S.navAnimeWidth
"home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth
"discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth
"movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth
"shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth
else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth
}
NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap }
} + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar)
@ -57,7 +58,8 @@ fun navBoxes(tabs: List<String>): List<NavBox> {
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val hasSelectedTab = state.route != "settings" && state.route != "updates"
val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) }
val reduced = reducedMotion()
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
val index = if (state.navFocused) state.navIndex else state.currentTab
@ -79,14 +81,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) {
if (scrolled) drawRect(C.bg) else drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
}
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf",
if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark)
state.tabs.forEachIndexed { itemIndex, tab ->
val item = boxes[itemIndex]
val focused = state.navFocused && state.navIndex == itemIndex
val labelColor by animateColorAsState(
if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted,
animationSpec = labelFade,
label = "nav-label-$tab",
)
@ -95,8 +98,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
.clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
if (itemIndex == state.currentTab && !focused) {
Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
if (hasSelectedTab && itemIndex == state.currentTab && !focused) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
}
}
@ -111,10 +114,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
.focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) },
backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person))
}
}

View file

@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item ->
val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) }
}
}
}

View file

@ -9,13 +9,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
@ -36,50 +35,53 @@ 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
import org.justinvisser.wolfskin.design.UpdateTypography as T
/** Settings routes identify real upstream destinations rather than stand-in panels. */
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
data class SettingsGroup(val key: String, val title: String, val description: String)
val SETTINGS_GROUPS = listOf(
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
SettingsGroup("updates", "Updates", "Check and install"),
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Protection · Home · Language"),
SettingsGroup("about", "About", "Installed version · Wolfskin about"),
SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"),
SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"),
SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"),
)
/** The count describes returned request history, not an inferred queue of active downloads. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
val list = state.view.listState
val reduced = reducedMotion()
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect
val y = L.row_lhead + L.row_srow * state.settingsRow
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
}
LazyColumn(modifier.fillMaxSize(), state = list,
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
item(key = "settings-header") {
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
Box(modifier.fillMaxSize()) {
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person))
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") requestCount else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop)
.height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)),
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group
SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
}
}
}
}
@ -94,15 +96,14 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
}
}
@ -122,6 +123,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
}
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
Text(label, color = foreground, style = T.caption.copy(fontWeight = T.label.fontWeight))
}
}

View file

@ -260,9 +260,13 @@ class ShellState(
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") openRequests() else {
handOff = group
onAction(ShellAction.HandOff(group))
when (group) {
"requests" -> openRequests()
"updates" -> onAction(ShellAction.Destination("updates"))
else -> {
handOff = group
onAction(ShellAction.HandOff(group))
}
}
}
else contentFocus?.key(ShellKey.Ok)
@ -305,6 +309,7 @@ class ShellState(
gearFocused = true
pickerIndex = people.lastIndex
} else if (route !in tabs) {
if (route == "updates") navFocused = false
onAction(ShellAction.Pop)
} else if (!navFocused) {
navFocused = true

View file

@ -56,6 +56,7 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsRequestCount: Int? = null,
settingsInstalledVersion: String = "",
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
@ -91,7 +92,7 @@ fun ShellSurface(
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())
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
overlay()

View file

@ -9,15 +9,51 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
// Precompose before packing into Color: packing first loses the browser's truncated channel fractions.
private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track ->
val backing = WolfskinTokens.Color.bg
val inverse = 1f - track.alpha
Color(
((track.red * track.alpha + backing.red * inverse) * 255).toInt(),
((track.green * track.alpha + backing.green * inverse) * 255).toInt(),
((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(),
)
}
/** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
@Composable
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f,
content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2)
.drawWithCache {
val edge = WolfskinTokens.CardMetadata.bottom.toPx()
val fill = when {
progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx()
watched -> WolfskinTokens.CardMetadata.watchedLine.toPx()
else -> 0f
}
val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f
onDrawWithContent {
clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() }
drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing))
if (fill > 0f) {
val top = Offset(0f, size.height - edge - fill)
if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill))
drawRect(WolfskinTokens.Color.accent, top,
Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill))
}
}
}, content = content)
}
}

View file

@ -30,6 +30,7 @@ data class TitleItem(
) : PosterContent {
override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie"
override val posterWatched: Boolean get() = watched
override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
override val badge: String? get() = if (held) "held" else null
val episodeLabel: String get() = "S$season E$episode"

View file

@ -4,7 +4,6 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -107,53 +106,36 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
}
}
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
@Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
Column(modifier.zIndex(if (focused) 1f else 0f)) {
if (!tile) {
val bitmaps = LocalTitleBitmaps.current
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
WolfskinTokens.Card.tileW, 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)
}
val progress = item.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, 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())))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}
if (!tile) {
val availability = androidx.compose.runtime.remember(item, catalogue) {
if (catalogue) {
if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }
} else if (item.watched) "Watched"
else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left"
else null
}
val facts = androidx.compose.runtime.remember(item, availability) {
listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(),
if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series",
availability).joinToString(" · ")
}
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank())
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) {
CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null,
item.watched && item.resumeSeconds <= 0, focused,
Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption)
}
}
}

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
object UpdateTypography {
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private val platform = PlatformTextStyle(includeFontPadding = false)
private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
val heading = resolveStyle(WolfskinTokens.Update.heading)
val nav = resolveStyle(WolfskinTokens.Type.nav)
val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
val body = resolveStyle(WolfskinTokens.Type.body)
val button = resolveStyle(WolfskinTokens.Type.button)
val label = resolveStyle(WolfskinTokens.Type.label)
val caption = resolveStyle(WolfskinTokens.Type.caption)
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
}

View file

@ -11,6 +11,20 @@ import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */
object WolfskinTokens {
object CardMetadata {
val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em)
val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em)
val check = 12.0.dp
val gap = 6.0.dp
val compactGap = 4.0.dp
val minimumGap = 2.0.dp
val bottom = 1.0.dp
val separator = 1.0.dp
val watchedLine = 1.5.dp
val resumeLine = 3.0.dp
val landscapeBand = 32.0.dp
}
object Color {
val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A)
@ -173,13 +187,11 @@ object WolfskinTokens {
val tileH = 112.5.dp
val tileGap = 20.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
const val tileSlots = 4
val wideW = 272.0.dp
val wideH = 153.0.dp
val wideGap = 20.0.dp
val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
const val wideSlots = 3
val entryW = 128.0.dp
val entryH = 72.0.dp
@ -209,6 +221,9 @@ object WolfskinTokens {
const val fillInverse = "background color.fg, text color.onFocus"
}
object Motion {
const val updateScan = 1800
const val updateSheen = 1600
val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
const val skeletonCycle = 1400
const val skeletonLowAlpha = 0.55f
const val focusMove = 180
@ -379,9 +394,6 @@ object WolfskinTokens {
val chipGap = 8.0.dp
val chipTop = 8.0.dp
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val captionFactsGap = 9.0.dp
val badgeIcon = 14.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
@ -465,7 +477,6 @@ object WolfskinTokens {
val personGap = 24.0.dp
val personNameBottom = 5.0.dp
val personNoteBottom = 3.0.dp
val captionWidth = 320.0.dp
val sheetPaddingTop = 40.0.dp
val sheetPaddingSide = 32.0.dp
val sheetGap = 9.0.dp
@ -552,8 +563,6 @@ object WolfskinTokens {
val tileOverlaySide = 11.0.dp
val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val tileProgressBottom = 5.0.dp
val ambientInset = 40.0.dp
const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp
@ -591,7 +600,6 @@ object WolfskinTokens {
val statePaddingY = 17.0.dp
val stateGap = 4.0.dp
val stateIcon = 18.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateFade = 23.0.dp
@ -612,8 +620,6 @@ object WolfskinTokens {
val heroSheetTop = 75.0.dp
val cardBlur = 13.0.dp
val heroBlur = 30.0.dp
val progressInset = 9.0.dp
val progressBottom = 5.0.dp
val endTop = 20.0.dp
val frameBottom = 40.0.dp
val toastPadding = 14.0.dp
@ -634,7 +640,6 @@ object WolfskinTokens {
val savedGap = 5.0.dp
val savedIcon = 14.0.dp
val savedRadius = 14.0.dp
val posterProgressBottom = 6.0.dp
}
object Discover {
val shelfHeadingBand = 52.0.dp
@ -698,4 +703,48 @@ object WolfskinTokens {
const val ambientSaturation = 1.2f
const val ambientBrightness = 0.55f
}
object Update {
val avatarJustin = ComposeColor(0xFF7AB8FF)
val wordmarkWidth = 81.796875.dp
val navHomeWidth = 74.6640625.dp
val navDiscoverWidth = 99.0703125.dp
val navMoviesWidth = 85.2578125.dp
val navShowsWidth = 80.578125.dp
val navAnimeWidth = 79.234375.dp
val notesListTop = 14.0.dp
val notesListBottom = 14.0.dp
val notesMarkerOffset = 5.5.dp
val notesMarkerWidth = 14.5.dp
val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em)
const val settingsVisibleRows = 6.0f
val headerTop = 75.0.dp
val headerHeight = 42.0.dp
val rowsTop = 139.0.dp
val rowsViewport = 362.0.dp
val contentTop = 149.0.dp
val width = 650.0.dp
val padding = 20.0.dp
val border = 1.0.dp
val radius = 11.0.dp
val titleBottom = 8.0.dp
val paragraphBottom = 7.0.dp
val actionsTop = 18.0.dp
val actionGap = 12.0.dp
val progressTop = 15.0.dp
val progressBottom = 11.0.dp
val progressHeight = 6.0.dp
val progressRadius = 4.0.dp
val autoTop = 431.0.dp
val toggleWidth = 40.0.dp
val toggleHeight = 21.0.dp
val toggleThumb = 15.0.dp
val toggleInset = 3.0.dp
val notesHeight = 256.0.dp
val notesGap = 9.0.dp
val notesScrollStep = 40.0.dp
val backTop = 92.0.dp
const val scanFraction = 0.28f
const val scanEnd = 4.6f
const val sheenAlpha = 0.35f
}
}

View file

@ -0,0 +1,4 @@
<!-- SPDX-License-Identifier: GPL-2.0-only -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5 12l4 4l10 -10" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.5" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M12,6 L22,16 L12,26" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M5,8 H27 M5,16 H27 M5,24 H27" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M26,14 A11,11 0,1 0,25 23 M26,5 V14 H17" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,135 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.dp
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
/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CardBottomPixelTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val focused = mutableStateOf(-1)
val showArt = mutableStateOf(true)
val state = mutableStateOf("unwatched")
val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026")
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
names.forEachIndexed { index, name ->
LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null,
played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f),
focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt)
}
}
} }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) {
rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val referenceFile = File(directory, "reference.png")
rule.onRoot().captureRoboImage(referenceFile.absolutePath)
val reference = BitmapFactory.decodeFile(referenceFile.absolutePath)
rule.runOnIdle { showArt.value = true }
rule.waitForIdle()
val file = File(directory, "$watchState-focus-$selection.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
names.forEachIndexed { index, name ->
val focus = selection == index
val left = 96 + 296 * index
val bottom = if (focus) 659 else 643
val edgeLeft = if (focus) left - 10 else left
val edgeRight = if (focus) left + 266 else left + 256
// The reference supplies only rounded-corner/ring coverage, never poster colours.
for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)",
reference.getPixel(x, bottom), bitmap.getPixel(x, bottom))
val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb()
for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)",
expected, bitmap.getPixel(x, bottom))
if (watchState != "unwatched") {
val fillY = if (focus) 655 else 640
assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY))
val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent
assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY))
}
}
reference.recycle()
bitmap.recycle()
}
}
@Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val fraction = mutableStateOf(.5f)
val art = mapOf(path to fixtureArt(path)!!)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100,
poster = path, played = true, resumeSeconds = fraction.value * 100.0)
HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)),
memory, false, 0, emptyList(), {}, {})
LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path,
played = true, progress = fraction.value), false, false, false,
Modifier.offset(240.dp, 42.dp), ::fixtureArt)
}
}
} }
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
for (progress in listOf(.5f, 1f, 1.5f)) {
rule.runOnIdle { fraction.value = progress }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot
val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot
assertEquals(reference.width, home.width, .1f)
assertEquals(reference.bottom, home.bottom, .1f)
val file = File(directory, "home-progress-${(progress * 100).toInt()}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val bottom = home.bottom.toInt()
val band = if (progress >= 1f) 7 else 10
for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) {
assertEquals("Home/library strip progress=$progress ($x,$y)",
bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y))
}
val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent
assertEquals("Completion changes the resume strip to the 3px watched strip",
separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7))
assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4))
} finally { bitmap.recycle() }
}
}
}

View file

@ -0,0 +1,121 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.testTag
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 java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
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
/** Changing a shelf's selected identity must restart its focus-only caption reveal. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class CardCaptionMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
rule.mainClock.autoAdvance = false
}
@Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() {
val title = mutableStateOf("Initial caption")
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
CardCaption(title.value, 2007, "2h 56m", true, true,
Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true)
CardCaption(title.value, 2007, "2h 56m", true, true,
Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference"))
}
} }
for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) {
update { title.value = selected }
rule.mainClock.advanceTimeBy(400)
assertCaptionPixels("component-$index",
rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot,
rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot)
}
}
@Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() {
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path),
HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path))
val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards)
val column = mutableStateOf(0)
val memory = HomeViewMemory()
val art = mapOf(path to fixtureArt(path)!!)
rule.setContent { WolfskinTheme {
CompositionLocalProvider(LocalTitleBitmaps provides art) {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Box(Modifier.testTag("home-shelf")) {
HomeShelf(row, memory, true, column.value, emptyList(), {}, {})
}
val selected = cards[column.value]
CardCaption(selected.title, selected.year, selected.runtime, false, true,
Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference"))
}
}
} }
for ((step, selected) in listOf(0, 1, 0).withIndex()) {
update { column.value = selected }
rule.mainClock.advanceTimeBy(400)
val title = cards[selected].title
val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertCaptionPixels("home-$step", actual, reference)
}
}
private fun update(block: () -> Unit) {
rule.runOnIdle { block(); Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
}
private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) {
assertEquals(reference.width, actual.width, .1f)
assertEquals(reference.height, actual.height, .1f)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
val file = File(directory, "$name.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
val foreground = WolfskinTokens.Color.fg.toArgb()
var compared = 0
for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) {
if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) {
assertEquals("$name opaque title pixel ($x,$y)", foreground,
bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y))
compared++
}
}
assertTrue("The reference must contain opaque title glyphs", compared >= 24)
} finally { bitmap.recycle() }
}
}

View file

@ -0,0 +1,169 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.toArgb
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.unit.dp
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
/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class CardGeometryTest {
@get:Rule val rule = createComposeRule()
@Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() {
val width = mutableStateOf(105.dp)
rule.setContent { WolfskinTheme {
CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true,
Modifier.width(width.value).testTag("caption"))
} }
for (pixels in listOf(210, 327)) {
rule.runOnIdle { width.value = (pixels / 2f).dp }
rule.waitForIdle()
val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node ->
val results = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results)
results.single()
}
val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") }
assertEquals(1, title.lineCount)
assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0))
for (facts in layouts.filter { it !== title }) {
assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow)
assertFalse(facts.isLineEllipsized(0))
}
val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot
assertEquals(58f, caption.height, .1f)
assertEquals(pixels.toFloat(), caption.width, .1f)
val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot
assertEquals(24f, check.width, .1f)
assertEquals(24f, check.height, .1f)
assertTrue(check.right <= caption.right)
}
}
@Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() {
val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") }
val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") }
val grid = LibraryGridState("Movies", titles)
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
// Focus scales around the unchanged slot centre.
assertEquals(372f, first.top + (first.height - 384f) / 2, .1f)
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f)
for (index in 0..5) {
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
assertEquals(256f, caption.boundsInRoot.width, .1f)
assertEquals(794f, caption.boundsInRoot.top, .1f)
}
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f)
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f)
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
rule.waitForIdle()
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
assertEquals(168f, pinnedFirst.top, .1f)
assertEquals(692f, pinnedSecond.top, .1f)
assertEquals(1076f, pinnedSecond.bottom, .1f)
}
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
val kind = mutableStateOf(HomeCardKind.Poster)
val loading = mutableStateOf(false)
val memory = HomeViewMemory()
rule.setContent { WolfskinTheme {
Column {
Box(Modifier.testTag("shelf")) {
HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)),
kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {})
}
Box(Modifier.size(1.dp).testTag("next"))
}
} }
for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) {
for (pending in listOf(false, true)) {
rule.runOnIdle { kind.value = cardKind; loading.value = pending }
rule.waitForIdle()
assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f)
assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f)
}
}
}
@Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() {
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560,
poster = path, held = true, watched = true))
val art = mapOf(path to fixtureArt(path)!!)
rule.setContent { WolfskinTheme {
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
TitlePoster(item.value, false, alwaysShowCaption = true) {}
}
} }
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
rule.onNodeWithText("2007").assertIsDisplayed()
rule.onNodeWithText("2h 56m").assertIsDisplayed()
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) }
rule.onNodeWithText("2016").assertIsDisplayed()
rule.onNodeWithText("2h 56m").assertDoesNotExist()
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) }
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
}
@Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() {
val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp),
"wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp))
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
slots.forEach { (name, geometry) ->
Box(Modifier.testTag("loading-$name")) {
PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third)
}
}
}
}
} }
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
val file = File(directory, "loading-bands.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
try {
for ((name, geometry) in slots) {
val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot
val band = geometry.third.value * 2
assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f)
for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) {
for (x in bounds.left.toInt() until bounds.right.toInt()) {
assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y))
}
}
}
} finally { bitmap.recycle() }
}
}

View file

@ -11,23 +11,12 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
/** Live letter-rail changes preserve deep selection and poster geometry. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibrarySpacingRegressionTest {
@get:Rule val rule = createComposeRule()
@Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() {
val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f)
rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } }
val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot
val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot
assertEquals(256f, poster.width, .1f)
assertEquals(220f, track.width, .1f)
assertEquals(18f, track.left - poster.left, .1f)
assertEquals(18f, poster.right - track.right, .1f)
}
@Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") }

View file

@ -68,7 +68,7 @@ class ShellCaptureTest {
@Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 }
@Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 }
}
/** Tests and debug builds share the same shell-only projection of the locked fixture. */

View file

@ -46,6 +46,22 @@ class ShellKeysTest {
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
}
@Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() {
val actions = mutableListOf<ShellAction>()
val shell = ShellState(fixturePeople(), "justin", actions::add).apply {
pickerVisible = false
enterDestination("settings")
}
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
pressKey(Key.DirectionDown)
pressKey(Key.DirectionCenter)
}
assertEquals(1, shell.settingsRow)
assertNull(shell.handOff)
assertEquals(listOf(ShellAction.Destination("updates")), actions)
}
@Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Back, Key.Back), 10)
assertEquals("home", state.route)
@ -54,10 +70,10 @@ class ShellKeysTest {
assertEquals(state.tabs.size, state.navIndex)
}
@Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
@Test fun playbackHandOffInTwelvePresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
assertEquals("playback", state.handOff)
assertEquals(2, state.settingsRow)
assertEquals(3, state.settingsRow)
}
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {

View file

@ -23,7 +23,6 @@
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
"personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"},
"personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"},
"captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"},
"sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"},
"sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"},
"sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"},
@ -110,8 +109,6 @@
"tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"},
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
"ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"},
"ambientScale": {"$value": 1.1, "source": "index.html:96"},
"posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"},

View file

@ -1,5 +1,17 @@
/* Generated from the locked TV token sources. */
:root {
--cardMetadata-titleRest: 28.0px;
--cardMetadata-titleFocus: 28.0px;
--cardMetadata-subtitle: 24.0px;
--cardMetadata-check: 24.0px;
--cardMetadata-gap: 12.0px;
--cardMetadata-compactGap: 8.0px;
--cardMetadata-minimumGap: 4.0px;
--cardMetadata-bottom: 2.0px;
--cardMetadata-separator: 2.0px;
--cardMetadata-watchedLine: 3.0px;
--cardMetadata-resumeLine: 6.0px;
--cardMetadata-landscapeBand: 64.0px;
--color-bg: #07070a;
--color-surface: #13131a;
--color-surface2: #1b1b25;
@ -123,6 +135,9 @@
--layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px;
--layout-row_chipsGrid: 136.0px;
--motion-updateScan: 1800ms;
--motion-updateSheen: 1600ms;
--motion-linear: cubic-bezier(0, 0, 1, 1);
--motion-skeletonCycle: 1400ms;
--motion-focusMove: 180ms;
--motion-captionDelay: 60ms;
@ -242,8 +257,6 @@
--library-chipGap: 16.0px;
--library-chipTop: 16.0px;
--library-chipIcon: 26.0px;
--library-captionWidth: 640.0px;
--library-captionFactsGap: 18.0px;
--library-badgeIcon: 28.0px;
--library-plateTop: 14.0px;
--library-plateSide: 16.0px;
@ -303,7 +316,6 @@
--title-personGap: 48.0px;
--title-personNameBottom: 10.0px;
--title-personNoteBottom: 6.0px;
--title-captionWidth: 640.0px;
--title-sheetPaddingTop: 80.0px;
--title-sheetPaddingSide: 64.0px;
--title-sheetGap: 18.0px;
@ -384,8 +396,6 @@
--title-tileOverlayTop: 54.0px;
--title-tileOverlaySide: 22.0px;
--title-tileOverlayBottom: 18.0px;
--title-cardProgressInset: 18.0px;
--title-tileProgressBottom: 10.0px;
--title-ambientInset: 80.0px;
--title-posterShadowRadius: 80.0px;
--title-posterShadowY: 30.0px;
@ -415,7 +425,6 @@
--home-statePaddingY: 34.0px;
--home-stateGap: 8.0px;
--home-stateIcon: 36.0px;
--home-captionWidth: 640.0px;
--home-plateTop: 14.0px;
--home-plateSide: 16.0px;
--home-plateFade: 46.0px;
@ -436,8 +445,6 @@
--home-heroSheetTop: 150.0px;
--home-cardBlur: 26.0px;
--home-heroBlur: 60.0px;
--home-progressInset: 18.0px;
--home-progressBottom: 10.0px;
--home-endTop: 40.0px;
--home-frameBottom: 80.0px;
--home-toastPadding: 28.0px;
@ -457,7 +464,6 @@
--home-savedGap: 10.0px;
--home-savedIcon: 28.0px;
--home-savedRadius: 28.0px;
--home-posterProgressBottom: 12.0px;
--discover-shelfHeadingBand: 104.0px;
--discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px;
@ -513,4 +519,42 @@
--discover-stageGap: 10.0px;
--discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px;
--update-avatarJustin: #7ab8ff;
--update-wordmarkWidth: 163.59375px;
--update-navHomeWidth: 149.328125px;
--update-navDiscoverWidth: 198.140625px;
--update-navMoviesWidth: 170.515625px;
--update-navShowsWidth: 161.15625px;
--update-navAnimeWidth: 158.46875px;
--update-notesListTop: 28.0px;
--update-notesListBottom: 28.0px;
--update-notesMarkerOffset: 11.0px;
--update-notesMarkerWidth: 29.0px;
--update-heading: 36.0px;
--update-headerTop: 150.0px;
--update-headerHeight: 84.0px;
--update-rowsTop: 278.0px;
--update-rowsViewport: 724.0px;
--update-contentTop: 298.0px;
--update-width: 1300.0px;
--update-padding: 40.0px;
--update-border: 2.0px;
--update-radius: 22.0px;
--update-titleBottom: 16.0px;
--update-paragraphBottom: 14.0px;
--update-actionsTop: 36.0px;
--update-actionGap: 24.0px;
--update-progressTop: 30.0px;
--update-progressBottom: 22.0px;
--update-progressHeight: 12.0px;
--update-progressRadius: 8.0px;
--update-autoTop: 862.0px;
--update-toggleWidth: 80.0px;
--update-toggleHeight: 42.0px;
--update-toggleThumb: 30.0px;
--update-toggleInset: 6.0px;
--update-notesHeight: 512.0px;
--update-notesGap: 18.0px;
--update-notesScrollStep: 80.0px;
--update-backTop: 184.0px;
}

View file

@ -1,5 +1,5 @@
{
"$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"meta": {
"density": 2.0,
"stagePx": [
@ -15,6 +15,20 @@
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json"
},
"cardMetadata": {
"titleRest": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":500,"$value":{"fontWeight":500}},
"titleFocus": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":700,"$value":{"fontWeight":700}},
"subtitle": {"$type":"typography","px":24,"sp":12,"lineHeight":1.0833333333333333,"weight":400,"$value":{"fontWeight":400}},
"check": {"$type":"dimension","px":24,"dp":12},
"gap": {"$type":"dimension","px":12,"dp":6},
"compactGap": {"$type":"dimension","px":8,"dp":4},
"minimumGap": {"$type":"dimension","px":4,"dp":2},
"bottom": {"$type":"dimension","px":2,"dp":1},
"separator": {"$type":"dimension","px":2,"dp":1},
"watchedLine": {"$type":"dimension","px":3,"dp":1.5},
"resumeLine": {"$type":"dimension","px":6,"dp":3},
"landscapeBand": {"$type":"dimension","px":64,"dp":32}
},
"color": {
"bg": {
"$type": "color",
@ -1045,7 +1059,7 @@
"px": 38,
"dp": 19,
"source": "index.html:1242",
"usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640"
"usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only"
},
"plateInset": {
"$type": "dimension",
@ -1202,7 +1216,7 @@
"ratio": "2:3",
"fullSlotsPerRow": 6,
"source": "index.html:656",
"usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest"
"usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only"
},
"tile": {
"$type": "card",
@ -1210,15 +1224,13 @@
"w": 400,
"h": 225,
"gap": 40,
"pitch": 440,
"captionBand": 0
"pitch": 440
},
"dp": {
"w": 200.0,
"h": 112.5,
"gap": 20.0,
"pitch": 220.0,
"captionBand": 0.0
"pitch": 220.0
},
"ratio": "16:9",
"fullSlotsPerRow": 4,
@ -1231,15 +1243,13 @@
"w": 544,
"h": 306,
"gap": 40,
"pitch": 584,
"captionBand": 0
"pitch": 584
},
"dp": {
"w": 272.0,
"h": 153.0,
"gap": 20.0,
"pitch": 292.0,
"captionBand": 0.0
"pitch": 292.0
},
"ratio": "16:9",
"fullSlotsPerRow": 3,
@ -1390,6 +1400,9 @@
}
},
"motion": {
"updateScan": { "$type": "duration", "$value": "1800ms", "source": "approved Updates progress busy scan" },
"updateSheen": { "$type": "duration", "$value": "1600ms", "source": "approved Updates download highlight" },
"linear": { "$type": "cubicBezier", "$value": [0, 0, 1, 1] },
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
"focusMove": { "$type": "duration", "$value": "180ms" },

View file

@ -1,8 +1,9 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json
8efafb988e507e367630212db4af4f40b529be55d0f90577a7be6dd98a86b4d4 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json
5f3b6d52c7ecfe49db59d2bd74acc1781ff7196db62bae3827d8f52b50d65aea tokens.css
3ed41f38aac01a608fb3e61c836180cf1ad76fe7cd1aac8b126f0a4adc0b2db1 tokens.json
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json

View file

@ -0,0 +1,167 @@
{
"update": {
"avatarJustin": {"$type": "color", "$value": "#7ab8ff"},
"wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875},
"navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625},
"navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125},
"navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125},
"navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125},
"navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375},
"notesListTop": {"$type": "dimension", "px": 28, "dp": 14},
"notesListBottom": {"$type": "dimension", "px": 28, "dp": 14},
"notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5},
"notesMarkerWidth": {"$type": "dimension", "px": 29, "dp": 14.5},
"heading": {
"$type": "typography",
"$value": {"fontWeight": 800},
"px": 36,
"sp": 18,
"weight": 800,
"lineHeight": 1.12,
"letterSpacingEm": -0.02
},
"settingsVisibleRows": {"$type": "number", "$value": 6},
"headerTop": {
"$type": "dimension",
"px": 150,
"dp": 75.0
},
"headerHeight": {
"$type": "dimension",
"px": 84,
"dp": 42.0
},
"rowsTop": {
"$type": "dimension",
"px": 278,
"dp": 139.0
},
"rowsViewport": {
"$type": "dimension",
"px": 724,
"dp": 362.0
},
"contentTop": {
"$type": "dimension",
"px": 298,
"dp": 149.0
},
"width": {
"$type": "dimension",
"px": 1300,
"dp": 650.0
},
"padding": {
"$type": "dimension",
"px": 40,
"dp": 20.0
},
"border": {
"$type": "dimension",
"px": 2,
"dp": 1.0
},
"radius": {
"$type": "dimension",
"px": 22,
"dp": 11.0
},
"titleBottom": {
"$type": "dimension",
"px": 16,
"dp": 8.0
},
"paragraphBottom": {
"$type": "dimension",
"px": 14,
"dp": 7.0
},
"actionsTop": {
"$type": "dimension",
"px": 36,
"dp": 18.0
},
"actionGap": {
"$type": "dimension",
"px": 24,
"dp": 12.0
},
"progressTop": {
"$type": "dimension",
"px": 30,
"dp": 15.0
},
"progressBottom": {
"$type": "dimension",
"px": 22,
"dp": 11.0
},
"progressHeight": {
"$type": "dimension",
"px": 12,
"dp": 6.0
},
"progressRadius": {
"$type": "dimension",
"px": 8,
"dp": 4.0
},
"autoTop": {
"$type": "dimension",
"px": 862,
"dp": 431.0
},
"toggleWidth": {
"$type": "dimension",
"px": 80,
"dp": 40.0
},
"toggleHeight": {
"$type": "dimension",
"px": 42,
"dp": 21.0
},
"toggleThumb": {
"$type": "dimension",
"px": 30,
"dp": 15.0
},
"toggleInset": {
"$type": "dimension",
"px": 6,
"dp": 3.0
},
"notesHeight": {
"$type": "dimension",
"px": 512,
"dp": 256.0
},
"notesGap": {
"$type": "dimension",
"px": 18,
"dp": 9.0
},
"notesScrollStep": {
"$type": "dimension",
"px": 80,
"dp": 40.0
},
"backTop": {
"$type": "dimension",
"px": 184,
"dp": 92.0
},
"scanFraction": {
"$type": "number",
"$value": 0.28
},
"scanEnd": {
"$type": "number",
"$value": 4.6
},
"sheenAlpha": {
"$type": "number",
"$value": 0.35
}
}
}