diff --git a/README.md b/README.md index b8f04e12..7566ce5e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ Wolfskin is an Android TV client for one household. It is released through Forge The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres. +## Wolfskin TV updates + +Settings opens on Requests. Press Down, then OK to open Updates; the row and screen show the installed app version. Left/Right selects an action, Down reaches Auto-check, and Up returns to the remembered action. Release notes keep focus inside the notes panel: Up/Down scrolls, and Back returns to the Release notes button. + +Auto-check uses the same preference as classic settings and only checks for updates. Manual checks remain available when it is off. Download and Install update are separate, explicit actions; Android owns installation permission and confirmation. Returning from Android refreshes the installed version. Back can leave a download running, and returning to Updates shows the controller's current state. + +Classic settings (trial) is the final Settings row. It opens the full original settings, including About → Update and More/Advanced → Updates. All existing settings groups remain available. + --- # Wholphin - an OSS Android TV client for Jellyfin diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 2fde3eae..e7ec173a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -52,6 +52,9 @@ class SwitchSeerrViewModel _state.update { it.copy(loading = LoadingState.Success) } } } + } else { + // Nothing to check so set success + _state.update { it.copy(loading = LoadingState.Success) } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt index 3b4d3efa..8d73f34f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -68,11 +68,16 @@ import javax.inject.Singleton @Module @TestInstallIn( components = [SingletonComponent::class], - replaces = [DeviceModule::class, AppModule::class], + replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class], ) object TestModule { val testDispatcher = StandardTestDispatcher() + @Provides + @Singleton + fun updateController(): org.justinvisser.wolfskin.tv.update.UpdateController = + org.justinvisser.wolfskin.tv.update.FakeUpdateController() + @Provides @Singleton fun deviceInfo( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt index 35e45892..ad3d4647 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeAvailabilityTest.kt @@ -88,7 +88,7 @@ class AnimeAvailabilityTest { } } val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt index 1af82b5c..125a67bd 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/AnimeRootRemovalTest.kt @@ -69,7 +69,7 @@ class AnimeRootRemovalTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, user) requireNotNull(model.onForeground(server, user)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt index b748367e..052aa05c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -49,7 +49,7 @@ class ConductarrAudienceRecoveryTest { val client = session.identity.value!!.client val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { requireNotNull(model.onForeground(server, user)).join() assertNull(libraries.audiences) @@ -82,7 +82,7 @@ class ConductarrAudienceRecoveryTest { session.authenticate() val libraries = LibraryReadFacade(mockk()) val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { val identity = session.identity.value!! val intent = org.justinvisser.wolfskin.design.DiscoverRequest( diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt index 07cd1e37..c412265e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FirstSignInNavigationTest.kt @@ -91,7 +91,7 @@ class FirstSignInNavigationTest { val navigation = NavigationManager() val session = WolfskinSession() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf( HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume"))) try { diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt index 95fcbb52..35c5cb57 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonPickerSessionTest.kt @@ -85,7 +85,7 @@ class PersonPickerSessionTest { val navigation = remember { NavigationManager() } val model = remember { WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) } + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) } } DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } } WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt index 6a5936f8..9a1ff11e 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/PersonSwitchThreadingTest.kt @@ -87,7 +87,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val clients = mutableListOf() try { model.load(server, justin) @@ -163,7 +163,7 @@ class PersonSwitchThreadingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { model.load(server, justin) requireNotNull(model.onForeground(server, justin)).join() diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt index 1fb29d31..01a2e425 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/SavedPeoplePickerTest.kt @@ -63,7 +63,7 @@ class SavedPeoplePickerTest { val session = WolfskinSession() val navigation = NavigationManager() val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), - mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) try { rule.setContent { WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt index dbcffdb4..8a01861d 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ShellStartupTimingTest.kt @@ -66,7 +66,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(user.id) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val load = launch { model.load(server, user) } try { withTimeout(5_000) { savedPeopleStarted.await() } @@ -121,7 +121,7 @@ class ShellStartupTimingTest { coEvery { libraries.readDestinations(any()) } returns emptyMap() val session = WolfskinSession() val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true), - libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)) + libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController()) val pending = CompletableDeferred() val release = java.util.concurrent.CountDownLatch(1) val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt new file mode 100644 index 00000000..3f6b81f3 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdateBehaviorTest.kt @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import org.junit.Assert.* +import org.junit.Test +import org.justinvisser.wolfskin.design.ShellKey + +/** Behavior boundaries are controller outcomes, not incidental UI wording. */ +class UpdateBehaviorTest { + @Test fun everyOutcomeHasOnlyExplicitUserActions() { + val expected = listOf( + UpdateUiState.Idle to listOf(UpdateCommand.Check), + UpdateUiState.Checking to listOf(UpdateCommand.Back), + UpdateUiState.UpToDate to listOf(UpdateCommand.Check), + UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check), + updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes), + UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel), + UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes), + UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel), + UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel), + UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes), + UpdateUiState.Offline to listOf(UpdateCommand.Retry), + UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic), + ) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) } + expected.forEach { (state, commands) -> + assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command }) + } + assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null)) + } + + @Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() { + val installed = "1.1.1" + val refreshed = presentUpdate(UpdateUiState.Idle, null, installed) + assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed) + assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command }) + assertEquals(presentUpdate(UpdateUiState.Checking, null), + presentUpdate(UpdateUiState.Checking, null, installed)) + } + + @Test fun percentageRequiresPositiveTotalAndIsBounded() { + listOf(null, 0L, -1L).forEach { total -> + val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture()) + assertNull(ui.fraction) + } + assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f) + assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f) + } + + @Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() { + val controller = FakeUpdateController(updateReleaseFixture()) + val focus = UpdateFocus(controller, {}, {}, {}, {}) + focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions) + focus.key(ShellKey.Right) + focus.key(ShellKey.Down) + assertTrue(focus.autoFocused) + assertTrue(controller.autoCheck.value) + focus.key(ShellKey.Ok) + assertFalse(controller.autoCheck.value) + focus.key(ShellKey.Up) + assertFalse(focus.autoFocused) + assertEquals(1, focus.actionIndex) + assertFalse(controller.autoCheck.value) + } + + @Test fun notesTrapFocusAndBackRestoresNotesAction() { + val controller = FakeUpdateController(updateReleaseFixture()) + var backs = 0 + var navs = 0 + val steps = mutableListOf() + 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.") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt new file mode 100644 index 00000000..e593675d --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/update/UpdatesKeysTest.kt @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import android.app.Application +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.semantics.SemanticsProperties +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.* +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class UpdatesKeysTest { + @get:Rule val rule = createAndroidComposeRule() + 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) { + 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) + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index aed8ec7b..caeb9543 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -80,6 +80,7 @@ class WolfskinShellViewModel @Inject constructor( val seeAll: WolfskinSeeAllLists, val conductarr: ConductarrSession, artwork: WolfskinArtwork, + val updates: org.justinvisser.wolfskin.tv.update.UpdateController, ) : ViewModel() { init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow @@ -195,7 +196,7 @@ class WolfskinShellViewModel @Inject constructor( is ShellAction.Destination -> { val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route) when { - action.route == "settings" -> navigation.navigateTo(target) + action.route == "settings" || action.route == "updates" -> navigation.navigateTo(target) action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target else -> navigation.replace(target) } @@ -262,6 +263,7 @@ fun WolfskinApp( { shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) }, ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() + val installedVersion by viewModel.updates.installedVersion.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } LaunchedEffect(identity?.signedIn, identity?.person) { @@ -303,7 +305,13 @@ fun WolfskinApp( onBack = { if (navigationManager.backStack.last().fullScreen) navigationManager.goBack() else state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }, entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryProvider = { destination -> - NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { + NavEntry( + destination, + contentKey = "${destination}_${server.id}_${user.id}", + metadata = if (destination is Destination.Wolfskin && destination.route == "updates") { + org.justinvisser.wolfskin.tv.update.updatesRouteTransitions + } else emptyMap(), + ) { // Cached entries must not retain the launch shell after sign-in completes. val state = LocalShellState.current val discover = LocalDiscoverState.current @@ -329,11 +337,15 @@ fun WolfskinApp( } else TitleDestination(route, state, preferences) } else { ShellSurface(state, handlesContentKeys = upstream == null, + settingsInstalledVersion = installedVersion.versionName, overlay = { if (route == "discover" || route.startsWith("discover-")) DiscoverOverlay(discover) }) { contentModifier -> when { (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier) + route == "updates" -> org.justinvisser.wolfskin.tv.update.UpdatesScreen(state, viewModel.updates, + onBack = { state.navFocused = false; navigationManager.goBack() }, + onClassic = { navigationManager.navigateTo(handOffDestination("classic")) }, modifier = contentModifier) route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt index b46bb0e2..75803a95 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateController.kt @@ -8,6 +8,7 @@ interface UpdateController { val state: StateFlow val autoCheck: StateFlow val installedVersion: StateFlow + /** Candidate facts survive download/installer transitions and verified-file restoration. */ val release: StateFlow fun check(manual: Boolean) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt new file mode 100644 index 00000000..cd959f76 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateFocus.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.justinvisser.wolfskin.design.ShellContentFocus +import org.justinvisser.wolfskin.design.ShellKey + +/** Action focus survives progress emissions; release notes own every key until Back. */ +class UpdateFocus( + private val controller: UpdateController, + private val back: () -> Unit, + private val classic: () -> Unit, + private val nav: () -> Unit, + private val scrollNotes: (Int) -> Unit, +) : ShellContentFocus { + override val cardKey: String? = null + var actionIndex by mutableIntStateOf(0) + private set + var autoFocused by mutableStateOf(false) + private set + var notesOpen by mutableStateOf(false) + private set + private var actions = emptyList() + private var previousState: Class<*>? = null + + /** Only a changed operation may retire an action; progress cannot steal focus. */ + fun update(state: UpdateUiState, value: List) { + 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 + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt new file mode 100644 index 00000000..ec5de195 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateNavigation.kt @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.togetherWith +import androidx.navigation3.ui.NavDisplay + +/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */ +internal val updatesRouteTransitions = + NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + + NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } + + NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt new file mode 100644 index 00000000..a052fac5 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatePresentation.kt @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import java.text.NumberFormat + +/** Screen commands remain explicit; no state transition implicitly installs an APK. */ +enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic } + +/** An action is a user command, not a focusable progress indicator. */ +data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false) + +/** Plain copy for the process controller's state; bytes and notes remain real controller data. */ +data class UpdatePresentation( + val title: String = "", + val summary: String = "", + val paragraphs: List = emptyList(), + val actions: List, + 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" + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt new file mode 100644 index 00000000..fc23963c --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdateProgress.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import org.justinvisser.wolfskin.design.reducedMotion +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U + +/** Draw-only bounded track: changing bytes never moves logical or Android focus. */ +@Composable +fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) { + val reduced = reducedMotion() + val phase = if (reduced) 0f else { + val transition = rememberInfiniteTransition(label = "update-progress") + val animated by transition.animateFloat(0f, 1f, + infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen, + easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight") + animated + } + Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress") + .semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) { + val radius = CornerRadius(U.progressRadius.toPx()) + drawRoundRect(C.surface3, cornerRadius = radius) + val width = size.width * (fraction ?: U.scanFraction) + val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase) + clipRect { + drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius) + if (!reduced && fraction != null && width > 0f) { + clipRect(0f, 0f, width, size.height) { + val start = width * (-1f + 2f * phase) + drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent), + startX = start, endX = start + width), size = Size(width, size.height)) + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt new file mode 100644 index 00000000..0db04aa7 --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/update/UpdatesScreen.kt @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv.update + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.Text +import kotlinx.coroutines.launch +import org.justinvisser.wolfskin.design.Avatar +import org.justinvisser.wolfskin.design.CssColumn +import org.justinvisser.wolfskin.design.PickerButton +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.WolfskinTokens +import org.justinvisser.wolfskin.design.exactLineBox +import org.justinvisser.wolfskin.design.focusScale +import org.justinvisser.wolfskin.design.motionDuration +import org.justinvisser.wolfskin.design.reducedMotion +import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor +import org.justinvisser.wolfskin.design.rowReveal +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C +import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L +import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.UpdateTypography as T +import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U + +/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */ +@Composable +fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) { + val state by controller.state.collectAsStateWithLifecycle() + val installed by controller.installedVersion.collectAsStateWithLifecycle() + val release by controller.release.collectAsStateWithLifecycle() + val autoCheck by controller.autoCheck.collectAsStateWithLifecycle() + val initialInstalledCode = remember { installed.versionCode } + val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode } + val presentation = remember(state, release, updatedVersion) { + presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate }) + } + val scroll = rememberScrollState() + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val reduced = reducedMotion() + val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced)) + val focus = remember(controller, shell) { + UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction -> + scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction, + tween(scrollDuration, easing = M.easeOut)) } + } + } + LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) } + LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() } + DisposableEffect(shell, focus) { + shell.contentFocus = focus + onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false } + } + LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen } + Box(modifier.fillMaxSize().testTag("updates-screen")) { + Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(shell.person)) + Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) + Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + } + Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted) + if (focus.notesOpen) { + NotesPanel(release, scroll) + } else { + UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass) + AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto) + } + } +} + +@Composable +private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) { + val shape = RoundedCornerShape(U.radius) + CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity) + .background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) { + if (presentation.title.isNotEmpty()) { + Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg) + Spacer(Modifier.height(U.titleBottom)) + } + if (presentation.summary.isNotEmpty()) { + Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom)) + } + presentation.paragraphs.forEachIndexed { index, paragraph -> + Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom)) + } + if (presentation.progress) { + Spacer(Modifier.height(U.progressTop)) + UpdateProgress(presentation.fraction) + presentation.byteProgress?.let { + Spacer(Modifier.height(U.progressBottom)) + Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + } + } + Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) { + presentation.actions.forEachIndexed { index, action -> + Box(Modifier.testTag("update-action-${action.command}")) { + PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary, + textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) } + } + } + } + } +} + +@Composable +private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) { + val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow) + Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check") + .semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" } + .focusScale(focused, WolfskinTokens.Focus.scaleRow) + .background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle) + .padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg) + Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted) + } + Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) { + Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset) + .size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape)) + } + } +} + +@Composable +private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) { + val shape = RoundedCornerShape(U.radius) + val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() } + val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal) + CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes") + .drawWithCache { + val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f + val radius = U.radius.toPx() + val inset = U.border.toPx() + ring / 2f + onDrawWithContent { + drawContent() + drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring), + CornerRadius(radius + ring / 2f), style = Stroke(ring)) + drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2), + CornerRadius(radius - inset), style = Stroke(ring)) + } + } + .background(C.surface, shape).border(U.border, C.border, shape) + .verticalScroll(scroll).padding(U.padding + U.border)) { + Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg) + Spacer(Modifier.height(U.notesListTop)) + paragraphs.forEachIndexed { index, paragraph -> + Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) { + Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted) + Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted) + } + if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap)) + } + Spacer(Modifier.height(U.notesListBottom)) + Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted) + } +} diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 3282a8d8..913f31f5 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -1,6 +1,6 @@ # Wolfskin TV specification — revision 6 -2026-11-06. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`. Revision-5 feedback below still applies. Unchanged static geometry, navigation, typography and colours remain governed by the approved frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`. +2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`. ## Who’s watching @@ -83,6 +83,29 @@ Discover key traversal skips pending lanes in both directions. If every remainin Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. +## Card metadata and bottom state line — revision 6, option B + +Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration. + +Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer. + +| Geometry at 1920×1080 | Pixels | +|---|---:| +| Library row pitch | 524 | +| Home poster shelf pitch | 632 | +| Home wide / tile shelf pitch | 522 / 441 | +| Initial library art row tops | 372 / 896 | +| Pinned library art row tops / second-row bottom | 168 / 692 / 1076 | +| Focus ring to caption / caption to next focused row ring | 20.48 / 26.48 | + +One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them. + +Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line. + +Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid. + +Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. + ## Sources and verification Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. diff --git a/docker/README.md b/docker/README.md index 92dc7adf..a4a688da 100644 --- a/docker/README.md +++ b/docker/README.md @@ -345,6 +345,15 @@ not the working tree. An uncommitted merge still builds with the old version nam Neither an upstream merge nor its validation build authorizes a release or TV installation. +## Upstream v1.1.1 integration + +The patch release makes manual Seerr setup available when the optional companion +plugin is absent: setup is marked ready immediately rather than remaining pending. +With the plugin installed, an empty settings response or a failed settings read +still reaches the existing ready-state fallback; plugin URL prefill is unchanged. +There are no new Room migrations (schema remains 41), player, layout, session, +DNS/client, dependency, branding or signing changes relative to v1.1.0. + ## Wolfskin shell Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in @@ -680,9 +689,9 @@ Down clamps a short final title row before entering the separate Try again / Sho more row; an empty slot cannot activate recovery. An unavailable library hides its unknown item count. -Caption separators use 40% of the existing muted colour's alpha. The rail follows -the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px, -overriding the spec table's generic caption style for this component. +Library titles and compact subtitles remain visible at rest, with one-line, +card-width ellipsis rather than a 640 px focused caption. The letter rail uses +bold 24 px letters, with the focused letter bold 28 px. Library and Settings header initials use the muted colour over the person's fill; navigation and picker avatars retain their dark initials. @@ -726,12 +735,35 @@ Title shelves reserve vertical drawing room for the scaled ring. Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. -Poster progress uses the independent 18 px title-card inset, not caption spacing: -the track remains 220 px wide on a 256 px poster. Library focus-follow restarts -when the effective column count, focused row or header geometry changes, retaining -deep focus across live snapshots that add or remove the letter rail. -`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged -focused index across the 199↔200-title boundary. +Cards use the approved option-B bottom state line instead of a separate inset +resume bar: watched is 3 px accent; resume is 6 px proportional accent over +`color.progressTrack`; unwatched and Discover have no line. Opaque background +backs the entire strip, including its bottom 2 px and the 2 px separator above +the fill. Artwork is clipped out of the backing band before painting it so +rounded-corner antialiasing cannot leak poster pixels into the lowest row. +The separately drawn focus ring stays above the line. + +Film subtitles are year + duration + a 24 px watched check, episode subtitles +are their own duration + check, and series subtitles are year + check with no +single runtime. Resuming cards have no check. The poster caption band remains +96 px; Home poster captions are focus-only. Home landscapes retain titles on +the art and reserve 64 px for focused metadata below it, making wide/tile shelf +pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px. +Loading rows reserve the same blank caption bands and share one pulse layer; +only art-sized blocks are painted, with no title/subtitle placeholder pills. + +`CardBottomPixelTest` samples full native bottom rows on four real posters at +rest and individually focused, including white Annie Hall and dark Locke. +`CardGeometryTest` measures 210/327 px caption fitting, card-width titles, +24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks +that every reserved loading-caption pixel remains background-coloured. +`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused +caption changes title directly or while moving across a Home shelf. +The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/) +contains the same illustrative watch-state data as the approved frames and +records renderer/fixture artifacts; unapproved design changes block merge. +`LibrarySpacingRegressionTest` retains the deep focused index across the +199↔200-title letter-rail boundary. `LibraryCaptureTest.spacingShowsFirstFocused`, `HomeCaptureTest.spacingNextUpFirstFocused` and diff --git a/wolfskin-design/build.gradle.kts b/wolfskin-design/build.gradle.kts index 74caf761..edc60ea2 100644 --- a/wolfskin-design/build.gradle.kts +++ b/wolfskin-design/build.gradle.kts @@ -35,7 +35,7 @@ val cacheRobolectricSdk by tasks.registering(Copy::class) { } val checkTokenDrift by tasks.registering { - inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) + inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") }) doLast { file("tokens.sha256").readLines().forEach { line -> val (expected, name) = line.split(" ", limit = 2) diff --git a/wolfskin-design/generate_tokens.py b/wolfskin-design/generate_tokens.py index e18bc364..d036e496 100755 --- a/wolfskin-design/generate_tokens.py +++ b/wolfskin-design/generate_tokens.py @@ -121,9 +121,10 @@ def main() -> None: tokens.update(json.loads((ROOT / "title-tokens.json").read_text())) tokens.update(json.loads((ROOT / "home-tokens.json").read_text())) tokens.update(json.loads((ROOT / "discover-tokens.json").read_text())) + tokens.update(json.loads((ROOT / "update-tokens.json").read_text())) kotlin, css = render_tokens(tokens) outputs = {str(KOTLIN): kotlin, "tokens.css": css} - inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"] + inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py"] checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs} checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()}) outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items())) diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index a574a31d..7cceeaa7 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -114,12 +114,6 @@ "dp": 18.0, "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, - "captionWidth": { - "$type": "dimension", - "px": 640, - "dp": 320.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, "plateTop": { "$type": "dimension", "px": 14, @@ -240,18 +234,6 @@ "dp": 30.0, "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, - "progressInset": { - "$type": "dimension", - "px": 18, - "dp": 9.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, - "progressBottom": { - "$type": "dimension", - "px": 10, - "dp": 5.0, - "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" - }, "endTop": { "$type": "dimension", "px": 40, @@ -316,7 +298,6 @@ "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, - "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, - "posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" } + "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" } } } diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index e66ac6ec..3c29b7ae 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -367,9 +367,6 @@ "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, - "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, - "captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"}, - "captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"}, "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, diff --git a/wolfskin-design/src/main/assets/update-font-license.txt b/wolfskin-design/src/main/assets/update-font-license.txt new file mode 100644 index 00000000..272739c0 --- /dev/null +++ b/wolfskin-design/src/main/assets/update-font-license.txt @@ -0,0 +1,47 @@ +DejaVu Sans and DejaVu Sans Bold +https://dejavu-fonts.github.io/ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. +Bitstream Vera is a trademark of Bitstream, Inc. +DejaVu changes are in public domain. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 3b2b2f8e..065e26e4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -10,18 +10,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.TextStyle import androidx.tv.material3.Text -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.unit.constrainHeight /** Captions rise inside the reserved band without changing measured geometry. */ @Composable -fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false, - titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption, - titleOverflow: TextOverflow = TextOverflow.Ellipsis) { +fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { val alpha = remember(title, facts) { Animatable(0f) } val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) } val reduced = reducedMotion() @@ -32,47 +25,10 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = if (visible) entryProgress.floatValue = value } } + val titleStyle = WolfskinTokens.Type.button + val factsStyle = WolfskinTokens.Type.caption Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) { - Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) - CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) - } -} - -/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */ -@Composable -private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) { - val parts = remember(facts) { facts.split(" · ") } - val muted = WolfskinTokens.Color.muted - val colour = WolfskinTokens.Color.accentText - Layout( - content = { - parts.forEachIndexed { index, part -> - val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) - Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip) - if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style)) - } - }, - modifier = Modifier.fillMaxWidth().clipToBounds(), - ) { children, constraints -> - val gap = WolfskinTokens.Library.captionFactsGap.roundToPx() - val basis = IntArray(children.size) - var fixed = gap * (children.size - 1).coerceAtLeast(0) - var flexible = 0 - fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast) - children.forEachIndexed { index, child -> - val width = child.maxIntrinsicWidth(constraints.maxHeight) - basis[index] = width - if (shrinks(index)) flexible += width else fixed += width - } - val available = (constraints.maxWidth - fixed).coerceAtLeast(0) - val ratio = if (flexible > available) available.toFloat() / flexible else 1f - val measured = children.mapIndexed { index, child -> - val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index] - child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0)) - } - layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) { - var x = 0 - measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap } - } + Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt new file mode 100644 index 00000000..9d6f28a8 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt @@ -0,0 +1,81 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.res.painterResource +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M +import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C + +/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */ +@Composable +fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean, + modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) { + val transition = if (focusOnly) { + val alpha = remember(title) { Animatable(0f) } + val entryProgress = remember(title) { mutableFloatStateOf(0f) } + val reduced = reducedMotion() + val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() } + LaunchedEffect(alpha, focused, reduced) { + if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong()) + alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) { + if (focused) entryProgress.floatValue = value + } + } + Modifier.graphicsLayer { + this.alpha = alpha.value + translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) + } + } else Modifier + Column(modifier.then(transition)) { + if (showTitle) { + val style = if (focused || focusOnly) M.titleFocus else M.titleRest + Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style, + modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + CardMetadata(year, duration, watched) + } +} + +/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */ +@Composable +fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) { + Layout(content = { + if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1) + if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1) + if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted) + }, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints -> + val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) } + val gaps = (children.size - 1).coerceAtLeast(0) + val textWidth = sizes.sum() + val gap = when { + textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx() + textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx() + else -> M.minimumGap.roundToPx() + } + val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) } + val height = with(this) { M.subtitle.lineHeight.toPx().toInt() } + layout(constraints.maxWidth, height) { + var left = 0 + measured.forEach { child -> + child.placeRelative(left, (height - child.height) / 2) + left += child.width + gap + } + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt index 4f1d281a..1600e51c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CssLineBox.kt @@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier { /** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */ @Composable -internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { +fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) { val exact = remember { CssColumnHeight() } Layout(content, modifier.then(exact)) { children, constraints -> val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 2551560a..ed527a73 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { LazyRow(state = cards, userScrollEnabled = false, contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap), - modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) { + modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) { if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) @@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } else if (lane.pending) { items(slots, key = { "pending:$it" }) { - Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) + PosterCaptionSkeleton() } } else { itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> @@ -80,15 +80,16 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } } - if (!services && active && !lane.pending) { + if (!services && !lane.pending) { val item = lane.titles.getOrNull(column) - Caption( - item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", - if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", - true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), - accentFirstFact = lane.hidden, - accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), - ) + val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW) + if (item != null) { + val card = item.libraryCard(state) + CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true) + } else Caption( + if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", + if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", + active, captionModifier) } } } @@ -108,7 +109,3 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick) } -internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String { - val card = title.libraryCard(state) - return (if (hidden) "Hidden from your picks · " else "") + card.facts(true) -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt index 5a9227d3..0039ab4a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt @@ -11,6 +11,9 @@ import androidx.tv.material3.Icon fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { val resource = when (name) { "gear" -> R.drawable.wolfskin_gear + "updates" -> R.drawable.wolfskin_update + "settings-list" -> R.drawable.wolfskin_settings_list + "settings-right" -> R.drawable.wolfskin_settings_chevron "requests" -> R.drawable.wolfskin_tray "interface" -> R.drawable.wolfskin_grid "play", "playback" -> R.drawable.wolfskin_play diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt index 81fbd5eb..8bf7d6e2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt @@ -38,7 +38,6 @@ data class HomeTitle( } val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left" val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}" - val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ") } /** Only observed, range-checked scores reach this model; formatting follows the TV locale. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 542e0704..11725f4a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -119,7 +119,7 @@ fun HomeScreen( private fun rowHeight(row: HomeRow) = when (row.kind) { HomeRowKind.Hero -> L.row_hero - HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter + HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter HomeRowKind.Section -> L.row_section HomeRowKind.State -> L.row_state HomeRowKind.Note -> L.row_note diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index 6bf8dea4..d1827758 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -20,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.testTag -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex @@ -36,7 +34,7 @@ import org.justinvisser.wolfskin.design.HomeType as T fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { val width = row.cardWidth val height = row.cardHeight - val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero + val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } @@ -50,39 +48,36 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> val selected = !row.skeleton && focused && column == index val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) - if (title == null && !row.skeleton) { + if (row.skeleton) { + PosterCaptionSkeleton(width, height, caption) + } else if (title == null) { LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) - } else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) { - if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else { + } else { + val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat() + TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height, + watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) { TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster) if (row.cardKind != HomeCardKind.Poster) TileOverlay(title) if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) } - if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { - Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent)) - } HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth()) } } } } - if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) { + if (!row.skeleton) { val title = row.cards.getOrNull(column) - val availability = when { - title == null -> null - row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" } - title.played -> "Watched" - title.resumeSeconds > 0 -> title.remaining - else -> null - } - val facts = remember(title, row.total, availability) { - (title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty() - } - Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis) + val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width) + if (title == null) { + if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier) + } else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year, + title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime }, + !row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier, + focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster) } } } @@ -90,9 +85,11 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In @Composable private fun TileOverlay(title: HomeTitle) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { - Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { - Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + val metadata = WolfskinTokens.CardMetadata + Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth() + .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))) + .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { + Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt index 266cbded..6aec4bf2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Identity.kt @@ -21,19 +21,24 @@ fun personColor(key: String): Color = when (key.lowercase()) { else -> WolfskinTokens.Color.accent } +/** Updates has its own locked avatar swatch; other destinations retain household colours. */ +fun resolveUpdateAvatarColor(person: ShellPerson): Color = + if (person.key.equals("justin", ignoreCase = true)) WolfskinTokens.Update.avatarJustin else personColor(person.key) + /** The same mark anchors the picker and the persistent navigation. */ @Composable -fun Wordmark(modifier: Modifier = Modifier) { +fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) { Row(modifier) { - Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark) - Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark) + Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle) + Text("skin", color = WolfskinTokens.Color.accent, style = textStyle) } } /** Initials are drawn over opaque person colour at the exact slot size. */ @Composable -fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus) { - Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) { +fun Avatar(person: ShellPerson, diameter: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letterColor: Color = WolfskinTokens.Color.onFocus, + backgroundColor: Color = personColor(person.key)) { + Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) { Text(person.initial, color = letterColor, style = textStyle) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index 2b6bcc1a..d4f09754 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -18,6 +18,7 @@ interface PosterContent { val poster: String? val ratings: List val progress: Float + val posterWatched: Boolean val badge: String? } @@ -40,18 +41,15 @@ data class LibraryTitle( ) : PosterContent { override val posterTitle: String get() = title override val posterIsMovie: Boolean get() = type == "movie" + override val posterWatched: Boolean get() = played + val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let { + val minutes = (it + 30) / 60 + if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" + } else null val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) val catalogueKey: String = "$type:${tmdbId ?: id}" val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0 - - fun facts(catalogue: Boolean): String = buildList { - year?.let { add(it.toString()) } - add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series") - if (catalogue) { if (availability.isNotEmpty()) add(availability) } - else if (played) add("Watched") - else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left") - }.joinToString(" · ") } /** Ordered rating entries are formatted after the active TV locale is known. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index 41e1e053..e3df4802 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -25,9 +25,12 @@ fun LibraryPoster( catalogue: Boolean, modifier: Modifier = Modifier, offlineArt: ((String) -> ImageBitmap?)? = null, + showWatchState: Boolean = !catalogue, ) { val poster = title.poster - TitleCard(focused, modifier.testTag("poster-${title.id}")) { + val progress = if (showWatchState) title.progress else 0f + TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched, + progress = if (progress >= 1f) 0f else progress) { val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) else if (poster != null && offlineArt == null) { @@ -39,13 +42,6 @@ fun LibraryPoster( onSuccess = { ready = true }, onError = { ready = false }) } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) - val progress = title.progress - if (progress > 0f && progress < 1f) { - Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap) - .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) { - Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) - } - } if (catalogue && title.badge != null) { val held = title.badge == "held" val color = if (title.badge == "failed") C.fail else C.accent diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 393620c3..93c55c85 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -121,7 +121,7 @@ fun LibraryScreen( LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) { Box(Modifier.fillMaxWidth().height(rowHeight)) { Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } + repeat(grid.columns) { PosterCaptionSkeleton() } } } } @@ -139,6 +139,7 @@ fun LibraryScreen( val title = items.getOrNull(index) ?: break key(if (grid.catalogue) title.catalogueKey else title.id) { val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index + Box(Modifier.width(K.posterW)) { LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { awaitPointerEventScope { @@ -150,15 +151,13 @@ fun LibraryScreen( } } }.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) + CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration, + !grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused, + Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW)) + } } } } - if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) { - val title = items.getOrNull(grid.index) - if (title != null) Caption(title.title, title.facts(grid.catalogue), true, - Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth), - accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f) - } } } if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") { @@ -240,7 +239,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) { Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide)) Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, - Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) + Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt index 93aa9887..e7607e04 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt @@ -46,7 +46,7 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) { Column { Spacer(Modifier.height(heading)) Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) } + repeat(K.posterSlots) { PosterCaptionSkeleton() } } } } @@ -55,3 +55,13 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) { private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) { Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } + +/** Loading reserves the ready caption band without painting unapproved text placeholders. */ +@Composable +internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW, + height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) { + Column(Modifier.width(width)) { + SkeletonBlock(width, height) + Spacer(Modifier.height(band)) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 40b8563c..17b0af02 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -40,15 +40,16 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V /** Blob bounds retain their measurement while their draw coordinates spring between targets. */ data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp) -fun navBoxes(tabs: List): List { - var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap +fun navBoxes(tabs: List, isUpdatesLayout: Boolean = false): List { + val updates = WolfskinTokens.Update + var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap return tabs.map { tab -> val width = when (tab) { - "home" -> S.navHomeWidth - "discover" -> S.navDiscoverWidth - "movies" -> S.navMoviesWidth - "shows" -> S.navShowsWidth - else -> S.navAnimeWidth + "home" -> if (isUpdatesLayout) updates.navHomeWidth else S.navHomeWidth + "discover" -> if (isUpdatesLayout) updates.navDiscoverWidth else S.navDiscoverWidth + "movies" -> if (isUpdatesLayout) updates.navMoviesWidth else S.navMoviesWidth + "shows" -> if (isUpdatesLayout) updates.navShowsWidth else S.navShowsWidth + else -> if (isUpdatesLayout) updates.navAnimeWidth else S.navAnimeWidth } NavBox(left, S.navItemTop, width, L.navItemHeight).also { left += width + L.navItemGap } } + NavBox(S.stageWidth - L.safeSide - L.navAvatar, S.navItemTop, L.navAvatar, L.navAvatar) @@ -57,7 +58,8 @@ fun navBoxes(tabs: List): List { /** There is one blob, including when focus wraps around the nav avatar. */ @Composable fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) { - val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) } + val hasSelectedTab = state.route != "settings" && state.route != "updates" + val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) } val reduced = reducedMotion() val labelFade = tween(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f)) val index = if (state.navFocused) state.navIndex else state.currentTab @@ -79,14 +81,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) Box(modifier.fillMaxWidth().height(L.navBand)) { Canvas(Modifier.matchParentSize()) { if (scrolled) drawRect(C.bg) else drawRect(band) - drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) + if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) } - Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) + Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf", + if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark) state.tabs.forEachIndexed { itemIndex, tab -> val item = boxes[itemIndex] val focused = state.navFocused && state.navIndex == itemIndex val labelColor by animateColorAsState( - if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted, + if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted, animationSpec = labelFade, label = "nav-label-$tab", ) @@ -95,8 +98,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) .clickable { state.focusNav(itemIndex); state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { - Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor) - if (itemIndex == state.currentTab && !focused) { + Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor) + if (hasSelectedTab && itemIndex == state.currentTab && !focused) { Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent)) } } @@ -111,10 +114,11 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) } } val avatar = boxes.last() - Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, + Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav, Modifier.offset(avatar.left, avatar.top) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) .focusProperties { canFocus = false } - .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) + .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }, + backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index ea95f24b..7da2fba8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { row.forEachIndexed { column, item -> val index = rowIndex * columns + column - TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } + TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt index 08c79cd8..fe7adad1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SettingsList.kt @@ -9,13 +9,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape @@ -36,50 +35,53 @@ import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S +import org.justinvisser.wolfskin.design.UpdateTypography as T /** Settings routes identify real upstream destinations rather than stand-in panels. */ -data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false) +data class SettingsGroup(val key: String, val title: String, val description: String) val SETTINGS_GROUPS = listOf( - SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"), - SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"), - SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"), - SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"), - SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true), - SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true), - SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true), - SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), + SettingsGroup("requests", "Requests", "Coming in: what the household asked for"), + SettingsGroup("updates", "Updates", "Check and install"), + SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"), + SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"), + SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"), + SettingsGroup("profile", "User profile settings", "Protection · Home · Language"), + SettingsGroup("about", "About", "Installed version · Wolfskin about"), + SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"), + SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"), + SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"), ) /** The count describes returned request history, not an inferred queue of active downloads. */ @Composable -fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) { +fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { val list = state.view.listState + val reduced = reducedMotion() val density = LocalDensity.current LaunchedEffect(state.settingsRow, state.navFocused) { if (state.navFocused) return@LaunchedEffect - val y = L.row_lhead + L.row_srow * state.settingsRow - val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero - val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1) - val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset - list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) + val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) + val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset + list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) } - LazyColumn(modifier.fillMaxSize(), state = list, - contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) { - item(key = "settings-header") { - Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { - Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted) - Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg) - Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) - } + Box(modifier.fillMaxSize()) { + Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) { + Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(state.person)) + Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg) + Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted) } - itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> - Box(Modifier.height(L.row_srow)) { - SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, - if (group.key == "requests") requestCount else null, - Modifier.offset(L.safeSide, S.settingsRowTop)) { - state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop) + .height(WolfskinTokens.Update.rowsViewport + L.settingsRowHeight * (WolfskinTokens.Focus.scaleRow - 1f)), + state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) { + itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> + Box(Modifier.height(L.row_srow)) { + val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group + SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow, + if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) { + state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) + } } } } @@ -94,15 +96,14 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) { - Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) + Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Column(Modifier.weight(1f)) { val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: "" - Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) - Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1) + Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } - if (group.wolfskin) SettingsPill("Wolfskin", focused) - SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused) - Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron)) + SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused) + Glyph("settings-right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) } } @@ -122,6 +123,6 @@ private fun SettingsPill(label: String, focused: Boolean) { drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline) } }.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) { - Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) + Text(label, color = foreground, style = T.caption.copy(fontWeight = T.label.fontWeight)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt index c770a3ab..2992e93f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellState.kt @@ -260,9 +260,13 @@ class ShellState( } } else if (route == "settings") { val group = SETTINGS_GROUPS[settingsRow].key - if (group == "requests") openRequests() else { - handOff = group - onAction(ShellAction.HandOff(group)) + when (group) { + "requests" -> openRequests() + "updates" -> onAction(ShellAction.Destination("updates")) + else -> { + handOff = group + onAction(ShellAction.HandOff(group)) + } } } else contentFocus?.key(ShellKey.Ok) @@ -305,6 +309,7 @@ class ShellState( gearFocused = true pickerIndex = people.lastIndex } else if (route !in tabs) { + if (route == "updates") navFocused = false onAction(ShellAction.Pop) } else if (!navFocused) { navFocused = true diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt index 603942b2..cf1da9c4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/ShellSurface.kt @@ -56,6 +56,7 @@ fun ShellSurface( modifier: Modifier = Modifier, handlesContentKeys: Boolean = true, settingsRequestCount: Int? = null, + settingsInstalledVersion: String = "", overlay: @Composable () -> Unit = {}, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, ) { @@ -91,7 +92,7 @@ fun ShellSurface( if (state.pickerVisible) { WhosWatching(state) } else { - if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) + if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) if (state.requestsVisible) ComingInSheet(state) overlay() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index fd581f12..ea760054 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -9,15 +9,51 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp +// Precompose before packing into Color: packing first loses the browser's truncated channel fractions. +private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track -> + val backing = WolfskinTokens.Color.bg + val inverse = 1f - track.alpha + Color( + ((track.red * track.alpha + backing.red * inverse) * 255).toInt(), + ((track.green * track.alpha + backing.green * inverse) * 255).toInt(), + ((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(), + ) +} + /** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */ @Composable fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, - height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { + height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, + content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { - Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) + Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) + .drawWithCache { + val edge = WolfskinTokens.CardMetadata.bottom.toPx() + val fill = when { + progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx() + watched -> WolfskinTokens.CardMetadata.watchedLine.toPx() + else -> 0f + } + val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f + onDrawWithContent { + clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() } + drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing)) + if (fill > 0f) { + val top = Offset(0f, size.height - edge - fill) + if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill)) + drawRect(WolfskinTokens.Color.accent, top, + Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill)) + } + } + }, content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 4630ce75..1fb71dd9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -30,6 +30,7 @@ data class TitleItem( ) : PosterContent { override val posterTitle: String get() = name override val posterIsMovie: Boolean get() = kind == "movie" + override val posterWatched: Boolean get() = watched override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f override val badge: String? get() = if (held) "held" else null val episodeLabel: String get() = "S$season E$episode" diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index fdb719df..429a38ce 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -4,7 +4,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween import androidx.compose.ui.platform.LocalDensity -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -107,53 +106,36 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { } } -/** Posters draw art at rest, with facts only in the reserved focused-caption band. */ +/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */ @Composable -fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { +fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current Column(modifier.zIndex(if (focused) 1f else 0f)) { if (!tile) { val bitmaps = LocalTitleBitmaps.current val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } - LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, + LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) - } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) - .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { + } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, + WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, + progress = item.progress.takeIf { it < 1f } ?: 0f) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) - } - val progress = item.progress - if (progress > 0f && progress < 1f) { - Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom) - .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { - Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) - } + if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0) } androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) } } if (!tile) { - val availability = androidx.compose.runtime.remember(item, catalogue) { - if (catalogue) { - if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } - } else if (item.watched) "Watched" - else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left" - else null - } - val facts = androidx.compose.runtime.remember(item, availability) { - listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), - if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", - availability).joinToString(" · ") - } - Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { - Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth), - accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank()) + Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) { + CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null, + item.watched && item.resumeSeconds <= 0, focused, + Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt new file mode 100644 index 00000000..e777372e --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/UpdateTypography.kt @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight + +/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */ +object UpdateTypography { + val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold)) + private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + private val platform = PlatformTextStyle(includeFontPadding = false) + private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform) + val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle) + val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle) + val heading = resolveStyle(WolfskinTokens.Update.heading) + val nav = resolveStyle(WolfskinTokens.Type.nav) + val wordmark = resolveStyle(WolfskinTokens.Type.wordmark) + val listTitle = resolveStyle(WolfskinTokens.Type.listTitle) + val body = resolveStyle(WolfskinTokens.Type.body) + val button = resolveStyle(WolfskinTokens.Type.button) + val label = resolveStyle(WolfskinTokens.Type.label) + val caption = resolveStyle(WolfskinTokens.Type.caption) + val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav) + val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 35ac83e3..e2103e63 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -11,6 +11,20 @@ import androidx.compose.ui.unit.sp /** The locked TV design, converted at the source density. */ object WolfskinTokens { + object CardMetadata { + val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em) + val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em) + val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em) + val check = 12.0.dp + val gap = 6.0.dp + val compactGap = 4.0.dp + val minimumGap = 2.0.dp + val bottom = 1.0.dp + val separator = 1.0.dp + val watchedLine = 1.5.dp + val resumeLine = 3.0.dp + val landscapeBand = 32.0.dp + } object Color { val bg = ComposeColor(0xFF07070A) val surface = ComposeColor(0xFF13131A) @@ -173,13 +187,11 @@ object WolfskinTokens { val tileH = 112.5.dp val tileGap = 20.0.dp val tilePitch = 220.0.dp - val tileCaptionBand = 0.0.dp const val tileSlots = 4 val wideW = 272.0.dp val wideH = 153.0.dp val wideGap = 20.0.dp val widePitch = 292.0.dp - val wideCaptionBand = 0.0.dp const val wideSlots = 3 val entryW = 128.0.dp val entryH = 72.0.dp @@ -209,6 +221,9 @@ object WolfskinTokens { const val fillInverse = "background color.fg, text color.onFocus" } object Motion { + const val updateScan = 1800 + const val updateSheen = 1600 + val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f) const val skeletonCycle = 1400 const val skeletonLowAlpha = 0.55f const val focusMove = 180 @@ -379,9 +394,6 @@ object WolfskinTokens { val chipGap = 8.0.dp val chipTop = 8.0.dp val chipIcon = 13.0.dp - val captionWidth = 320.0.dp - const val captionSeparatorOpacity = 0.4f - val captionFactsGap = 9.0.dp val badgeIcon = 14.0.dp val plateTop = 7.0.dp val plateSide = 8.0.dp @@ -465,7 +477,6 @@ object WolfskinTokens { val personGap = 24.0.dp val personNameBottom = 5.0.dp val personNoteBottom = 3.0.dp - val captionWidth = 320.0.dp val sheetPaddingTop = 40.0.dp val sheetPaddingSide = 32.0.dp val sheetGap = 9.0.dp @@ -552,8 +563,6 @@ object WolfskinTokens { val tileOverlaySide = 11.0.dp val tileOverlayBottom = 9.0.dp const val cardOverlayOpacity = 0.92f - val cardProgressInset = 9.0.dp - val tileProgressBottom = 5.0.dp val ambientInset = 40.0.dp const val ambientScale = 1.1f val posterShadowRadius = 40.0.dp @@ -591,7 +600,6 @@ object WolfskinTokens { val statePaddingY = 17.0.dp val stateGap = 4.0.dp val stateIcon = 18.0.dp - val captionWidth = 320.0.dp val plateTop = 7.0.dp val plateSide = 8.0.dp val plateFade = 23.0.dp @@ -612,8 +620,6 @@ object WolfskinTokens { val heroSheetTop = 75.0.dp val cardBlur = 13.0.dp val heroBlur = 30.0.dp - val progressInset = 9.0.dp - val progressBottom = 5.0.dp val endTop = 20.0.dp val frameBottom = 40.0.dp val toastPadding = 14.0.dp @@ -634,7 +640,6 @@ object WolfskinTokens { val savedGap = 5.0.dp val savedIcon = 14.0.dp val savedRadius = 14.0.dp - val posterProgressBottom = 6.0.dp } object Discover { val shelfHeadingBand = 52.0.dp @@ -698,4 +703,48 @@ object WolfskinTokens { const val ambientSaturation = 1.2f const val ambientBrightness = 0.55f } + object Update { + val avatarJustin = ComposeColor(0xFF7AB8FF) + val wordmarkWidth = 81.796875.dp + val navHomeWidth = 74.6640625.dp + val navDiscoverWidth = 99.0703125.dp + val navMoviesWidth = 85.2578125.dp + val navShowsWidth = 80.578125.dp + val navAnimeWidth = 79.234375.dp + val notesListTop = 14.0.dp + val notesListBottom = 14.0.dp + val notesMarkerOffset = 5.5.dp + val notesMarkerWidth = 14.5.dp + val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em) + const val settingsVisibleRows = 6.0f + val headerTop = 75.0.dp + val headerHeight = 42.0.dp + val rowsTop = 139.0.dp + val rowsViewport = 362.0.dp + val contentTop = 149.0.dp + val width = 650.0.dp + val padding = 20.0.dp + val border = 1.0.dp + val radius = 11.0.dp + val titleBottom = 8.0.dp + val paragraphBottom = 7.0.dp + val actionsTop = 18.0.dp + val actionGap = 12.0.dp + val progressTop = 15.0.dp + val progressBottom = 11.0.dp + val progressHeight = 6.0.dp + val progressRadius = 4.0.dp + val autoTop = 431.0.dp + val toggleWidth = 40.0.dp + val toggleHeight = 21.0.dp + val toggleThumb = 15.0.dp + val toggleInset = 3.0.dp + val notesHeight = 256.0.dp + val notesGap = 9.0.dp + val notesScrollStep = 40.0.dp + val backTop = 92.0.dp + const val scanFraction = 0.28f + const val scanEnd = 4.6f + const val sheenAlpha = 0.35f + } } diff --git a/wolfskin-design/src/main/res/drawable/card_watched_check.xml b/wolfskin-design/src/main/res/drawable/card_watched_check.xml new file mode 100644 index 00000000..1dd41eb0 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/card_watched_check.xml @@ -0,0 +1,4 @@ + + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml b/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml new file mode 100644 index 00000000..bbf83f40 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_settings_chevron.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml new file mode 100644 index 00000000..362cc295 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_settings_list.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_update.xml b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml new file mode 100644 index 00000000..cfe21b10 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/wolfskin_update.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf new file mode 100644 index 00000000..1728e9ec Binary files /dev/null and b/wolfskin-design/src/main/res/font/update_dejavu_sans.ttf differ diff --git a/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf b/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf new file mode 100644 index 00000000..0a7fb3f0 Binary files /dev/null and b/wolfskin-design/src/main/res/font/update_dejavu_sans_bold.ttf differ diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt new file mode 100644 index 00000000..5bbc98ce --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt @@ -0,0 +1,135 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class CardBottomPixelTest { + @get:Rule val rule = createAndroidComposeRule() + + @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() } + } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt new file mode 100644 index 00000000..ee5b9d57 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt @@ -0,0 +1,121 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import android.view.View +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** Changing a shelf's selected identity must restart its focus-only caption reveal. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class CardCaptionMotionTest { + @get:Rule val rule = createAndroidComposeRule() + + @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() } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt new file mode 100644 index 00000000..be217089 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt @@ -0,0 +1,169 @@ +package org.justinvisser.wolfskin.design + +import android.app.Application +import android.graphics.BitmapFactory +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.toArgb +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.dp +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class CardGeometryTest { + @get:Rule val rule = createComposeRule() + + @Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() { + val width = mutableStateOf(105.dp) + rule.setContent { WolfskinTheme { + CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true, + Modifier.width(width.value).testTag("caption")) + } } + for (pixels in listOf(210, 327)) { + rule.runOnIdle { width.value = (pixels / 2f).dp } + rule.waitForIdle() + val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node -> + val results = mutableListOf() + 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() } + } +} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt index 79e89277..a3d8a958 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt @@ -11,23 +11,12 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ +/** Live letter-rail changes preserve deep selection and poster geometry. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") class LibrarySpacingRegressionTest { @get:Rule val rule = createComposeRule() - @Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() { - val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f) - rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } } - val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot - val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot - assertEquals(256f, poster.width, .1f) - assertEquals(220f, track.width, .1f) - assertEquals(18f, track.left - poster.left, .1f) - assertEquals(18f, poster.right - track.right, .1f) - } - @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt index 6b508fd3..21a783d8 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt @@ -68,7 +68,7 @@ class ShellCaptureTest { @Test fun st014_discoverNav() = capture("st-014") { it.pickerVisible = false; it.navFocused = true; it.navIndex = 1 } @Test fun st091_settings() = capture("st-091") { it.pickerVisible = false; it.enterDestination("settings") } @Test fun st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") } - @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } + @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 5 } } /** Tests and debug builds share the same shell-only projection of the locked fixture. */ diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt index 77052d6f..3cb4445c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellKeysTest.kt @@ -46,6 +46,22 @@ class ShellKeysTest { assertFalse("Requests content, not the nav, must own focus", state.navFocused) } + @Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() { + val actions = mutableListOf() + val shell = ShellState(fixturePeople(), "justin", actions::add).apply { + pickerVisible = false + enterDestination("settings") + } + rule.setContent { WolfskinTheme { ShellSurface(shell) } } + rule.onNodeWithTag("wolfskin-shell").performKeyInput { + pressKey(Key.DirectionDown) + pressKey(Key.DirectionCenter) + } + assertEquals(1, shell.settingsRow) + assertNull(shell.handOff) + assertEquals(listOf(ShellAction.Destination("updates")), actions) + } + @Test fun settingsBackBackInTenPresses() { replay(settings + listOf(Key.Back, Key.Back), 10) assertEquals("home", state.route) @@ -54,10 +70,10 @@ class ShellKeysTest { assertEquals(state.tabs.size, state.navIndex) } - @Test fun playbackHandOffInElevenPresses() { - replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11) + @Test fun playbackHandOffInTwelvePresses() { + replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12) assertEquals("playback", state.handOff) - assertEquals(2, state.settingsRow) + assertEquals(3, state.settingsRow) } @Test fun onlyLastPersonReachesGearAndUpReturnsThere() { diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 156054d0..309913d4 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -23,7 +23,6 @@ "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, "personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"}, "personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"}, - "captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"}, "sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"}, "sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"}, "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, @@ -110,8 +109,6 @@ "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, - "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, - "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, "ambientScale": {"$value": 1.1, "source": "index.html:96"}, "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index bd65a73d..1c06609a 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -1,5 +1,17 @@ /* Generated from the locked TV token sources. */ :root { + --cardMetadata-titleRest: 28.0px; + --cardMetadata-titleFocus: 28.0px; + --cardMetadata-subtitle: 24.0px; + --cardMetadata-check: 24.0px; + --cardMetadata-gap: 12.0px; + --cardMetadata-compactGap: 8.0px; + --cardMetadata-minimumGap: 4.0px; + --cardMetadata-bottom: 2.0px; + --cardMetadata-separator: 2.0px; + --cardMetadata-watchedLine: 3.0px; + --cardMetadata-resumeLine: 6.0px; + --cardMetadata-landscapeBand: 64.0px; --color-bg: #07070a; --color-surface: #13131a; --color-surface2: #1b1b25; @@ -123,6 +135,9 @@ --layout-castCircle: 200.0px; --layout-personPortrait: 240.0px; --layout-row_chipsGrid: 136.0px; + --motion-updateScan: 1800ms; + --motion-updateSheen: 1600ms; + --motion-linear: cubic-bezier(0, 0, 1, 1); --motion-skeletonCycle: 1400ms; --motion-focusMove: 180ms; --motion-captionDelay: 60ms; @@ -242,8 +257,6 @@ --library-chipGap: 16.0px; --library-chipTop: 16.0px; --library-chipIcon: 26.0px; - --library-captionWidth: 640.0px; - --library-captionFactsGap: 18.0px; --library-badgeIcon: 28.0px; --library-plateTop: 14.0px; --library-plateSide: 16.0px; @@ -303,7 +316,6 @@ --title-personGap: 48.0px; --title-personNameBottom: 10.0px; --title-personNoteBottom: 6.0px; - --title-captionWidth: 640.0px; --title-sheetPaddingTop: 80.0px; --title-sheetPaddingSide: 64.0px; --title-sheetGap: 18.0px; @@ -384,8 +396,6 @@ --title-tileOverlayTop: 54.0px; --title-tileOverlaySide: 22.0px; --title-tileOverlayBottom: 18.0px; - --title-cardProgressInset: 18.0px; - --title-tileProgressBottom: 10.0px; --title-ambientInset: 80.0px; --title-posterShadowRadius: 80.0px; --title-posterShadowY: 30.0px; @@ -415,7 +425,6 @@ --home-statePaddingY: 34.0px; --home-stateGap: 8.0px; --home-stateIcon: 36.0px; - --home-captionWidth: 640.0px; --home-plateTop: 14.0px; --home-plateSide: 16.0px; --home-plateFade: 46.0px; @@ -436,8 +445,6 @@ --home-heroSheetTop: 150.0px; --home-cardBlur: 26.0px; --home-heroBlur: 60.0px; - --home-progressInset: 18.0px; - --home-progressBottom: 10.0px; --home-endTop: 40.0px; --home-frameBottom: 80.0px; --home-toastPadding: 28.0px; @@ -457,7 +464,6 @@ --home-savedGap: 10.0px; --home-savedIcon: 28.0px; --home-savedRadius: 28.0px; - --home-posterProgressBottom: 12.0px; --discover-shelfHeadingBand: 104.0px; --discover-sheetPaddingX: 64.0px; --discover-sheetPaddingY: 80.0px; @@ -513,4 +519,42 @@ --discover-stageGap: 10.0px; --discover-ambientInset: 80.0px; --discover-ambientBlur: 60.0px; + --update-avatarJustin: #7ab8ff; + --update-wordmarkWidth: 163.59375px; + --update-navHomeWidth: 149.328125px; + --update-navDiscoverWidth: 198.140625px; + --update-navMoviesWidth: 170.515625px; + --update-navShowsWidth: 161.15625px; + --update-navAnimeWidth: 158.46875px; + --update-notesListTop: 28.0px; + --update-notesListBottom: 28.0px; + --update-notesMarkerOffset: 11.0px; + --update-notesMarkerWidth: 29.0px; + --update-heading: 36.0px; + --update-headerTop: 150.0px; + --update-headerHeight: 84.0px; + --update-rowsTop: 278.0px; + --update-rowsViewport: 724.0px; + --update-contentTop: 298.0px; + --update-width: 1300.0px; + --update-padding: 40.0px; + --update-border: 2.0px; + --update-radius: 22.0px; + --update-titleBottom: 16.0px; + --update-paragraphBottom: 14.0px; + --update-actionsTop: 36.0px; + --update-actionGap: 24.0px; + --update-progressTop: 30.0px; + --update-progressBottom: 22.0px; + --update-progressHeight: 12.0px; + --update-progressRadius: 8.0px; + --update-autoTop: 862.0px; + --update-toggleWidth: 80.0px; + --update-toggleHeight: 42.0px; + --update-toggleThumb: 30.0px; + --update-toggleInset: 6.0px; + --update-notesHeight: 512.0px; + --update-notesGap: 18.0px; + --update-notesScrollStep: 80.0px; + --update-backTop: 184.0px; } diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 28161fbc..b66734ad 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", + "$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", "meta": { "density": 2.0, "stagePx": [ @@ -15,6 +15,20 @@ "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py", "feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json" }, + "cardMetadata": { + "titleRest": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":500,"$value":{"fontWeight":500}}, + "titleFocus": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":700,"$value":{"fontWeight":700}}, + "subtitle": {"$type":"typography","px":24,"sp":12,"lineHeight":1.0833333333333333,"weight":400,"$value":{"fontWeight":400}}, + "check": {"$type":"dimension","px":24,"dp":12}, + "gap": {"$type":"dimension","px":12,"dp":6}, + "compactGap": {"$type":"dimension","px":8,"dp":4}, + "minimumGap": {"$type":"dimension","px":4,"dp":2}, + "bottom": {"$type":"dimension","px":2,"dp":1}, + "separator": {"$type":"dimension","px":2,"dp":1}, + "watchedLine": {"$type":"dimension","px":3,"dp":1.5}, + "resumeLine": {"$type":"dimension","px":6,"dp":3}, + "landscapeBand": {"$type":"dimension","px":64,"dp":32} + }, "color": { "bg": { "$type": "color", @@ -1045,7 +1059,7 @@ "px": 38, "dp": 19, "source": "index.html:1242", - "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" + "usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only" }, "plateInset": { "$type": "dimension", @@ -1202,7 +1216,7 @@ "ratio": "2:3", "fullSlotsPerRow": 6, "source": "index.html:656", - "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" + "usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only" }, "tile": { "$type": "card", @@ -1210,15 +1224,13 @@ "w": 400, "h": 225, "gap": 40, - "pitch": 440, - "captionBand": 0 + "pitch": 440 }, "dp": { "w": 200.0, "h": 112.5, "gap": 20.0, - "pitch": 220.0, - "captionBand": 0.0 + "pitch": 220.0 }, "ratio": "16:9", "fullSlotsPerRow": 4, @@ -1231,15 +1243,13 @@ "w": 544, "h": 306, "gap": 40, - "pitch": 584, - "captionBand": 0 + "pitch": 584 }, "dp": { "w": 272.0, "h": 153.0, "gap": 20.0, - "pitch": 292.0, - "captionBand": 0.0 + "pitch": 292.0 }, "ratio": "16:9", "fullSlotsPerRow": 3, @@ -1390,6 +1400,9 @@ } }, "motion": { + "updateScan": { "$type": "duration", "$value": "1800ms", "source": "approved Updates progress busy scan" }, + "updateSheen": { "$type": "duration", "$value": "1600ms", "source": "approved Updates download highlight" }, + "linear": { "$type": "cubicBezier", "$value": [0, 0, 1, 1] }, "skeletonCycle": { "$type": "duration", "$value": "1400ms" }, "skeletonLowAlpha": { "$type": "number", "$value": 0.55 }, "focusMove": { "$type": "duration", "$value": "180ms" }, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 8d5b523f..2fd24fdd 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,9 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json -c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py -25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json -be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json -faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css -f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json +993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py +eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json +192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json +8efafb988e507e367630212db4af4f40b529be55d0f90577a7be6dd98a86b4d4 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json +5f3b6d52c7ecfe49db59d2bd74acc1781ff7196db62bae3827d8f52b50d65aea tokens.css +3ed41f38aac01a608fb3e61c836180cf1ad76fe7cd1aac8b126f0a4adc0b2db1 tokens.json +19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json diff --git a/wolfskin-design/update-tokens.json b/wolfskin-design/update-tokens.json new file mode 100644 index 00000000..57d2454b --- /dev/null +++ b/wolfskin-design/update-tokens.json @@ -0,0 +1,167 @@ +{ + "update": { + "avatarJustin": {"$type": "color", "$value": "#7ab8ff"}, + "wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875}, + "navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625}, + "navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125}, + "navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125}, + "navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125}, + "navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375}, + "notesListTop": {"$type": "dimension", "px": 28, "dp": 14}, + "notesListBottom": {"$type": "dimension", "px": 28, "dp": 14}, + "notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5}, + "notesMarkerWidth": {"$type": "dimension", "px": 29, "dp": 14.5}, + "heading": { + "$type": "typography", + "$value": {"fontWeight": 800}, + "px": 36, + "sp": 18, + "weight": 800, + "lineHeight": 1.12, + "letterSpacingEm": -0.02 + }, + "settingsVisibleRows": {"$type": "number", "$value": 6}, + "headerTop": { + "$type": "dimension", + "px": 150, + "dp": 75.0 + }, + "headerHeight": { + "$type": "dimension", + "px": 84, + "dp": 42.0 + }, + "rowsTop": { + "$type": "dimension", + "px": 278, + "dp": 139.0 + }, + "rowsViewport": { + "$type": "dimension", + "px": 724, + "dp": 362.0 + }, + "contentTop": { + "$type": "dimension", + "px": 298, + "dp": 149.0 + }, + "width": { + "$type": "dimension", + "px": 1300, + "dp": 650.0 + }, + "padding": { + "$type": "dimension", + "px": 40, + "dp": 20.0 + }, + "border": { + "$type": "dimension", + "px": 2, + "dp": 1.0 + }, + "radius": { + "$type": "dimension", + "px": 22, + "dp": 11.0 + }, + "titleBottom": { + "$type": "dimension", + "px": 16, + "dp": 8.0 + }, + "paragraphBottom": { + "$type": "dimension", + "px": 14, + "dp": 7.0 + }, + "actionsTop": { + "$type": "dimension", + "px": 36, + "dp": 18.0 + }, + "actionGap": { + "$type": "dimension", + "px": 24, + "dp": 12.0 + }, + "progressTop": { + "$type": "dimension", + "px": 30, + "dp": 15.0 + }, + "progressBottom": { + "$type": "dimension", + "px": 22, + "dp": 11.0 + }, + "progressHeight": { + "$type": "dimension", + "px": 12, + "dp": 6.0 + }, + "progressRadius": { + "$type": "dimension", + "px": 8, + "dp": 4.0 + }, + "autoTop": { + "$type": "dimension", + "px": 862, + "dp": 431.0 + }, + "toggleWidth": { + "$type": "dimension", + "px": 80, + "dp": 40.0 + }, + "toggleHeight": { + "$type": "dimension", + "px": 42, + "dp": 21.0 + }, + "toggleThumb": { + "$type": "dimension", + "px": 30, + "dp": 15.0 + }, + "toggleInset": { + "$type": "dimension", + "px": 6, + "dp": 3.0 + }, + "notesHeight": { + "$type": "dimension", + "px": 512, + "dp": 256.0 + }, + "notesGap": { + "$type": "dimension", + "px": 18, + "dp": 9.0 + }, + "notesScrollStep": { + "$type": "dimension", + "px": 80, + "dp": 40.0 + }, + "backTop": { + "$type": "dimension", + "px": 184, + "dp": 92.0 + }, + "scanFraction": { + "$type": "number", + "$value": 0.28 + }, + "scanEnd": { + "$type": "number", + "$value": 4.6 + }, + "sheenAlpha": { + "$type": "number", + "$value": 0.35 + } + } +}