Compare commits
No commits in common. "main" and "fm/tv-updater-core" have entirely different histories.
main
...
fm/tv-upda
67 changed files with 318 additions and 1969 deletions
|
|
@ -8,14 +8,6 @@ Wolfskin is an Android TV client for one household. It is released through Forge
|
|||
|
||||
The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -52,9 +52,6 @@ class SwitchSeerrViewModel
|
|||
_state.update { it.copy(loading = LoadingState.Success) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Nothing to check so set success
|
||||
_state.update { it.copy(loading = LoadingState.Success) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,16 +68,11 @@ import javax.inject.Singleton
|
|||
@Module
|
||||
@TestInstallIn(
|
||||
components = [SingletonComponent::class],
|
||||
replaces = [DeviceModule::class, AppModule::class, org.justinvisser.wolfskin.tv.update.UpdateModule::class],
|
||||
replaces = [DeviceModule::class, AppModule::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(
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ class AnimeAvailabilityTest {
|
|||
}
|
||||
}
|
||||
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
model.load(server, user)
|
||||
requireNotNull(model.onForeground(server, user)).join()
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true))
|
||||
try {
|
||||
val identity = session.identity.value!!
|
||||
val intent = org.justinvisser.wolfskin.design.DiscoverRequest(
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController()).also { models.add(it) }
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
|
||||
}
|
||||
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
|
||||
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
val clients = mutableListOf<ConductarrClient>()
|
||||
try {
|
||||
model.load(server, justin)
|
||||
|
|
@ -163,7 +163,7 @@ class PersonSwitchThreadingTest {
|
|||
coEvery { libraries.readDestinations(any()) } returns emptyMap()
|
||||
val session = WolfskinSession()
|
||||
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
model.load(server, justin)
|
||||
requireNotNull(model.onForeground(server, justin)).join()
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
mockk(relaxed = true), libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
try {
|
||||
rule.setContent {
|
||||
WolfskinApp(server, users.first(), navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
|
||||
|
|
|
|||
|
|
@ -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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
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), org.justinvisser.wolfskin.tv.update.FakeUpdateController())
|
||||
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
|
||||
val pending = CompletableDeferred<Unit>()
|
||||
val release = java.util.concurrent.CountDownLatch(1)
|
||||
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
|
||||
/** Behavior boundaries are controller outcomes, not incidental UI wording. */
|
||||
class UpdateBehaviorTest {
|
||||
@Test fun everyOutcomeHasOnlyExplicitUserActions() {
|
||||
val expected = listOf(
|
||||
UpdateUiState.Idle to listOf(UpdateCommand.Check),
|
||||
UpdateUiState.Checking to listOf(UpdateCommand.Back),
|
||||
UpdateUiState.UpToDate to listOf(UpdateCommand.Check),
|
||||
UpdateUiState.NoReleasePublished to listOf(UpdateCommand.Check),
|
||||
updateReleaseFixture() to listOf(UpdateCommand.Download, UpdateCommand.Notes),
|
||||
UpdateUiState.Downloading(12, 30) to listOf(UpdateCommand.Cancel),
|
||||
UpdateUiState.Ready("1.1.1") to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||
UpdateUiState.PermissionNeeded to listOf(UpdateCommand.Install, UpdateCommand.Cancel),
|
||||
UpdateUiState.HandingOff to listOf(UpdateCommand.Cancel),
|
||||
UpdateUiState.InstallCancelled to listOf(UpdateCommand.Install, UpdateCommand.Notes),
|
||||
UpdateUiState.Offline to listOf(UpdateCommand.Retry),
|
||||
UpdateUiState.NoInstaller to listOf(UpdateCommand.Retry, UpdateCommand.Classic),
|
||||
) + UpdateFailure.entries.map { UpdateUiState.Failed(it, "operation failed", UpdateOperation.Download) to listOf(UpdateCommand.Retry, UpdateCommand.Back) }
|
||||
expected.forEach { (state, commands) ->
|
||||
assertEquals(state.toString(), commands, presentUpdate(state, updateReleaseFixture()).actions.map { it.command })
|
||||
}
|
||||
assertNotEquals(presentUpdate(UpdateUiState.UpToDate, null), presentUpdate(UpdateUiState.NoReleasePublished, null))
|
||||
}
|
||||
|
||||
@Test fun refreshedInstalledIdentityDoesNotRequireOrMaskAChannelCheck() {
|
||||
val installed = "1.1.1"
|
||||
val refreshed = presentUpdate(UpdateUiState.Idle, null, installed)
|
||||
assertNotEquals(presentUpdate(UpdateUiState.Idle, null), refreshed)
|
||||
assertEquals(listOf(UpdateCommand.Check), refreshed.actions.map { it.command })
|
||||
assertEquals(presentUpdate(UpdateUiState.Checking, null),
|
||||
presentUpdate(UpdateUiState.Checking, null, installed))
|
||||
}
|
||||
|
||||
@Test fun percentageRequiresPositiveTotalAndIsBounded() {
|
||||
listOf(null, 0L, -1L).forEach { total ->
|
||||
val ui = presentUpdate(UpdateUiState.Downloading(20_100_000, total), updateReleaseFixture())
|
||||
assertNull(ui.fraction)
|
||||
}
|
||||
assertEquals(.42f, presentUpdate(UpdateUiState.Downloading(20_076_000, 47_800_000), null).fraction!!, .0001f)
|
||||
assertEquals(1f, presentUpdate(UpdateUiState.Downloading(90, 30), null).fraction!!, 0f)
|
||||
}
|
||||
|
||||
@Test fun actionsAndAutoCheckRememberFocusWithoutTogglingOnMovement() {
|
||||
val controller = FakeUpdateController(updateReleaseFixture())
|
||||
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||
focus.key(ShellKey.Right)
|
||||
focus.key(ShellKey.Down)
|
||||
assertTrue(focus.autoFocused)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
focus.key(ShellKey.Ok)
|
||||
assertFalse(controller.autoCheck.value)
|
||||
focus.key(ShellKey.Up)
|
||||
assertFalse(focus.autoFocused)
|
||||
assertEquals(1, focus.actionIndex)
|
||||
assertFalse(controller.autoCheck.value)
|
||||
}
|
||||
|
||||
@Test fun notesTrapFocusAndBackRestoresNotesAction() {
|
||||
val controller = FakeUpdateController(updateReleaseFixture())
|
||||
var backs = 0
|
||||
var navs = 0
|
||||
val steps = mutableListOf<Int>()
|
||||
val focus = UpdateFocus(controller, { backs++ }, {}, { navs++ }, steps::add)
|
||||
focus.update(controller.state.value, presentUpdate(controller.state.value, controller.release.value).actions)
|
||||
focus.key(ShellKey.Right); focus.key(ShellKey.Ok)
|
||||
listOf(ShellKey.Up, ShellKey.Down, ShellKey.Left, ShellKey.Right, ShellKey.Ok).forEach(focus::key)
|
||||
assertTrue(focus.notesOpen)
|
||||
assertEquals(listOf(-1, 1), steps)
|
||||
assertEquals(0, navs)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
focus.key(ShellKey.Back)
|
||||
assertFalse(focus.notesOpen)
|
||||
assertEquals(1, focus.actionIndex)
|
||||
assertEquals(0, backs)
|
||||
focus.key(ShellKey.Up)
|
||||
assertEquals(1, navs)
|
||||
focus.key(ShellKey.Back)
|
||||
assertEquals(1, backs)
|
||||
}
|
||||
|
||||
@Test fun progressUpdatesCannotStealFocusAndInstallerCancellationRestoresInstall() {
|
||||
val controller = FakeUpdateController(UpdateUiState.Downloading(1, 100))
|
||||
val focus = UpdateFocus(controller, {}, {}, {}, {})
|
||||
fun update(state: UpdateUiState) { focus.update(state, presentUpdate(state, null).actions) }
|
||||
update(controller.state.value)
|
||||
focus.key(ShellKey.Down)
|
||||
update(UpdateUiState.Downloading(90, 100))
|
||||
assertTrue(focus.autoFocused)
|
||||
update(UpdateUiState.Ready("1.1.1"))
|
||||
assertFalse(focus.autoFocused)
|
||||
focus.key(ShellKey.Right)
|
||||
update(UpdateUiState.PermissionNeeded)
|
||||
update(UpdateUiState.InstallCancelled)
|
||||
assertEquals(0, focus.actionIndex)
|
||||
assertFalse(focus.autoFocused)
|
||||
}
|
||||
}
|
||||
|
||||
/** Same release facts as the approved television frames. */
|
||||
fun updateReleaseFixture() = UpdateUiState.Available("1.1.1", 47_800_000, "6 Nov 2026",
|
||||
"Updates inside Wolfskin settings.\nKeep sign-ins and preferences.\nClassic settings remains for a trial.")
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performKeyInput
|
||||
import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Native D-pad behavior preserves the retained shell, action focus and real scroller. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class UpdatesKeysTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
private val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
|
||||
pickerVisible = false
|
||||
enterDestination("updates")
|
||||
settingsRow = 1
|
||||
}
|
||||
private lateinit var controller: FakeUpdateController
|
||||
private val reduced = mutableStateOf(false)
|
||||
private val directory get() = System.getProperty("wolfskin.updater.captureDirectory")?.let(::File)
|
||||
|
||||
@Before fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
rule.mainClock.autoAdvance = false
|
||||
}
|
||||
|
||||
private fun launch(state: UpdateUiState, auto: Boolean = true, settings: Boolean = false) {
|
||||
controller = FakeUpdateController(state, auto, InstalledVersion("1.1.0-140-g5162c299", 100000), state as? UpdateUiState.Available ?: updateReleaseFixture())
|
||||
if (settings) shell.enterDestination("settings")
|
||||
rule.setContent {
|
||||
CompositionLocalProvider(LocalReducedMotion provides reduced.value) {
|
||||
WolfskinTheme {
|
||||
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight),
|
||||
settingsInstalledVersion = controller.installedVersion.value.versionName) { modifier ->
|
||||
UpdatesScreen(shell, controller, { shell.navFocused = false; shell.enterDestination("settings") }, {}, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
private fun capture(name: String) {
|
||||
val target = directory ?: return
|
||||
target.mkdirs()
|
||||
rule.waitForIdle()
|
||||
val file = File(target, "$name.png")
|
||||
file.parentFile?.mkdirs()
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
}
|
||||
|
||||
private fun press(keys: List<Key>) {
|
||||
keys.forEach { key ->
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
|
||||
rule.runOnIdle { androidx.compose.runtime.snapshots.Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.waitForIdle()
|
||||
}
|
||||
|
||||
@Test fun classicRequiresEveryRetainedRowAndTheRealScroller() {
|
||||
shell.settingsRow = 0
|
||||
launch(UpdateUiState.Checking, settings = true)
|
||||
repeat(9) { press(listOf(Key.DirectionDown)) }
|
||||
assertEquals(9, shell.settingsRow)
|
||||
assertEquals(4, shell.view.listState.firstVisibleItemIndex)
|
||||
capture("classic")
|
||||
press(listOf(Key.DirectionCenter))
|
||||
assertEquals("classic", shell.handOff)
|
||||
}
|
||||
|
||||
@Test fun notesTrapRealKeysAndBackPreservesSettingsRowAndScroller() {
|
||||
launch(updateReleaseFixture())
|
||||
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionUp, Key.DirectionRight, Key.DirectionDown))
|
||||
assertTrue(shell.contentOverlayVisible)
|
||||
assertFalse(shell.navFocused)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
press(listOf(Key.Back))
|
||||
assertFalse(shell.contentOverlayVisible)
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
press(listOf(Key.Back))
|
||||
assertEquals("settings", shell.route)
|
||||
assertEquals(1, shell.settingsRow)
|
||||
assertEquals(0, shell.view.listState.firstVisibleItemIndex)
|
||||
}
|
||||
|
||||
@Test fun longNotesScrollWithoutEscapingToNavOrAutoCheck() {
|
||||
launch(updateReleaseFixture().copy(notes = (1..30).joinToString("\n") { "Release detail $it" }))
|
||||
press(listOf(Key.DirectionRight, Key.DirectionCenter, Key.DirectionDown, Key.DirectionDown))
|
||||
val range = rule.onNodeWithTag("update-notes").fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange]
|
||||
assertTrue(range.value() > 0f)
|
||||
assertFalse(shell.navFocused)
|
||||
assertFalse((shell.contentFocus as UpdateFocus).autoFocused)
|
||||
press(listOf(Key.Back))
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
}
|
||||
|
||||
@Test fun navAndAutoCheckReturnToTheRememberedActionWithoutToggling() {
|
||||
launch(updateReleaseFixture())
|
||||
press(listOf(Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
press(listOf(Key.DirectionUp))
|
||||
assertTrue(shell.navFocused)
|
||||
press(listOf(Key.DirectionDown))
|
||||
assertFalse(shell.navFocused)
|
||||
assertEquals(1, (shell.contentFocus as UpdateFocus).actionIndex)
|
||||
assertTrue(controller.autoCheck.value)
|
||||
}
|
||||
|
||||
@Test fun refreshingInstalledIdentityCannotMaskAnOngoingCheck() {
|
||||
launch(UpdateUiState.UpToDate)
|
||||
rule.runOnIdle { controller.showInstalled(InstalledVersion("1.1.1", 100001)) }
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.runOnIdle { controller.show(UpdateUiState.Checking) }
|
||||
rule.mainClock.advanceTimeBy(320)
|
||||
rule.onNodeWithTag("update-progress").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun progressKeepsAutoCheckFocusAcrossByteAndReducedMotionChanges() {
|
||||
launch(UpdateUiState.Checking)
|
||||
val target = directory
|
||||
if (target != null) {
|
||||
repeat(113) { frame ->
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
capture("motion/checking-%03d".format(frame))
|
||||
}
|
||||
}
|
||||
rule.runOnIdle { controller.show(UpdateUiState.Downloading(20_100_000, 47_800_000)) }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
repeat(24) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/state-%03d".format(frame)) }
|
||||
if (target != null) {
|
||||
repeat(101) { frame -> rule.mainClock.advanceTimeBy(16); capture("motion/download-%03d".format(frame)) }
|
||||
}
|
||||
press(listOf(Key.DirectionDown))
|
||||
rule.runOnIdle { controller.show(UpdateUiState.Downloading(31_000_000, 47_800_000)) }
|
||||
rule.mainClock.advanceTimeBy(16)
|
||||
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||
rule.runOnIdle { reduced.value = true }
|
||||
rule.mainClock.advanceTimeBy(96)
|
||||
capture("motion/reduced-000")
|
||||
rule.mainClock.advanceTimeBy(1600)
|
||||
capture("motion/reduced-100")
|
||||
assertTrue((shell.contentFocus as UpdateFocus).autoFocused)
|
||||
}
|
||||
}
|
||||
|
|
@ -80,7 +80,6 @@ class WolfskinShellViewModel @Inject constructor(
|
|||
val seeAll: WolfskinSeeAllLists,
|
||||
val conductarr: ConductarrSession,
|
||||
artwork: WolfskinArtwork,
|
||||
val updates: org.justinvisser.wolfskin.tv.update.UpdateController,
|
||||
) : ViewModel() {
|
||||
init { artwork.configure(context) }
|
||||
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
|
||||
|
|
@ -196,7 +195,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" || action.route == "updates" -> navigation.navigateTo(target)
|
||||
action.route == "settings" -> navigation.navigateTo(target)
|
||||
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
|
||||
else -> navigation.replace(target)
|
||||
}
|
||||
|
|
@ -263,7 +262,6 @@ 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) {
|
||||
|
|
@ -305,13 +303,7 @@ 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}",
|
||||
metadata = if (destination is Destination.Wolfskin && destination.route == "updates") {
|
||||
org.justinvisser.wolfskin.tv.update.updatesRouteTransitions
|
||||
} else emptyMap(),
|
||||
) {
|
||||
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
|
||||
// Cached entries must not retain the launch shell after sign-in completes.
|
||||
val state = LocalShellState.current
|
||||
val discover = LocalDiscoverState.current
|
||||
|
|
@ -337,15 +329,11 @@ 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)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ interface UpdateController {
|
|||
val state: StateFlow<UpdateUiState>
|
||||
val autoCheck: StateFlow<Boolean>
|
||||
val installedVersion: StateFlow<InstalledVersion>
|
||||
/** Candidate facts survive download/installer transitions and verified-file restoration. */
|
||||
val release: StateFlow<UpdateUiState.Available?>
|
||||
|
||||
fun check(manual: Boolean)
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.justinvisser.wolfskin.design.ShellContentFocus
|
||||
import org.justinvisser.wolfskin.design.ShellKey
|
||||
|
||||
/** Action focus survives progress emissions; release notes own every key until Back. */
|
||||
class UpdateFocus(
|
||||
private val controller: UpdateController,
|
||||
private val back: () -> Unit,
|
||||
private val classic: () -> Unit,
|
||||
private val nav: () -> Unit,
|
||||
private val scrollNotes: (Int) -> Unit,
|
||||
) : ShellContentFocus {
|
||||
override val cardKey: String? = null
|
||||
var actionIndex by mutableIntStateOf(0)
|
||||
private set
|
||||
var autoFocused by mutableStateOf(false)
|
||||
private set
|
||||
var notesOpen by mutableStateOf(false)
|
||||
private set
|
||||
private var actions = emptyList<UpdateAction>()
|
||||
private var previousState: Class<*>? = null
|
||||
|
||||
/** Only a changed operation may retire an action; progress cannot steal focus. */
|
||||
fun update(state: UpdateUiState, value: List<UpdateAction>) {
|
||||
if (previousState != state.javaClass) {
|
||||
actionIndex = 0
|
||||
notesOpen = false
|
||||
if (state == UpdateUiState.InstallCancelled || state is UpdateUiState.Ready) autoFocused = false
|
||||
}
|
||||
previousState = state.javaClass
|
||||
actions = value
|
||||
actionIndex = actionIndex.coerceAtMost(actions.lastIndex.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
fun focusAction(index: Int) { actionIndex = index; autoFocused = false }
|
||||
|
||||
fun activate(index: Int) {
|
||||
focusAction(index)
|
||||
when (actions[index].command) {
|
||||
UpdateCommand.Back -> back()
|
||||
UpdateCommand.Check -> controller.check(manual = true)
|
||||
UpdateCommand.Download -> controller.download()
|
||||
UpdateCommand.Notes -> notesOpen = true
|
||||
UpdateCommand.Cancel -> controller.cancel()
|
||||
UpdateCommand.Install -> controller.install()
|
||||
UpdateCommand.Retry -> controller.retry()
|
||||
UpdateCommand.Classic -> classic()
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAuto() { autoFocused = true; controller.setAutoCheck(!controller.autoCheck.value) }
|
||||
|
||||
override fun key(key: ShellKey): Boolean {
|
||||
if (notesOpen) {
|
||||
when (key) {
|
||||
ShellKey.Back -> notesOpen = false
|
||||
ShellKey.Up -> scrollNotes(-1)
|
||||
ShellKey.Down -> scrollNotes(1)
|
||||
else -> Unit
|
||||
}
|
||||
return true
|
||||
}
|
||||
when (key) {
|
||||
ShellKey.Back -> back()
|
||||
ShellKey.Up -> if (autoFocused) autoFocused = false else nav()
|
||||
ShellKey.Down -> autoFocused = true
|
||||
ShellKey.Left -> if (!autoFocused) actionIndex = (actionIndex - 1).coerceAtLeast(0)
|
||||
ShellKey.Right -> if (!autoFocused) actionIndex = (actionIndex + 1).coerceAtMost(actions.lastIndex.coerceAtLeast(0))
|
||||
ShellKey.Ok -> if (autoFocused) toggleAuto() else if (actions.isNotEmpty()) activate(actionIndex)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
|
||||
/** Updates animates bounded content only; the retained nav never crossfades as a full scene. */
|
||||
internal val updatesRouteTransitions =
|
||||
NavDisplay.transitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
||||
NavDisplay.popTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) } +
|
||||
NavDisplay.predictivePopTransitionSpec { (EnterTransition.None togetherWith ExitTransition.None).using(null) }
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import java.text.NumberFormat
|
||||
|
||||
/** Screen commands remain explicit; no state transition implicitly installs an APK. */
|
||||
enum class UpdateCommand { Back, Check, Download, Notes, Cancel, Install, Retry, Classic }
|
||||
|
||||
/** An action is a user command, not a focusable progress indicator. */
|
||||
data class UpdateAction(val label: String, val command: UpdateCommand, val primary: Boolean = false)
|
||||
|
||||
/** Plain copy for the process controller's state; bytes and notes remain real controller data. */
|
||||
data class UpdatePresentation(
|
||||
val title: String = "",
|
||||
val summary: String = "",
|
||||
val paragraphs: List<String> = emptyList(),
|
||||
val actions: List<UpdateAction>,
|
||||
val progress: Boolean = false,
|
||||
val fraction: Float? = null,
|
||||
val byteProgress: String? = null,
|
||||
)
|
||||
|
||||
/** Decimal megabytes match release sizes; unknown totals never create a percentage. */
|
||||
fun formatUpdateBytes(bytes: Long): String = NumberFormat.getNumberInstance().apply {
|
||||
minimumFractionDigits = 1
|
||||
maximumFractionDigits = 1
|
||||
}.format(bytes / 1_000_000.0) + " MB"
|
||||
|
||||
/** Metadata uses the controller's locale-formatted release date. */
|
||||
fun updateMetadata(release: UpdateUiState.Available?): String = release?.let {
|
||||
"${it.versionName} · ${formatUpdateBytes(it.bytes)} · ${it.dateText}"
|
||||
}.orEmpty()
|
||||
|
||||
/** Every controller outcome is distinct from a successful compatible version comparison. */
|
||||
fun presentUpdate(state: UpdateUiState, release: UpdateUiState.Available?, updatedVersion: String? = null): UpdatePresentation {
|
||||
val back = UpdateAction("Back", UpdateCommand.Back)
|
||||
val notes = UpdateAction("Release notes", UpdateCommand.Notes)
|
||||
val retry = UpdateAction("Retry", UpdateCommand.Retry, true)
|
||||
val metadata = updateMetadata(release)
|
||||
return when (state) {
|
||||
UpdateUiState.Idle -> updatedVersion?.let(::updatedPresentation)
|
||||
?: UpdatePresentation(actions = listOf(UpdateAction("Check for updates", UpdateCommand.Check, true)))
|
||||
UpdateUiState.Checking -> UpdatePresentation(actions = listOf(back), progress = true)
|
||||
UpdateUiState.UpToDate -> updatedVersion?.let(::updatedPresentation)
|
||||
?: UpdatePresentation("Up to date", "No newer release for this TV.",
|
||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
||||
UpdateUiState.NoReleasePublished -> UpdatePresentation("No release published", "No update has been published for this TV yet.",
|
||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)))
|
||||
is UpdateUiState.Available -> UpdatePresentation("Update available", updateMetadata(state),
|
||||
state.notes.lineSequence().filter { it.isNotBlank() }.take(3).toList(),
|
||||
listOf(UpdateAction("Download", UpdateCommand.Download, true), notes))
|
||||
is UpdateUiState.Downloading -> {
|
||||
val total = state.bytesTotal?.takeIf { it > 0 }
|
||||
val fraction = total?.let { (state.bytesDone.toDouble() / it).coerceIn(0.0, 1.0).toFloat() }
|
||||
val bytes = total?.let { "${formatUpdateBytes(state.bytesDone).removeSuffix(" MB")} / ${formatUpdateBytes(it)}" }
|
||||
?: formatUpdateBytes(state.bytesDone)
|
||||
UpdatePresentation(fraction?.let { "${(it * 100).toInt()}%" }.orEmpty(), metadata,
|
||||
actions = listOf(UpdateAction("Cancel download", UpdateCommand.Cancel)), progress = true, fraction = fraction, byteProgress = bytes)
|
||||
}
|
||||
is UpdateUiState.Ready -> UpdatePresentation("Ready to install", metadata.ifEmpty { state.versionName },
|
||||
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Install update", UpdateCommand.Install, true), notes))
|
||||
UpdateUiState.PermissionNeeded -> UpdatePresentation("Allow updates on this TV", "Enable Wolfskin in Install unknown apps, then return.",
|
||||
actions = listOf(UpdateAction("Open TV settings", UpdateCommand.Install, true), UpdateAction("Not now", UpdateCommand.Cancel)))
|
||||
UpdateUiState.HandingOff -> UpdatePresentation("Continue in Android", "Confirm the update on the system screen.",
|
||||
listOf("Wolfskin closes during installation.", "Choose Open, or reopen Wolfskin from the launcher."), listOf(UpdateAction("Back", UpdateCommand.Cancel)))
|
||||
UpdateUiState.InstallCancelled -> UpdatePresentation("Install cancelled", metadata,
|
||||
listOf("Verified · your sign-ins and settings stay."), listOf(UpdateAction("Try install again", UpdateCommand.Install, true), notes))
|
||||
is UpdateUiState.Failed -> UpdatePresentation(failureTitle(state.kind, state.operation), state.message, actions = listOf(retry, back))
|
||||
UpdateUiState.Offline -> UpdatePresentation("No network", "Reconnect this TV, then try again.",
|
||||
actions = listOf(UpdateAction("Try again", UpdateCommand.Retry, true)))
|
||||
UpdateUiState.NoInstaller -> UpdatePresentation("This TV cannot open an installer", "Android’s install confirmation is unavailable.",
|
||||
listOf("The download is kept. Classic settings may offer the same system limitation."),
|
||||
listOf(retry, UpdateAction("Classic settings (trial)", UpdateCommand.Classic)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatedPresentation(version: String) = UpdatePresentation(
|
||||
"Updated to $version", "Sign-ins and settings kept.",
|
||||
actions = listOf(UpdateAction("Check again", UpdateCommand.Check, true)),
|
||||
)
|
||||
|
||||
private fun failureTitle(kind: UpdateFailure, operation: UpdateOperation): String {
|
||||
if (operation == UpdateOperation.Install) return "Install failed"
|
||||
return when (kind) {
|
||||
UpdateFailure.Network, UpdateFailure.HttpRefused, UpdateFailure.InsufficientStorage ->
|
||||
if (operation == UpdateOperation.Check) "Check failed" else "Download failed"
|
||||
UpdateFailure.InvalidMetadata ->
|
||||
if (operation == UpdateOperation.Check) "Update unavailable" else "Verification failed"
|
||||
UpdateFailure.UnsupportedAbi -> "Update unavailable"
|
||||
UpdateFailure.ChecksumMismatch, UpdateFailure.WrongPackage, UpdateFailure.WrongSigner, UpdateFailure.LowerVersionCode -> "Verification failed"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import org.justinvisser.wolfskin.design.reducedMotion
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
|
||||
|
||||
/** Draw-only bounded track: changing bytes never moves logical or Android focus. */
|
||||
@Composable
|
||||
fun UpdateProgress(fraction: Float?, modifier: Modifier = Modifier) {
|
||||
val reduced = reducedMotion()
|
||||
val phase = if (reduced) 0f else {
|
||||
val transition = rememberInfiniteTransition(label = "update-progress")
|
||||
val animated by transition.animateFloat(0f, 1f,
|
||||
infiniteRepeatable(tween(if (fraction == null) M.updateScan else M.updateSheen,
|
||||
easing = if (fraction == null) M.breatheEase else M.linear), RepeatMode.Restart), label = "highlight")
|
||||
animated
|
||||
}
|
||||
Canvas(modifier.fillMaxWidth().height(U.progressHeight).testTag("update-progress")
|
||||
.semantics { progressBarRangeInfo = fraction?.let { ProgressBarRangeInfo(it, 0f..1f) } ?: ProgressBarRangeInfo.Indeterminate }) {
|
||||
val radius = CornerRadius(U.progressRadius.toPx())
|
||||
drawRoundRect(C.surface3, cornerRadius = radius)
|
||||
val width = size.width * (fraction ?: U.scanFraction)
|
||||
val left = if (fraction != null || reduced) 0f else width * (-1f + (U.scanEnd + 1f) * phase)
|
||||
clipRect {
|
||||
drawRoundRect(C.accent, Offset(left, 0f), Size(width, size.height), radius)
|
||||
if (!reduced && fraction != null && width > 0f) {
|
||||
clipRect(0f, 0f, width, size.height) {
|
||||
val start = width * (-1f + 2f * phase)
|
||||
drawRect(Brush.horizontalGradient(listOf(Color.Transparent, Color.White.copy(alpha = U.sheenAlpha), Color.Transparent),
|
||||
startX = start, endX = start + width), size = Size(width, size.height))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.tv.update
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.tv.material3.Text
|
||||
import kotlinx.coroutines.launch
|
||||
import org.justinvisser.wolfskin.design.Avatar
|
||||
import org.justinvisser.wolfskin.design.CssColumn
|
||||
import org.justinvisser.wolfskin.design.PickerButton
|
||||
import org.justinvisser.wolfskin.design.ShellState
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import org.justinvisser.wolfskin.design.exactLineBox
|
||||
import org.justinvisser.wolfskin.design.focusScale
|
||||
import org.justinvisser.wolfskin.design.motionDuration
|
||||
import org.justinvisser.wolfskin.design.reducedMotion
|
||||
import org.justinvisser.wolfskin.design.resolveUpdateAvatarColor
|
||||
import org.justinvisser.wolfskin.design.rowReveal
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
|
||||
import org.justinvisser.wolfskin.design.UpdateTypography as T
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Update as U
|
||||
|
||||
/** Controller lifetime belongs to the process, while navigation owns this screen's focus. */
|
||||
@Composable
|
||||
fun UpdatesScreen(shell: ShellState, controller: UpdateController, onBack: () -> Unit, onClassic: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val state by controller.state.collectAsStateWithLifecycle()
|
||||
val installed by controller.installedVersion.collectAsStateWithLifecycle()
|
||||
val release by controller.release.collectAsStateWithLifecycle()
|
||||
val autoCheck by controller.autoCheck.collectAsStateWithLifecycle()
|
||||
val initialInstalledCode = remember { installed.versionCode }
|
||||
val updatedVersion = installed.versionName.takeIf { installed.versionCode > initialInstalledCode }
|
||||
val presentation = remember(state, release, updatedVersion) {
|
||||
presentUpdate(state, release, updatedVersion.takeIf { state == UpdateUiState.Idle || state == UpdateUiState.UpToDate })
|
||||
}
|
||||
val scroll = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val density = LocalDensity.current
|
||||
val reduced = reducedMotion()
|
||||
val scrollDuration by rememberUpdatedState(motionDuration(M.scroll, reduced))
|
||||
val focus = remember(controller, shell) {
|
||||
UpdateFocus(controller, onBack, onClassic, { shell.navFocused = true; shell.navIndex = shell.currentTab }) { direction ->
|
||||
scope.launch { scroll.animateScrollBy(with(density) { U.notesScrollStep.toPx() } * direction,
|
||||
tween(scrollDuration, easing = M.easeOut)) }
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state, presentation.actions) { focus.update(state, presentation.actions) }
|
||||
LaunchedEffect(controller) { if (controller.state.value == UpdateUiState.Idle) controller.check(manual = true) }
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { controller.onResume() }
|
||||
DisposableEffect(shell, focus) {
|
||||
shell.contentFocus = focus
|
||||
onDispose { if (shell.contentFocus === focus) shell.contentFocus = null; shell.contentOverlayVisible = false }
|
||||
}
|
||||
LaunchedEffect(focus.notesOpen) { shell.contentOverlayVisible = focus.notesOpen }
|
||||
Box(modifier.fillMaxSize().testTag("updates-screen")) {
|
||||
Row(Modifier.offset(L.safeSide, U.headerTop).height(U.headerHeight),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
|
||||
Avatar(shell.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted, backgroundColor = resolveUpdateAvatarColor(shell.person))
|
||||
Text("Updates", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
|
||||
Text("Installed ${installed.versionName}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
|
||||
}
|
||||
Text("Back · Settings", Modifier.align(Alignment.TopEnd).padding(end = L.safeSide).offset(y = U.backTop), style = T.label.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = C.muted)
|
||||
if (focus.notesOpen) {
|
||||
NotesPanel(release, scroll)
|
||||
} else {
|
||||
UpdatePanel(presentation, focus, !shell.navFocused, state.javaClass)
|
||||
AutoCheckRow(autoCheck, focus.autoFocused && !shell.navFocused, Modifier.offset(L.safeSide, U.autoTop), focus::toggleAuto)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdatePanel(presentation: UpdatePresentation, focus: UpdateFocus, focused: Boolean, identity: Any) {
|
||||
val shape = RoundedCornerShape(U.radius)
|
||||
CssColumn(Modifier.offset(L.safeSide, U.contentTop).width(U.width).rowReveal(identity)
|
||||
.background(C.surface, shape).border(U.border, C.border, shape).padding(U.padding + U.border)) {
|
||||
if (presentation.title.isNotEmpty()) {
|
||||
Text(presentation.title, Modifier.exactLineBox(T.heading), style = T.heading, color = C.fg)
|
||||
Spacer(Modifier.height(U.titleBottom))
|
||||
}
|
||||
if (presentation.summary.isNotEmpty()) {
|
||||
Text(presentation.summary, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
||||
if (presentation.paragraphs.isNotEmpty()) Spacer(Modifier.height(U.paragraphBottom))
|
||||
}
|
||||
presentation.paragraphs.forEachIndexed { index, paragraph ->
|
||||
Text(paragraph, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
||||
if (index != presentation.paragraphs.lastIndex) Spacer(Modifier.height(U.paragraphBottom))
|
||||
}
|
||||
if (presentation.progress) {
|
||||
Spacer(Modifier.height(U.progressTop))
|
||||
UpdateProgress(presentation.fraction)
|
||||
presentation.byteProgress?.let {
|
||||
Spacer(Modifier.height(U.progressBottom))
|
||||
Text(it, Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
||||
}
|
||||
}
|
||||
Row(Modifier.padding(top = U.actionsTop), horizontalArrangement = Arrangement.spacedBy(U.actionGap)) {
|
||||
presentation.actions.forEachIndexed { index, action ->
|
||||
Box(Modifier.testTag("update-action-${action.command}")) {
|
||||
PickerButton(action.label, focused && !focus.autoFocused && focus.actionIndex == index, action.primary,
|
||||
textStyle = T.button, onFocus = { focus.focusAction(index) }) { focus.activate(index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutoCheckRow(enabled: Boolean, focused: Boolean, modifier: Modifier, onToggle: () -> Unit) {
|
||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.listRow)
|
||||
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).testTag("update-auto-check")
|
||||
.semantics { role = Role.Switch; stateDescription = if (enabled) "On" else "Off" }
|
||||
.focusScale(focused, WolfskinTokens.Focus.scaleRow)
|
||||
.background(if (focused) C.fg else C.listRowRest, shape).clickable(onClick = onToggle)
|
||||
.padding(horizontal = S.settingsRowPadding), verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Auto-check for updates", Modifier.exactLineBox(T.listTitle), style = T.listTitle, color = if (focused) C.onFocus else C.fg)
|
||||
Text("${if (enabled) "On" else "Off"} · checks only, never installs", Modifier.exactLineBox(T.caption), style = T.caption, color = if (focused) C.onFocus else C.muted)
|
||||
}
|
||||
Box(Modifier.size(U.toggleWidth, U.toggleHeight).background(if (focused || !enabled) C.surface3 else C.accentSoft, CircleShape)) {
|
||||
Box(Modifier.align(if (enabled) Alignment.CenterEnd else Alignment.CenterStart).padding(horizontal = U.toggleInset)
|
||||
.size(U.toggleThumb).background(if (enabled) C.accentText else C.muted, CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotesPanel(release: UpdateUiState.Available?, scroll: androidx.compose.foundation.ScrollState) {
|
||||
val shape = RoundedCornerShape(U.radius)
|
||||
val paragraphs = remember(release?.notes) { release?.notes?.lineSequence()?.filter { it.isNotBlank() }?.toList().orEmpty() }
|
||||
val notesStyle = T.nav.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal)
|
||||
CssColumn(Modifier.offset(L.safeSide, U.contentTop).size(U.width, U.notesHeight).testTag("update-notes")
|
||||
.drawWithCache {
|
||||
val ring = WolfskinTokens.Focus.ringControlRing.toPx() / 2f
|
||||
val radius = U.radius.toPx()
|
||||
val inset = U.border.toPx() + ring / 2f
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRoundRect(C.accent, Offset(-ring / 2f, -ring / 2f), Size(size.width + ring, size.height + ring),
|
||||
CornerRadius(radius + ring / 2f), style = Stroke(ring))
|
||||
drawRoundRect(C.accent, Offset(inset, inset), Size(size.width - inset * 2, size.height - inset * 2),
|
||||
CornerRadius(radius - inset), style = Stroke(ring))
|
||||
}
|
||||
}
|
||||
.background(C.surface, shape).border(U.border, C.border, shape)
|
||||
.verticalScroll(scroll).padding(U.padding + U.border)) {
|
||||
Text(updateMetadata(release), Modifier.exactLineBox(T.stateTitle), style = T.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold), color = C.fg)
|
||||
Spacer(Modifier.height(U.notesListTop))
|
||||
paragraphs.forEachIndexed { index, paragraph ->
|
||||
Row(Modifier.padding(start = U.notesMarkerOffset).exactLineBox(notesStyle)) {
|
||||
Text("•", Modifier.width(U.notesMarkerWidth).exactLineBox(notesStyle), style = notesStyle, color = C.muted)
|
||||
Text(paragraph.removePrefix("- "), Modifier.exactLineBox(notesStyle), style = notesStyle, color = C.muted)
|
||||
}
|
||||
if (index != paragraphs.lastIndex) Spacer(Modifier.height(U.notesGap))
|
||||
}
|
||||
Spacer(Modifier.height(U.notesListBottom))
|
||||
Text("Sign-in stays on this TV. Android asks before installing.", Modifier.exactLineBox(T.body), style = T.body, color = C.muted)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Wolfskin TV specification — revision 6
|
||||
|
||||
2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
|
||||
2026-11-06. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`. Revision-5 feedback below still applies. Unchanged static geometry, navigation, typography and colours remain governed by the approved frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`.
|
||||
|
||||
## Who’s watching
|
||||
|
||||
|
|
@ -83,29 +83,6 @@ Discover key traversal skips pending lanes in both directions. If every remainin
|
|||
Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target.
|
||||
|
||||
|
||||
## 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`.
|
||||
|
|
|
|||
|
|
@ -345,15 +345,6 @@ not the working tree. An uncommitted merge still builds with the old version nam
|
|||
Neither an upstream merge nor its validation build authorizes a release or TV
|
||||
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
|
||||
|
|
@ -689,9 +680,9 @@ Down clamps a short final title row before entering the separate Try again / Sho
|
|||
more row; an empty slot cannot activate recovery. An unavailable library hides
|
||||
its unknown item count.
|
||||
|
||||
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.
|
||||
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 and Settings header initials use the muted colour over the person's fill;
|
||||
navigation and picker avatars retain their dark initials.
|
||||
|
||||
|
|
@ -735,35 +726,12 @@ Title shelves reserve vertical drawing room for the scaled ring.
|
|||
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
|
||||
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
|
||||
|
||||
Cards use the approved option-B bottom state line instead of a separate inset
|
||||
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.
|
||||
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.
|
||||
|
||||
`LibraryCaptureTest.spacingShowsFirstFocused`,
|
||||
`HomeCaptureTest.spacingNextUpFirstFocused` and
|
||||
|
|
|
|||
|
|
@ -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", "update-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py", "tokens.css", "tokens.sha256", "src/main/java/**/WolfskinTokens.kt") })
|
||||
doLast {
|
||||
file("tokens.sha256").readLines().forEach { line ->
|
||||
val (expected, name) = line.split(" ", limit = 2)
|
||||
|
|
|
|||
|
|
@ -121,10 +121,9 @@ 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", "update-tokens.json", "generate_tokens.py"]
|
||||
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"]
|
||||
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
|
||||
checksums.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()))
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@
|
|||
"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,
|
||||
|
|
@ -234,6 +240,18 @@
|
|||
"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,
|
||||
|
|
@ -298,6 +316,7 @@
|
|||
"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" }
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -367,6 +367,9 @@
|
|||
"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"},
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
DejaVu Sans and DejaVu Sans Bold
|
||||
https://dejavu-fonts.github.io/
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
|
||||
Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
DejaVu changes are in public domain.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of the fonts accompanying this license ("Fonts") and associated
|
||||
documentation files (the "Font Software"), to reproduce and distribute the
|
||||
Font Software, including without limitation the rights to use, copy, merge,
|
||||
publish, distribute, and/or sell copies of the Font Software, and to permit
|
||||
persons to whom the Font Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall
|
||||
be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular
|
||||
the designs of glyphs or characters in the Fonts may be modified and
|
||||
additional glyphs or characters may be added to the Fonts, only if the fonts
|
||||
are renamed to names not containing either the words "Bitstream" or the word
|
||||
"Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font
|
||||
Software that has been modified and is distributed under the "Bitstream
|
||||
Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no
|
||||
copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
|
||||
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
|
||||
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
|
||||
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
|
||||
FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome
|
||||
Foundation, and Bitstream Inc., shall not be used in advertising or
|
||||
otherwise to promote the sale, use or other dealings in this Font Software
|
||||
without prior written authorization from the Gnome Foundation or Bitstream
|
||||
Inc., respectively. For further information, contact: fonts at gnome dot
|
||||
org.
|
||||
|
|
@ -10,11 +10,18 @@ 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) {
|
||||
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false,
|
||||
titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption,
|
||||
titleOverflow: TextOverflow = TextOverflow.Ellipsis) {
|
||||
val alpha = remember(title, facts) { Animatable(0f) }
|
||||
val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) }
|
||||
val reduced = reducedMotion()
|
||||
|
|
@ -25,10 +32,47 @@ 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 = TextOverflow.Ellipsis)
|
||||
Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||
|
||||
/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */
|
||||
@Composable
|
||||
fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean,
|
||||
modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) {
|
||||
val transition = if (focusOnly) {
|
||||
val alpha = remember(title) { Animatable(0f) }
|
||||
val entryProgress = remember(title) { mutableFloatStateOf(0f) }
|
||||
val reduced = reducedMotion()
|
||||
val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() }
|
||||
LaunchedEffect(alpha, focused, reduced) {
|
||||
if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong())
|
||||
alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) {
|
||||
if (focused) entryProgress.floatValue = value
|
||||
}
|
||||
}
|
||||
Modifier.graphicsLayer {
|
||||
this.alpha = alpha.value
|
||||
translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue)
|
||||
}
|
||||
} else Modifier
|
||||
Column(modifier.then(transition)) {
|
||||
if (showTitle) {
|
||||
val style = if (focused || focusOnly) M.titleFocus else M.titleRest
|
||||
Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style,
|
||||
modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
CardMetadata(year, duration, watched)
|
||||
}
|
||||
}
|
||||
|
||||
/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */
|
||||
@Composable
|
||||
fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) {
|
||||
Layout(content = {
|
||||
if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
|
||||
if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1)
|
||||
if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted)
|
||||
}, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints ->
|
||||
val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) }
|
||||
val gaps = (children.size - 1).coerceAtLeast(0)
|
||||
val textWidth = sizes.sum()
|
||||
val gap = when {
|
||||
textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx()
|
||||
textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx()
|
||||
else -> M.minimumGap.roundToPx()
|
||||
}
|
||||
val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) }
|
||||
val height = with(this) { M.subtitle.lineHeight.toPx().toInt() }
|
||||
layout(constraints.maxWidth, height) {
|
||||
var left = 0
|
||||
measured.forEach { child ->
|
||||
child.placeRelative(left, (height - child.height) / 2)
|
||||
left += child.width + gap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
|
|||
|
||||
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
|
||||
@Composable
|
||||
fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
|
||||
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
|
||||
val exact = remember { CssColumnHeight() }
|
||||
Layout(content, modifier.then(exact)) { children, constraints ->
|
||||
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }
|
||||
|
|
|
|||
|
|
@ -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 + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
|
||||
modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) {
|
||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||
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" }) {
|
||||
PosterCaptionSkeleton()
|
||||
Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card)))
|
||||
}
|
||||
} else {
|
||||
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->
|
||||
|
|
@ -80,16 +80,15 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (!services && !lane.pending) {
|
||||
if (!services && active && !lane.pending) {
|
||||
val item = lane.titles.getOrNull(column)
|
||||
val 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)
|
||||
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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,3 +108,7 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ 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. */
|
||||
|
|
|
|||
|
|
@ -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.CardMetadata.landscapeBand) + L.rowGapAfter
|
||||
HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter
|
||||
HomeRowKind.Section -> L.row_section
|
||||
HomeRowKind.State -> L.row_state
|
||||
HomeRowKind.Note -> L.row_note
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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
|
||||
|
|
@ -19,6 +20,7 @@ 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
|
||||
|
|
@ -34,7 +36,7 @@ import org.justinvisser.wolfskin.design.HomeType as T
|
|||
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
|
||||
val width = row.cardWidth
|
||||
val height = row.cardHeight
|
||||
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand
|
||||
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
|
||||
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
|
||||
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) } }
|
||||
|
|
@ -48,36 +50,39 @@ 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 + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
|
||||
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
|
||||
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 (row.skeleton) {
|
||||
PosterCaptionSkeleton(width, height, caption)
|
||||
} else if (title == null) {
|
||||
if (title == null && !row.skeleton) {
|
||||
LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label)
|
||||
} 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) {
|
||||
} 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 {
|
||||
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.skeleton) {
|
||||
if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) {
|
||||
val title = row.cards.getOrNull(column)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -85,11 +90,9 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
|
|||
@Composable
|
||||
private fun TileOverlay(title: HomeTitle) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,24 +21,19 @@ 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, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
|
||||
fun Wordmark(modifier: Modifier = Modifier) {
|
||||
Row(modifier) {
|
||||
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
|
||||
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
|
||||
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
|
||||
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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,
|
||||
backgroundColor: Color = personColor(person.key)) {
|
||||
Box(modifier.size(diameter).background(backgroundColor, CircleShape), contentAlignment = Alignment.Center) {
|
||||
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) {
|
||||
Text(person.initial, color = letterColor, style = textStyle)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ interface PosterContent {
|
|||
val poster: String?
|
||||
val ratings: List<LibraryRating>
|
||||
val progress: Float
|
||||
val posterWatched: Boolean
|
||||
val badge: String?
|
||||
}
|
||||
|
||||
|
|
@ -41,15 +40,18 @@ 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. */
|
||||
|
|
|
|||
|
|
@ -25,12 +25,9 @@ fun LibraryPoster(
|
|||
catalogue: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||
showWatchState: Boolean = !catalogue,
|
||||
) {
|
||||
val poster = title.poster
|
||||
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) {
|
||||
TitleCard(focused, modifier.testTag("poster-${title.id}")) {
|
||||
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) {
|
||||
|
|
@ -42,6 +39,13 @@ 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
|
||||
|
|
|
|||
|
|
@ -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) { PosterCaptionSkeleton() }
|
||||
repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,6 @@ fun LibraryScreen(
|
|||
val title = items.getOrNull(index) ?: break
|
||||
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 {
|
||||
|
|
@ -151,13 +150,15 @@ 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") {
|
||||
|
|
@ -239,7 +240,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(K.posterW))
|
||||
Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) { PosterCaptionSkeleton() }
|
||||
repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,13 +55,3 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) {
|
|||
private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,16 +40,15 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.SavedIndicator as V
|
|||
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
|
||||
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
|
||||
|
||||
fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox> {
|
||||
val updates = WolfskinTokens.Update
|
||||
var left = L.safeSide + (if (isUpdatesLayout) updates.wordmarkWidth else S.wordmarkWidth) + L.wordmarkGap + L.navItemGap
|
||||
fun navBoxes(tabs: List<String>): List<NavBox> {
|
||||
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
|
||||
return tabs.map { tab ->
|
||||
val width = when (tab) {
|
||||
"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
|
||||
"home" -> S.navHomeWidth
|
||||
"discover" -> S.navDiscoverWidth
|
||||
"movies" -> S.navMoviesWidth
|
||||
"shows" -> S.navShowsWidth
|
||||
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)
|
||||
|
|
@ -58,8 +57,7 @@ fun navBoxes(tabs: List<String>, isUpdatesLayout: Boolean = false): List<NavBox>
|
|||
/** There is one blob, including when focus wraps around the nav avatar. */
|
||||
@Composable
|
||||
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
|
||||
val hasSelectedTab = state.route != "settings" && state.route != "updates"
|
||||
val boxes = remember(state.person.hasAnime, hasSelectedTab) { navBoxes(state.tabs, isUpdatesLayout = !hasSelectedTab) }
|
||||
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
|
||||
val reduced = reducedMotion()
|
||||
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
|
||||
val index = if (state.navFocused) state.navIndex else state.currentTab
|
||||
|
|
@ -81,15 +79,14 @@ 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)
|
||||
if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
|
||||
}
|
||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf",
|
||||
if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark)
|
||||
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
|
||||
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 (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted,
|
||||
if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
|
||||
animationSpec = labelFade,
|
||||
label = "nav-label-$tab",
|
||||
)
|
||||
|
|
@ -98,8 +95,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 = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
|
||||
if (hasSelectedTab && itemIndex == state.currentTab && !focused) {
|
||||
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
|
||||
if (itemIndex == state.currentTab && !focused) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
|
||||
}
|
||||
}
|
||||
|
|
@ -114,11 +111,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
|
|||
}
|
||||
}
|
||||
val avatar = boxes.last()
|
||||
Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav,
|
||||
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
|
||||
Modifier.offset(avatar.left, avatar.top)
|
||||
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||
.focusProperties { canFocus = false }
|
||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) },
|
||||
backgroundColor = if (hasSelectedTab) personColor(state.person.key) else resolveUpdateAvatarColor(state.person))
|
||||
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) }
|
||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ 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
|
||||
|
|
@ -35,53 +36,50 @@ 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)
|
||||
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
|
||||
|
||||
val SETTINGS_GROUPS = listOf(
|
||||
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"),
|
||||
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"),
|
||||
)
|
||||
|
||||
/** The count describes returned request history, not an inferred queue of active downloads. */
|
||||
@Composable
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
|
||||
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
|
||||
val list = state.view.listState
|
||||
val reduced = reducedMotion()
|
||||
val density = LocalDensity.current
|
||||
LaunchedEffect(state.settingsRow, state.navFocused) {
|
||||
if (state.navFocused) return@LaunchedEffect
|
||||
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))
|
||||
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))
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,14 +94,15 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
|
|||
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
|
||||
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
|
||||
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
|
||||
Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
|
||||
Column(Modifier.weight(1f)) {
|
||||
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
|
||||
Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1)
|
||||
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
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)
|
||||
}
|
||||
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))
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,6 +122,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 = T.caption.copy(fontWeight = T.label.fontWeight))
|
||||
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,13 +260,9 @@ class ShellState(
|
|||
}
|
||||
} else if (route == "settings") {
|
||||
val group = SETTINGS_GROUPS[settingsRow].key
|
||||
when (group) {
|
||||
"requests" -> openRequests()
|
||||
"updates" -> onAction(ShellAction.Destination("updates"))
|
||||
else -> {
|
||||
handOff = group
|
||||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
if (group == "requests") openRequests() else {
|
||||
handOff = group
|
||||
onAction(ShellAction.HandOff(group))
|
||||
}
|
||||
}
|
||||
else contentFocus?.key(ShellKey.Ok)
|
||||
|
|
@ -309,7 +305,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ fun ShellSurface(
|
|||
modifier: Modifier = Modifier,
|
||||
handlesContentKeys: Boolean = true,
|
||||
settingsRequestCount: Int? = null,
|
||||
settingsInstalledVersion: String = "",
|
||||
overlay: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
|
||||
) {
|
||||
|
|
@ -92,7 +91,7 @@ fun ShellSurface(
|
|||
if (state.pickerVisible) {
|
||||
WhosWatching(state)
|
||||
} else {
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
|
||||
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
|
||||
if (state.requestsVisible) ComingInSheet(state)
|
||||
overlay()
|
||||
|
|
|
|||
|
|
@ -9,51 +9,15 @@ 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, watched: Boolean = false, progress: Float = 0f,
|
||||
content: @Composable BoxScope.() -> Unit) {
|
||||
height: Dp = WolfskinTokens.Card.posterH, 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)
|
||||
.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)
|
||||
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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
|
||||
|
|
@ -106,36 +107,53 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */
|
||||
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
|
||||
@Composable
|
||||
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
|
||||
val focusOwner = LocalTitleFocusOwner.current
|
||||
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 = alwaysShowCaption, showWatchState = true,
|
||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||
} else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() },
|
||||
WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched,
|
||||
progress = item.progress.takeIf { it < 1f } ?: 0f) {
|
||||
} 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() }) {
|
||||
TitleTileArtwork(item)
|
||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0)
|
||||
}
|
||||
val progress = item.progress
|
||||
if (progress > 0f && progress < 1f) {
|
||||
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
|
||||
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
||||
}
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) {
|
||||
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
if (!tile) {
|
||||
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)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import androidx.compose.ui.text.PlatformTextStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.LineHeightStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
|
||||
object UpdateTypography {
|
||||
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
|
||||
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
|
||||
private val platform = PlatformTextStyle(includeFontPadding = false)
|
||||
private fun resolveStyle(style: TextStyle) = style.copy(fontFamily = family, lineHeightStyle = lineBox, platformStyle = platform)
|
||||
val screenTitle = resolveStyle(WolfskinTokens.Type.screenTitle)
|
||||
val stateTitle = resolveStyle(WolfskinTokens.Type.stateTitle)
|
||||
val heading = resolveStyle(WolfskinTokens.Update.heading)
|
||||
val nav = resolveStyle(WolfskinTokens.Type.nav)
|
||||
val wordmark = resolveStyle(WolfskinTokens.Type.wordmark)
|
||||
val listTitle = resolveStyle(WolfskinTokens.Type.listTitle)
|
||||
val body = resolveStyle(WolfskinTokens.Type.body)
|
||||
val button = resolveStyle(WolfskinTokens.Type.button)
|
||||
val label = resolveStyle(WolfskinTokens.Type.label)
|
||||
val caption = resolveStyle(WolfskinTokens.Type.caption)
|
||||
val avatarLetterNav = resolveStyle(WolfskinTokens.Type.avatarLetterNav)
|
||||
val avatarLetterLibHeader = resolveStyle(WolfskinTokens.Type.avatarLetterLibHeader)
|
||||
}
|
||||
|
|
@ -11,20 +11,6 @@ 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)
|
||||
|
|
@ -187,11 +173,13 @@ 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
|
||||
|
|
@ -221,9 +209,6 @@ 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
|
||||
|
|
@ -394,6 +379,9 @@ 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
|
||||
|
|
@ -477,6 +465,7 @@ 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
|
||||
|
|
@ -563,6 +552,8 @@ 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
|
||||
|
|
@ -600,6 +591,7 @@ 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
|
||||
|
|
@ -620,6 +612,8 @@ 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
|
||||
|
|
@ -640,6 +634,7 @@ 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
|
||||
|
|
@ -703,48 +698,4 @@ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<!-- SPDX-License-Identifier: GPL-2.0-only -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M5 12l4 4l10 -10" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.5" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M12,6 L22,16 L12,26" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
||||
</vector>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M5,8 H27 M5,16 H27 M5,24 H27" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
||||
</vector>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
|
||||
<path android:pathData="M26,14 A11,11 0,1 0,25 23 M26,5 V14 H17" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,135 +0,0 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class CardBottomPixelTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
val focused = mutableStateOf(-1)
|
||||
val showArt = mutableStateOf(true)
|
||||
val state = mutableStateOf("unwatched")
|
||||
val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026")
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
names.forEachIndexed { index, name ->
|
||||
LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null,
|
||||
played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f),
|
||||
focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt)
|
||||
}
|
||||
}
|
||||
} }
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
|
||||
for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) {
|
||||
rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val referenceFile = File(directory, "reference.png")
|
||||
rule.onRoot().captureRoboImage(referenceFile.absolutePath)
|
||||
val reference = BitmapFactory.decodeFile(referenceFile.absolutePath)
|
||||
rule.runOnIdle { showArt.value = true }
|
||||
rule.waitForIdle()
|
||||
val file = File(directory, "$watchState-focus-$selection.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
names.forEachIndexed { index, name ->
|
||||
val focus = selection == index
|
||||
val left = 96 + 296 * index
|
||||
val bottom = if (focus) 659 else 643
|
||||
val edgeLeft = if (focus) left - 10 else left
|
||||
val edgeRight = if (focus) left + 266 else left + 256
|
||||
// The reference supplies only rounded-corner/ring coverage, never poster colours.
|
||||
for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)",
|
||||
reference.getPixel(x, bottom), bitmap.getPixel(x, bottom))
|
||||
val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb()
|
||||
for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)",
|
||||
expected, bitmap.getPixel(x, bottom))
|
||||
if (watchState != "unwatched") {
|
||||
val fillY = if (focus) 655 else 640
|
||||
assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY))
|
||||
val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent
|
||||
assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY))
|
||||
}
|
||||
}
|
||||
reference.recycle()
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
|
||||
val fraction = mutableStateOf(.5f)
|
||||
val art = mapOf(path to fixtureArt(path)!!)
|
||||
val memory = HomeViewMemory()
|
||||
rule.setContent { WolfskinTheme {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100,
|
||||
poster = path, played = true, resumeSeconds = fraction.value * 100.0)
|
||||
HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)),
|
||||
memory, false, 0, emptyList(), {}, {})
|
||||
LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path,
|
||||
played = true, progress = fraction.value), false, false, false,
|
||||
Modifier.offset(240.dp, 42.dp), ::fixtureArt)
|
||||
}
|
||||
}
|
||||
} }
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() }
|
||||
for (progress in listOf(.5f, 1f, 1.5f)) {
|
||||
rule.runOnIdle { fraction.value = progress }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot
|
||||
val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(reference.width, home.width, .1f)
|
||||
assertEquals(reference.bottom, home.bottom, .1f)
|
||||
val file = File(directory, "home-progress-${(progress * 100).toInt()}.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
val bottom = home.bottom.toInt()
|
||||
val band = if (progress >= 1f) 7 else 10
|
||||
for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) {
|
||||
assertEquals("Home/library strip progress=$progress ($x,$y)",
|
||||
bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y))
|
||||
}
|
||||
val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent
|
||||
assertEquals("Completion changes the resume strip to the 3px watched strip",
|
||||
separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7))
|
||||
assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4))
|
||||
} finally { bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** Changing a shelf's selected identity must restart its focus-only caption reveal. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class CardCaptionMotionTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Before fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
rule.mainClock.autoAdvance = false
|
||||
}
|
||||
|
||||
@Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() {
|
||||
val title = mutableStateOf("Initial caption")
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
CardCaption(title.value, 2007, "2h 56m", true, true,
|
||||
Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true)
|
||||
CardCaption(title.value, 2007, "2h 56m", true, true,
|
||||
Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference"))
|
||||
}
|
||||
} }
|
||||
for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) {
|
||||
update { title.value = selected }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
assertCaptionPixels("component-$index",
|
||||
rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot,
|
||||
rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() {
|
||||
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
|
||||
val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path),
|
||||
HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path))
|
||||
val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards)
|
||||
val column = mutableStateOf(0)
|
||||
val memory = HomeViewMemory()
|
||||
val art = mapOf(path to fixtureArt(path)!!)
|
||||
rule.setContent { WolfskinTheme {
|
||||
CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
Box(Modifier.testTag("home-shelf")) {
|
||||
HomeShelf(row, memory, true, column.value, emptyList(), {}, {})
|
||||
}
|
||||
val selected = cards[column.value]
|
||||
CardCaption(selected.title, selected.year, selected.runtime, false, true,
|
||||
Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference"))
|
||||
}
|
||||
}
|
||||
} }
|
||||
for ((step, selected) in listOf(0, 1, 0).withIndex()) {
|
||||
update { column.value = selected }
|
||||
rule.mainClock.advanceTimeBy(400)
|
||||
val title = cards[selected].title
|
||||
val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
assertCaptionPixels("home-$step", actual, reference)
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(block: () -> Unit) {
|
||||
rule.runOnIdle { block(); Snapshot.sendApplyNotifications() }
|
||||
rule.mainClock.advanceTimeByFrame()
|
||||
}
|
||||
|
||||
private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) {
|
||||
assertEquals(reference.width, actual.width, .1f)
|
||||
assertEquals(reference.height, actual.height, .1f)
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
|
||||
val file = File(directory, "$name.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
val foreground = WolfskinTokens.Color.fg.toArgb()
|
||||
var compared = 0
|
||||
for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) {
|
||||
if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) {
|
||||
assertEquals("$name opaque title pixel ($x,$y)", foreground,
|
||||
bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y))
|
||||
compared++
|
||||
}
|
||||
}
|
||||
assertTrue("The reference must contain opaque title glyphs", compared >= 24)
|
||||
} finally { bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
package org.justinvisser.wolfskin.design
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import java.io.File
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.SemanticsActions
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
class CardGeometryTest {
|
||||
@get:Rule val rule = createComposeRule()
|
||||
|
||||
@Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() {
|
||||
val width = mutableStateOf(105.dp)
|
||||
rule.setContent { WolfskinTheme {
|
||||
CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true,
|
||||
Modifier.width(width.value).testTag("caption"))
|
||||
} }
|
||||
for (pixels in listOf(210, 327)) {
|
||||
rule.runOnIdle { width.value = (pixels / 2f).dp }
|
||||
rule.waitForIdle()
|
||||
val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node ->
|
||||
val results = mutableListOf<TextLayoutResult>()
|
||||
node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results)
|
||||
results.single()
|
||||
}
|
||||
val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") }
|
||||
assertEquals(1, title.lineCount)
|
||||
assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0))
|
||||
for (facts in layouts.filter { it !== title }) {
|
||||
assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow)
|
||||
assertFalse(facts.isLineEllipsized(0))
|
||||
}
|
||||
val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(58f, caption.height, .1f)
|
||||
assertEquals(pixels.toFloat(), caption.width, .1f)
|
||||
val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(24f, check.width, .1f)
|
||||
assertEquals(24f, check.height, .1f)
|
||||
assertTrue(check.right <= caption.right)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() {
|
||||
val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") }
|
||||
val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") }
|
||||
val grid = LibraryGridState("Movies", titles)
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } }
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
||||
val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
||||
// Focus scales around the unchanged slot centre.
|
||||
assertEquals(372f, first.top + (first.height - 384f) / 2, .1f)
|
||||
assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f)
|
||||
for (index in 0..5) {
|
||||
val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode()
|
||||
assertEquals(256f, caption.boundsInRoot.width, .1f)
|
||||
assertEquals(794f, caption.boundsInRoot.top, .1f)
|
||||
}
|
||||
val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f)
|
||||
assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f)
|
||||
rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } }
|
||||
rule.waitForIdle()
|
||||
val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot
|
||||
val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot
|
||||
assertEquals(168f, pinnedFirst.top, .1f)
|
||||
assertEquals(692f, pinnedSecond.top, .1f)
|
||||
assertEquals(1076f, pinnedSecond.bottom, .1f)
|
||||
}
|
||||
|
||||
@Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() {
|
||||
val kind = mutableStateOf(HomeCardKind.Poster)
|
||||
val loading = mutableStateOf(false)
|
||||
val memory = HomeViewMemory()
|
||||
rule.setContent { WolfskinTheme {
|
||||
Column {
|
||||
Box(Modifier.testTag("shelf")) {
|
||||
HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)),
|
||||
kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {})
|
||||
}
|
||||
Box(Modifier.size(1.dp).testTag("next"))
|
||||
}
|
||||
} }
|
||||
for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) {
|
||||
for (pending in listOf(false, true)) {
|
||||
rule.runOnIdle { kind.value = cardKind; loading.value = pending }
|
||||
rule.waitForIdle()
|
||||
assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f)
|
||||
assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() {
|
||||
val path = "/library/assets/movie-annie-hall-1977-poster.jpg"
|
||||
val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560,
|
||||
poster = path, held = true, watched = true))
|
||||
val art = mapOf(path to fixtureArt(path)!!)
|
||||
rule.setContent { WolfskinTheme {
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) {
|
||||
TitlePoster(item.value, false, alwaysShowCaption = true) {}
|
||||
}
|
||||
} }
|
||||
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
|
||||
rule.onNodeWithText("2007").assertIsDisplayed()
|
||||
rule.onNodeWithText("2h 56m").assertIsDisplayed()
|
||||
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
|
||||
rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) }
|
||||
rule.onNodeWithText("2016").assertIsDisplayed()
|
||||
rule.onNodeWithText("2h 56m").assertDoesNotExist()
|
||||
rule.onNodeWithContentDescription("Watched").assertIsDisplayed()
|
||||
rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) }
|
||||
rule.onNodeWithContentDescription("Watched").assertDoesNotExist()
|
||||
rule.onNodeWithText("A filmography credit").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() {
|
||||
val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp),
|
||||
"wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp))
|
||||
rule.setContent { WolfskinTheme {
|
||||
Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) {
|
||||
Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
slots.forEach { (name, geometry) ->
|
||||
Box(Modifier.testTag("loading-$name")) {
|
||||
PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
rule.waitForIdle()
|
||||
val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() }
|
||||
val file = File(directory, "loading-bands.png")
|
||||
rule.onRoot().captureRoboImage(file.absolutePath)
|
||||
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
|
||||
try {
|
||||
for ((name, geometry) in slots) {
|
||||
val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot
|
||||
val band = geometry.third.value * 2
|
||||
assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f)
|
||||
for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) {
|
||||
for (x in bounds.left.toInt() until bounds.right.toInt()) {
|
||||
assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { bitmap.recycle() }
|
||||
}
|
||||
}
|
||||
|
|
@ -11,12 +11,23 @@ import org.junit.runner.RunWith
|
|||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Live letter-rail changes preserve deep selection and poster geometry. */
|
||||
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@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") }
|
||||
|
|
|
|||
|
|
@ -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 = 5 }
|
||||
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
|
||||
}
|
||||
|
||||
/** Tests and debug builds share the same shell-only projection of the locked fixture. */
|
||||
|
|
|
|||
|
|
@ -46,22 +46,6 @@ class ShellKeysTest {
|
|||
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
|
||||
}
|
||||
|
||||
@Test fun updatesUseTwoPressesFromRequestsWithoutClassicHandOff() {
|
||||
val actions = mutableListOf<ShellAction>()
|
||||
val shell = ShellState(fixturePeople(), "justin", actions::add).apply {
|
||||
pickerVisible = false
|
||||
enterDestination("settings")
|
||||
}
|
||||
rule.setContent { WolfskinTheme { ShellSurface(shell) } }
|
||||
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
|
||||
pressKey(Key.DirectionDown)
|
||||
pressKey(Key.DirectionCenter)
|
||||
}
|
||||
assertEquals(1, shell.settingsRow)
|
||||
assertNull(shell.handOff)
|
||||
assertEquals(listOf(ShellAction.Destination("updates")), actions)
|
||||
}
|
||||
|
||||
@Test fun settingsBackBackInTenPresses() {
|
||||
replay(settings + listOf(Key.Back, Key.Back), 10)
|
||||
assertEquals("home", state.route)
|
||||
|
|
@ -70,10 +54,10 @@ class ShellKeysTest {
|
|||
assertEquals(state.tabs.size, state.navIndex)
|
||||
}
|
||||
|
||||
@Test fun playbackHandOffInTwelvePresses() {
|
||||
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
|
||||
@Test fun playbackHandOffInElevenPresses() {
|
||||
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
|
||||
assertEquals("playback", state.handOff)
|
||||
assertEquals(3, state.settingsRow)
|
||||
assertEquals(2, state.settingsRow)
|
||||
}
|
||||
|
||||
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"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"},
|
||||
|
|
@ -109,6 +110,8 @@
|
|||
"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"},
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
/* 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;
|
||||
|
|
@ -135,9 +123,6 @@
|
|||
--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;
|
||||
|
|
@ -257,6 +242,8 @@
|
|||
--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;
|
||||
|
|
@ -316,6 +303,7 @@
|
|||
--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;
|
||||
|
|
@ -396,6 +384,8 @@
|
|||
--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;
|
||||
|
|
@ -425,6 +415,7 @@
|
|||
--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;
|
||||
|
|
@ -445,6 +436,8 @@
|
|||
--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;
|
||||
|
|
@ -464,6 +457,7 @@
|
|||
--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;
|
||||
|
|
@ -519,42 +513,4 @@
|
|||
--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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
|
||||
"$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
|
||||
"meta": {
|
||||
"density": 2.0,
|
||||
"stagePx": [
|
||||
|
|
@ -15,20 +15,6 @@
|
|||
"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",
|
||||
|
|
@ -1059,7 +1045,7 @@
|
|||
"px": 38,
|
||||
"dp": 19,
|
||||
"source": "index.html:1242",
|
||||
"usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only"
|
||||
"usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640"
|
||||
},
|
||||
"plateInset": {
|
||||
"$type": "dimension",
|
||||
|
|
@ -1216,7 +1202,7 @@
|
|||
"ratio": "2:3",
|
||||
"fullSlotsPerRow": 6,
|
||||
"source": "index.html:656",
|
||||
"usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only"
|
||||
"usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest"
|
||||
},
|
||||
"tile": {
|
||||
"$type": "card",
|
||||
|
|
@ -1224,13 +1210,15 @@
|
|||
"w": 400,
|
||||
"h": 225,
|
||||
"gap": 40,
|
||||
"pitch": 440
|
||||
"pitch": 440,
|
||||
"captionBand": 0
|
||||
},
|
||||
"dp": {
|
||||
"w": 200.0,
|
||||
"h": 112.5,
|
||||
"gap": 20.0,
|
||||
"pitch": 220.0
|
||||
"pitch": 220.0,
|
||||
"captionBand": 0.0
|
||||
},
|
||||
"ratio": "16:9",
|
||||
"fullSlotsPerRow": 4,
|
||||
|
|
@ -1243,13 +1231,15 @@
|
|||
"w": 544,
|
||||
"h": 306,
|
||||
"gap": 40,
|
||||
"pitch": 584
|
||||
"pitch": 584,
|
||||
"captionBand": 0
|
||||
},
|
||||
"dp": {
|
||||
"w": 272.0,
|
||||
"h": 153.0,
|
||||
"gap": 20.0,
|
||||
"pitch": 292.0
|
||||
"pitch": 292.0,
|
||||
"captionBand": 0.0
|
||||
},
|
||||
"ratio": "16:9",
|
||||
"fullSlotsPerRow": 3,
|
||||
|
|
@ -1400,9 +1390,6 @@
|
|||
}
|
||||
},
|
||||
"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" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
{
|
||||
"update": {
|
||||
"avatarJustin": {"$type": "color", "$value": "#7ab8ff"},
|
||||
"wordmarkWidth": {"$type": "dimension", "px": 163.59375, "dp": 81.796875},
|
||||
"navHomeWidth": {"$type": "dimension", "px": 149.328125, "dp": 74.6640625},
|
||||
"navDiscoverWidth": {"$type": "dimension", "px": 198.140625, "dp": 99.0703125},
|
||||
"navMoviesWidth": {"$type": "dimension", "px": 170.515625, "dp": 85.2578125},
|
||||
"navShowsWidth": {"$type": "dimension", "px": 161.15625, "dp": 80.578125},
|
||||
"navAnimeWidth": {"$type": "dimension", "px": 158.46875, "dp": 79.234375},
|
||||
"notesListTop": {"$type": "dimension", "px": 28, "dp": 14},
|
||||
"notesListBottom": {"$type": "dimension", "px": 28, "dp": 14},
|
||||
"notesMarkerOffset": {"$type": "dimension", "px": 11, "dp": 5.5},
|
||||
"notesMarkerWidth": {"$type": "dimension", "px": 29, "dp": 14.5},
|
||||
"heading": {
|
||||
"$type": "typography",
|
||||
"$value": {"fontWeight": 800},
|
||||
"px": 36,
|
||||
"sp": 18,
|
||||
"weight": 800,
|
||||
"lineHeight": 1.12,
|
||||
"letterSpacingEm": -0.02
|
||||
},
|
||||
"settingsVisibleRows": {"$type": "number", "$value": 6},
|
||||
"headerTop": {
|
||||
"$type": "dimension",
|
||||
"px": 150,
|
||||
"dp": 75.0
|
||||
},
|
||||
"headerHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 84,
|
||||
"dp": 42.0
|
||||
},
|
||||
"rowsTop": {
|
||||
"$type": "dimension",
|
||||
"px": 278,
|
||||
"dp": 139.0
|
||||
},
|
||||
"rowsViewport": {
|
||||
"$type": "dimension",
|
||||
"px": 724,
|
||||
"dp": 362.0
|
||||
},
|
||||
"contentTop": {
|
||||
"$type": "dimension",
|
||||
"px": 298,
|
||||
"dp": 149.0
|
||||
},
|
||||
"width": {
|
||||
"$type": "dimension",
|
||||
"px": 1300,
|
||||
"dp": 650.0
|
||||
},
|
||||
"padding": {
|
||||
"$type": "dimension",
|
||||
"px": 40,
|
||||
"dp": 20.0
|
||||
},
|
||||
"border": {
|
||||
"$type": "dimension",
|
||||
"px": 2,
|
||||
"dp": 1.0
|
||||
},
|
||||
"radius": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0
|
||||
},
|
||||
"titleBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 16,
|
||||
"dp": 8.0
|
||||
},
|
||||
"paragraphBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 14,
|
||||
"dp": 7.0
|
||||
},
|
||||
"actionsTop": {
|
||||
"$type": "dimension",
|
||||
"px": 36,
|
||||
"dp": 18.0
|
||||
},
|
||||
"actionGap": {
|
||||
"$type": "dimension",
|
||||
"px": 24,
|
||||
"dp": 12.0
|
||||
},
|
||||
"progressTop": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
"dp": 15.0
|
||||
},
|
||||
"progressBottom": {
|
||||
"$type": "dimension",
|
||||
"px": 22,
|
||||
"dp": 11.0
|
||||
},
|
||||
"progressHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 12,
|
||||
"dp": 6.0
|
||||
},
|
||||
"progressRadius": {
|
||||
"$type": "dimension",
|
||||
"px": 8,
|
||||
"dp": 4.0
|
||||
},
|
||||
"autoTop": {
|
||||
"$type": "dimension",
|
||||
"px": 862,
|
||||
"dp": 431.0
|
||||
},
|
||||
"toggleWidth": {
|
||||
"$type": "dimension",
|
||||
"px": 80,
|
||||
"dp": 40.0
|
||||
},
|
||||
"toggleHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 42,
|
||||
"dp": 21.0
|
||||
},
|
||||
"toggleThumb": {
|
||||
"$type": "dimension",
|
||||
"px": 30,
|
||||
"dp": 15.0
|
||||
},
|
||||
"toggleInset": {
|
||||
"$type": "dimension",
|
||||
"px": 6,
|
||||
"dp": 3.0
|
||||
},
|
||||
"notesHeight": {
|
||||
"$type": "dimension",
|
||||
"px": 512,
|
||||
"dp": 256.0
|
||||
},
|
||||
"notesGap": {
|
||||
"$type": "dimension",
|
||||
"px": 18,
|
||||
"dp": 9.0
|
||||
},
|
||||
"notesScrollStep": {
|
||||
"$type": "dimension",
|
||||
"px": 80,
|
||||
"dp": 40.0
|
||||
},
|
||||
"backTop": {
|
||||
"$type": "dimension",
|
||||
"px": 184,
|
||||
"dp": 92.0
|
||||
},
|
||||
"scanFraction": {
|
||||
"$type": "number",
|
||||
"$value": 0.28
|
||||
},
|
||||
"scanEnd": {
|
||||
"$type": "number",
|
||||
"$value": 4.6
|
||||
},
|
||||
"sheenAlpha": {
|
||||
"$type": "number",
|
||||
"$value": 0.35
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue