Compare commits

..

No commits in common. "main" and "fm/tv-updater-core" have entirely different histories.

67 changed files with 318 additions and 1969 deletions

View file

@ -8,14 +8,6 @@ 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. 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 # Wholphin - an OSS Android TV client for Jellyfin

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,108 +0,0 @@
// 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

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

View file

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

View file

@ -1,79 +0,0 @@
// 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

@ -1,13 +0,0 @@
// 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

@ -1,92 +0,0 @@
// 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

@ -1,58 +0,0 @@
// 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

@ -1,202 +0,0 @@
// 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 # Wolfskin TV specification — revision 6
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/`. 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/`.
## Who’s watching ## Who’s watching
@ -83,29 +83,6 @@ 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. 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 ## 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`. 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,15 +345,6 @@ 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 Neither an upstream merge nor its validation build authorizes a release or TV
installation. 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 shell
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
@ -689,9 +680,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 more row; an empty slot cannot activate recovery. An unavailable library hides
its unknown item count. its unknown item count.
Library titles and compact subtitles remain visible at rest, with one-line, Caption separators use 40% of the existing muted colour's alpha. The rail follows
card-width ellipsis rather than a 640 px focused caption. The letter rail uses the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px,
bold 24 px letters, with the focused letter bold 28 px. overriding the spec table's generic caption style for this component.
Library and Settings header initials use the muted colour over the person's fill; Library and Settings header initials use the muted colour over the person's fill;
navigation and picker avatars retain their dark initials. navigation and picker avatars retain their dark initials.
@ -735,35 +726,12 @@ Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px 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. for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
Cards use the approved option-B bottom state line instead of a separate inset Poster progress uses the independent 18 px title-card inset, not caption spacing:
resume bar: watched is 3 px accent; resume is 6 px proportional accent over the track remains 220 px wide on a 256 px poster. Library focus-follow restarts
`color.progressTrack`; unwatched and Discover have no line. Opaque background when the effective column count, focused row or header geometry changes, retaining
backs the entire strip, including its bottom 2 px and the 2 px separator above deep focus across live snapshots that add or remove the letter rail.
the fill. Artwork is clipped out of the backing band before painting it so `LibrarySpacingRegressionTest` renders both progress geometry and the unchanged
rounded-corner antialiasing cannot leak poster pixels into the lowest row. focused index across the 199↔200-title boundary.
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`, `LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and `HomeCaptureTest.spacingNextUpFirstFocused` and

View file

@ -35,7 +35,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) {
} }
val checkTokenDrift by tasks.registering { val checkTokenDrift by tasks.registering {
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") }) 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") })
doLast { doLast {
file("tokens.sha256").readLines().forEach { line -> file("tokens.sha256").readLines().forEach { line ->
val (expected, name) = line.split(" ", limit = 2) val (expected, name) = line.split(" ", limit = 2)

View file

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

View file

@ -114,6 +114,12 @@
"dp": 18.0, "dp": 18.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "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": { "plateTop": {
"$type": "dimension", "$type": "dimension",
"px": 14, "px": 14,
@ -234,6 +240,18 @@
"dp": 30.0, "dp": 30.0,
"source": "locked index.html:94-165,180-198,1014-1029,1276-1289" "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": { "endTop": {
"$type": "dimension", "$type": "dimension",
"px": 40, "px": 40,
@ -298,6 +316,7 @@
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, "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" }, "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" }, "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" } "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" }
} }
} }

View file

@ -367,6 +367,9 @@
"chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"},
"chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"},
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, "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"}, "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},

View file

@ -1,47 +0,0 @@
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,11 +10,18 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.TextStyle
import androidx.tv.material3.Text 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. */ /** Captions rise inside the reserved band without changing measured geometry. */
@Composable @Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { 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) {
val alpha = remember(title, facts) { Animatable(0f) } val alpha = remember(title, facts) { Animatable(0f) }
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) } val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
val reduced = reducedMotion() val reduced = reducedMotion()
@ -25,10 +32,47 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
if (visible) entryProgress.floatValue = value 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) }) { 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 = TextOverflow.Ellipsis) Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow)
Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) 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 }
}
} }
} }

View file

@ -1,81 +0,0 @@
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. */ /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable @Composable
fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
val exact = remember { CssColumnHeight() } val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints -> Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } 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, 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), 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), horizontalArrangement = Arrangement.spacedBy(K.posterGap),
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)) { modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + 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 -> 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) 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) .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) { } else if (lane.pending) {
items(slots, key = { "pending:$it" }) { items(slots, key = { "pending:$it" }) {
PosterCaptionSkeleton() Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
} }
} else { } else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
@ -80,16 +80,15 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
} }
} }
} }
if (!services && !lane.pending) { if (!services && active && !lane.pending) {
val item = lane.titles.getOrNull(column) val item = lane.titles.getOrNull(column)
val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW) Caption(
if (item != null) { item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
val card = item.libraryCard(state) 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",
CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true) true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
} else Caption( accentFirstFact = lane.hidden,
if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
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)
} }
} }
} }
@ -109,3 +108,7 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: ()
else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, 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,9 +11,6 @@ import androidx.tv.material3.Icon
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) { val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear "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 "requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid "interface" -> R.drawable.wolfskin_grid
"play", "playback" -> R.drawable.wolfskin_play "play", "playback" -> R.drawable.wolfskin_play

View file

@ -38,6 +38,7 @@ data class HomeTitle(
} }
val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left" 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 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. */ /** 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) { private fun rowHeight(row: HomeRow) = when (row.kind) {
HomeRowKind.Hero -> L.row_hero HomeRowKind.Hero -> L.row_hero
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
HomeRowKind.Section -> L.row_section HomeRowKind.Section -> L.row_section
HomeRowKind.State -> L.row_state HomeRowKind.State -> L.row_state
HomeRowKind.Note -> L.row_note HomeRowKind.Note -> L.row_note

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@ -34,7 +36,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) { fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth val width = row.cardWidth
val height = row.cardHeight val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } 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) } } val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
@ -48,36 +50,39 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { 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)) 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() 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 + 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) { 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) {
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 -> 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 selected = !row.skeleton && focused && column == index
val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$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) .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier)
if (row.skeleton) { if (title == null && !row.skeleton) {
PosterCaptionSkeleton(width, height, caption)
} else if (title == null) {
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
} else { } else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) {
val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat() if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else {
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) 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.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 (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()) HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth())
} }
} }
} }
} }
if (!row.skeleton) { if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
val title = row.cards.getOrNull(column) val title = row.cards.getOrNull(column)
val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width) val availability = when {
if (title == null) { title == null -> null
if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier) row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" }
} else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year, title.played -> "Watched"
title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime }, title.resumeSeconds > 0 -> title.remaining
!row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier, else -> null
focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster) }
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)
} }
} }
} }
@ -85,11 +90,9 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
@Composable @Composable
private fun TileOverlay(title: HomeTitle) { private fun TileOverlay(title: HomeTitle) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
val metadata = WolfskinTokens.CardMetadata 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)) {
Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth() Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))) Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
.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,24 +21,19 @@ fun personColor(key: String): Color = when (key.lowercase()) {
else -> WolfskinTokens.Color.accent 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. */ /** The same mark anchors the picker and the persistent navigation. */
@Composable @Composable
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) { fun Wordmark(modifier: Modifier = Modifier) {
Row(modifier) { Row(modifier) {
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle) Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle) Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
} }
} }
/** Initials are drawn over opaque person colour at the exact slot size. */ /** Initials are drawn over opaque person colour at the exact slot size. */
@Composable @Composable
fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus, 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(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = letterColor, style = textStyle) Text(person.initial, color = letterColor, style = textStyle)
} }
} }

View file

@ -18,7 +18,6 @@ interface PosterContent {
val poster: String? val poster: String?
val ratings: List<LibraryRating> val ratings: List<LibraryRating>
val progress: Float val progress: Float
val posterWatched: Boolean
val badge: String? val badge: String?
} }
@ -41,15 +40,18 @@ data class LibraryTitle(
) : PosterContent { ) : PosterContent {
override val posterTitle: String get() = title override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie" 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 sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}" val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' 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 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. */ /** Ordered rating entries are formatted after the active TV locale is known. */

View file

@ -25,12 +25,9 @@ fun LibraryPoster(
catalogue: Boolean, catalogue: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null, offlineArt: ((String) -> ImageBitmap?)? = null,
showWatchState: Boolean = !catalogue,
) { ) {
val poster = title.poster val poster = title.poster
val progress = if (showWatchState) title.progress else 0f TitleCard(focused, modifier.testTag("poster-${title.id}")) {
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) } } val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) { else if (poster != null && offlineArt == null) {
@ -42,6 +39,13 @@ fun LibraryPoster(
onSuccess = { ready = true }, onError = { ready = false }) onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) 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) { if (catalogue && title.badge != null) {
val held = title.badge == "held" val held = title.badge == "held"
val color = if (title.badge == "failed") C.fail else C.accent 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" }) { LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) {
Box(Modifier.fillMaxWidth().height(rowHeight)) { Box(Modifier.fillMaxWidth().height(rowHeight)) {
Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) {
repeat(grid.columns) { PosterCaptionSkeleton() } repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
} }
} }
} }
@ -139,7 +139,6 @@ fun LibraryScreen(
val title = items.getOrNull(index) ?: break val title = items.getOrNull(index) ?: break
key(if (grid.catalogue) title.catalogueKey else title.id) { key(if (grid.catalogue) title.catalogueKey else title.id) {
val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index
Box(Modifier.width(K.posterW)) {
LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue,
Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) {
awaitPointerEventScope { awaitPointerEventScope {
@ -151,13 +150,15 @@ fun LibraryScreen(
} }
} }
}.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) }.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") { if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") {
@ -239,7 +240,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) {
Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { 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)) 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, 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(K.posterW)) Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -9,51 +9,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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 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. */ /** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */
@Composable @Composable
fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW,
height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card) val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
.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,7 +30,6 @@ data class TitleItem(
) : PosterContent { ) : PosterContent {
override val posterTitle: String get() = name override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie" 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 progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
override val badge: String? get() = if (held) "held" else null override val badge: String? get() = if (held) "held" else null
val episodeLabel: String get() = "S$season E$episode" val episodeLabel: String get() = "S$season E$episode"

View file

@ -4,6 +4,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -106,36 +107,53 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
} }
} }
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */ /** Posters draw art at rest, with facts only in the reserved focused-caption band. */
@Composable @Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current val focusOwner = LocalTitleFocusOwner.current
Column(modifier.zIndex(if (focused) 1f else 0f)) { Column(modifier.zIndex(if (focused) 1f else 0f)) {
if (!tile) { if (!tile) {
val bitmaps = LocalTitleBitmaps.current val bitmaps = LocalTitleBitmaps.current
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
progress = item.progress.takeIf { it < 1f } ?: 0f) { .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleTileArtwork(item) TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) 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)) { .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.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) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0) }
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))
}
} }
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
} }
} }
if (!tile) { if (!tile) {
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) { val availability = androidx.compose.runtime.remember(item, catalogue) {
CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null, if (catalogue) {
item.watched && item.resumeSeconds <= 0, focused, if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }
Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption) } 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())
} }
} }
} }

View file

@ -1,29 +0,0 @@
// 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,20 +11,6 @@ import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */ /** The locked TV design, converted at the source density. */
object WolfskinTokens { 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 { object Color {
val bg = ComposeColor(0xFF07070A) val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A) val surface = ComposeColor(0xFF13131A)
@ -187,11 +173,13 @@ object WolfskinTokens {
val tileH = 112.5.dp val tileH = 112.5.dp
val tileGap = 20.0.dp val tileGap = 20.0.dp
val tilePitch = 220.0.dp val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
const val tileSlots = 4 const val tileSlots = 4
val wideW = 272.0.dp val wideW = 272.0.dp
val wideH = 153.0.dp val wideH = 153.0.dp
val wideGap = 20.0.dp val wideGap = 20.0.dp
val widePitch = 292.0.dp val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
const val wideSlots = 3 const val wideSlots = 3
val entryW = 128.0.dp val entryW = 128.0.dp
val entryH = 72.0.dp val entryH = 72.0.dp
@ -221,9 +209,6 @@ object WolfskinTokens {
const val fillInverse = "background color.fg, text color.onFocus" const val fillInverse = "background color.fg, text color.onFocus"
} }
object Motion { 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 skeletonCycle = 1400
const val skeletonLowAlpha = 0.55f const val skeletonLowAlpha = 0.55f
const val focusMove = 180 const val focusMove = 180
@ -394,6 +379,9 @@ object WolfskinTokens {
val chipGap = 8.0.dp val chipGap = 8.0.dp
val chipTop = 8.0.dp val chipTop = 8.0.dp
val chipIcon = 13.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 badgeIcon = 14.0.dp
val plateTop = 7.0.dp val plateTop = 7.0.dp
val plateSide = 8.0.dp val plateSide = 8.0.dp
@ -477,6 +465,7 @@ object WolfskinTokens {
val personGap = 24.0.dp val personGap = 24.0.dp
val personNameBottom = 5.0.dp val personNameBottom = 5.0.dp
val personNoteBottom = 3.0.dp val personNoteBottom = 3.0.dp
val captionWidth = 320.0.dp
val sheetPaddingTop = 40.0.dp val sheetPaddingTop = 40.0.dp
val sheetPaddingSide = 32.0.dp val sheetPaddingSide = 32.0.dp
val sheetGap = 9.0.dp val sheetGap = 9.0.dp
@ -563,6 +552,8 @@ object WolfskinTokens {
val tileOverlaySide = 11.0.dp val tileOverlaySide = 11.0.dp
val tileOverlayBottom = 9.0.dp val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val tileProgressBottom = 5.0.dp
val ambientInset = 40.0.dp val ambientInset = 40.0.dp
const val ambientScale = 1.1f const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp val posterShadowRadius = 40.0.dp
@ -600,6 +591,7 @@ object WolfskinTokens {
val statePaddingY = 17.0.dp val statePaddingY = 17.0.dp
val stateGap = 4.0.dp val stateGap = 4.0.dp
val stateIcon = 18.0.dp val stateIcon = 18.0.dp
val captionWidth = 320.0.dp
val plateTop = 7.0.dp val plateTop = 7.0.dp
val plateSide = 8.0.dp val plateSide = 8.0.dp
val plateFade = 23.0.dp val plateFade = 23.0.dp
@ -620,6 +612,8 @@ object WolfskinTokens {
val heroSheetTop = 75.0.dp val heroSheetTop = 75.0.dp
val cardBlur = 13.0.dp val cardBlur = 13.0.dp
val heroBlur = 30.0.dp val heroBlur = 30.0.dp
val progressInset = 9.0.dp
val progressBottom = 5.0.dp
val endTop = 20.0.dp val endTop = 20.0.dp
val frameBottom = 40.0.dp val frameBottom = 40.0.dp
val toastPadding = 14.0.dp val toastPadding = 14.0.dp
@ -640,6 +634,7 @@ object WolfskinTokens {
val savedGap = 5.0.dp val savedGap = 5.0.dp
val savedIcon = 14.0.dp val savedIcon = 14.0.dp
val savedRadius = 14.0.dp val savedRadius = 14.0.dp
val posterProgressBottom = 6.0.dp
} }
object Discover { object Discover {
val shelfHeadingBand = 52.0.dp val shelfHeadingBand = 52.0.dp
@ -703,48 +698,4 @@ object WolfskinTokens {
const val ambientSaturation = 1.2f const val ambientSaturation = 1.2f
const val ambientBrightness = 0.55f 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

@ -1,4 +0,0 @@
<!-- 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

@ -1,3 +0,0 @@
<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

@ -1,3 +0,0 @@
<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

@ -1,3 +0,0 @@
<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

@ -1,135 +0,0 @@
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

@ -1,121 +0,0 @@
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

@ -1,169 +0,0 @@
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,12 +11,23 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
/** Live letter-rail changes preserve deep selection and poster geometry. */ /** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibrarySpacingRegressionTest { class LibrarySpacingRegressionTest {
@get:Rule val rule = createComposeRule() @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() { @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } 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 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 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 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 = 5 } @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
} }
/** Tests and debug builds share the same shell-only projection of the locked fixture. */ /** Tests and debug builds share the same shell-only projection of the locked fixture. */

View file

@ -46,22 +46,6 @@ class ShellKeysTest {
assertFalse("Requests content, not the nav, must own focus", state.navFocused) 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() { @Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Back, Key.Back), 10) replay(settings + listOf(Key.Back, Key.Back), 10)
assertEquals("home", state.route) assertEquals("home", state.route)
@ -70,10 +54,10 @@ class ShellKeysTest {
assertEquals(state.tabs.size, state.navIndex) assertEquals(state.tabs.size, state.navIndex)
} }
@Test fun playbackHandOffInTwelvePresses() { @Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12) replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
assertEquals("playback", state.handOff) assertEquals("playback", state.handOff)
assertEquals(3, state.settingsRow) assertEquals(2, state.settingsRow)
} }
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() { @Test fun onlyLastPersonReachesGearAndUpReturnsThere() {

View file

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

View file

@ -1,17 +1,5 @@
/* Generated from the locked TV token sources. */ /* Generated from the locked TV token sources. */
:root { :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-bg: #07070a;
--color-surface: #13131a; --color-surface: #13131a;
--color-surface2: #1b1b25; --color-surface2: #1b1b25;
@ -135,9 +123,6 @@
--layout-castCircle: 200.0px; --layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px; --layout-personPortrait: 240.0px;
--layout-row_chipsGrid: 136.0px; --layout-row_chipsGrid: 136.0px;
--motion-updateScan: 1800ms;
--motion-updateSheen: 1600ms;
--motion-linear: cubic-bezier(0, 0, 1, 1);
--motion-skeletonCycle: 1400ms; --motion-skeletonCycle: 1400ms;
--motion-focusMove: 180ms; --motion-focusMove: 180ms;
--motion-captionDelay: 60ms; --motion-captionDelay: 60ms;
@ -257,6 +242,8 @@
--library-chipGap: 16.0px; --library-chipGap: 16.0px;
--library-chipTop: 16.0px; --library-chipTop: 16.0px;
--library-chipIcon: 26.0px; --library-chipIcon: 26.0px;
--library-captionWidth: 640.0px;
--library-captionFactsGap: 18.0px;
--library-badgeIcon: 28.0px; --library-badgeIcon: 28.0px;
--library-plateTop: 14.0px; --library-plateTop: 14.0px;
--library-plateSide: 16.0px; --library-plateSide: 16.0px;
@ -316,6 +303,7 @@
--title-personGap: 48.0px; --title-personGap: 48.0px;
--title-personNameBottom: 10.0px; --title-personNameBottom: 10.0px;
--title-personNoteBottom: 6.0px; --title-personNoteBottom: 6.0px;
--title-captionWidth: 640.0px;
--title-sheetPaddingTop: 80.0px; --title-sheetPaddingTop: 80.0px;
--title-sheetPaddingSide: 64.0px; --title-sheetPaddingSide: 64.0px;
--title-sheetGap: 18.0px; --title-sheetGap: 18.0px;
@ -396,6 +384,8 @@
--title-tileOverlayTop: 54.0px; --title-tileOverlayTop: 54.0px;
--title-tileOverlaySide: 22.0px; --title-tileOverlaySide: 22.0px;
--title-tileOverlayBottom: 18.0px; --title-tileOverlayBottom: 18.0px;
--title-cardProgressInset: 18.0px;
--title-tileProgressBottom: 10.0px;
--title-ambientInset: 80.0px; --title-ambientInset: 80.0px;
--title-posterShadowRadius: 80.0px; --title-posterShadowRadius: 80.0px;
--title-posterShadowY: 30.0px; --title-posterShadowY: 30.0px;
@ -425,6 +415,7 @@
--home-statePaddingY: 34.0px; --home-statePaddingY: 34.0px;
--home-stateGap: 8.0px; --home-stateGap: 8.0px;
--home-stateIcon: 36.0px; --home-stateIcon: 36.0px;
--home-captionWidth: 640.0px;
--home-plateTop: 14.0px; --home-plateTop: 14.0px;
--home-plateSide: 16.0px; --home-plateSide: 16.0px;
--home-plateFade: 46.0px; --home-plateFade: 46.0px;
@ -445,6 +436,8 @@
--home-heroSheetTop: 150.0px; --home-heroSheetTop: 150.0px;
--home-cardBlur: 26.0px; --home-cardBlur: 26.0px;
--home-heroBlur: 60.0px; --home-heroBlur: 60.0px;
--home-progressInset: 18.0px;
--home-progressBottom: 10.0px;
--home-endTop: 40.0px; --home-endTop: 40.0px;
--home-frameBottom: 80.0px; --home-frameBottom: 80.0px;
--home-toastPadding: 28.0px; --home-toastPadding: 28.0px;
@ -464,6 +457,7 @@
--home-savedGap: 10.0px; --home-savedGap: 10.0px;
--home-savedIcon: 28.0px; --home-savedIcon: 28.0px;
--home-savedRadius: 28.0px; --home-savedRadius: 28.0px;
--home-posterProgressBottom: 12.0px;
--discover-shelfHeadingBand: 104.0px; --discover-shelfHeadingBand: 104.0px;
--discover-sheetPaddingX: 64.0px; --discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px; --discover-sheetPaddingY: 80.0px;
@ -519,42 +513,4 @@
--discover-stageGap: 10.0px; --discover-stageGap: 10.0px;
--discover-ambientInset: 80.0px; --discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.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 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.", "$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.",
"meta": { "meta": {
"density": 2.0, "density": 2.0,
"stagePx": [ "stagePx": [
@ -15,20 +15,6 @@
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py", "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json" "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": { "color": {
"bg": { "bg": {
"$type": "color", "$type": "color",
@ -1059,7 +1045,7 @@
"px": 38, "px": 38,
"dp": 19, "dp": 19,
"source": "index.html:1242", "source": "index.html:1242",
"usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only" "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"
}, },
"plateInset": { "plateInset": {
"$type": "dimension", "$type": "dimension",
@ -1216,7 +1202,7 @@
"ratio": "2:3", "ratio": "2:3",
"fullSlotsPerRow": 6, "fullSlotsPerRow": 6,
"source": "index.html:656", "source": "index.html:656",
"usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only" "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest"
}, },
"tile": { "tile": {
"$type": "card", "$type": "card",
@ -1224,13 +1210,15 @@
"w": 400, "w": 400,
"h": 225, "h": 225,
"gap": 40, "gap": 40,
"pitch": 440 "pitch": 440,
"captionBand": 0
}, },
"dp": { "dp": {
"w": 200.0, "w": 200.0,
"h": 112.5, "h": 112.5,
"gap": 20.0, "gap": 20.0,
"pitch": 220.0 "pitch": 220.0,
"captionBand": 0.0
}, },
"ratio": "16:9", "ratio": "16:9",
"fullSlotsPerRow": 4, "fullSlotsPerRow": 4,
@ -1243,13 +1231,15 @@
"w": 544, "w": 544,
"h": 306, "h": 306,
"gap": 40, "gap": 40,
"pitch": 584 "pitch": 584,
"captionBand": 0
}, },
"dp": { "dp": {
"w": 272.0, "w": 272.0,
"h": 153.0, "h": 153.0,
"gap": 20.0, "gap": 20.0,
"pitch": 292.0 "pitch": 292.0,
"captionBand": 0.0
}, },
"ratio": "16:9", "ratio": "16:9",
"fullSlotsPerRow": 3, "fullSlotsPerRow": 3,
@ -1400,9 +1390,6 @@
} }
}, },
"motion": { "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" }, "skeletonCycle": { "$type": "duration", "$value": "1400ms" },
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 }, "skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
"focusMove": { "$type": "duration", "$value": "180ms" }, "focusMove": { "$type": "duration", "$value": "180ms" },

View file

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

View file

@ -1,167 +0,0 @@
{
"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
}
}
}