Build the Wolfskin TV shell and navigation #29

Merged
justinzeus merged 5 commits from fm/tv-slice2-shell into main 2026-11-05 16:49:28 +01:00
98 changed files with 4615 additions and 91 deletions

View file

@ -1,3 +1,4 @@
import com.android.build.api.artifact.SingleArtifact
import com.android.build.api.dsl.ApplicationExtension
import com.android.build.api.dsl.ProductFlavor
import com.android.build.api.variant.FilterConfiguration
@ -5,6 +6,7 @@ import com.google.protobuf.gradle.id
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.File
import java.util.Base64
import java.util.Properties
@ -194,6 +196,7 @@ configure<ApplicationExtension> {
sourceSets {
getByName("main") {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
kotlin.directories += "src/wolfskin/java"
}
}
@ -224,6 +227,47 @@ androidComponents {
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
output.outputFileName = outputFileName
}
if (variant.name == "defaultDebug") {
val packagedApks = variant.artifacts.get(SingleArtifact.APK)
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()
val aapt2 = sdkComponents.aapt2.flatMap { it.executable }
val checkWolfskinBranding = tasks.register("checkWolfskinBranding") {
inputs.dir(packagedApks)
inputs.file(aapt2)
doLast {
val artifacts = checkNotNull(artifactLoader.load(packagedApks.get())) {
"Packaged default/debug APK metadata was not found; branding is unverified"
}.elements
check(artifacts.isNotEmpty()) { "No default/debug APKs were found; branding is unverified" }
val header = Regex("""^\s*resource 0x[\da-fA-F]+ (?:[^\s:]+:)?([^\s/]+)/([^\s]+)""")
val visibleTypes = setOf("string", "plurals", "array")
// Upstream legal attribution and its project URL must survive the branding cutover.
val legalNames = setOf("wolfskin_upstream_credit", "wolfskin_upstream_url")
val violations = sortedSetOf<String>()
artifacts.forEach { artifact ->
val dump = providers.exec {
commandLine(aapt2.get().asFile, "dump", "resources", artifact.outputFile)
}.standardOutput.asText.get()
var currentName: String? = null
var inspected = 0
dump.lineSequence().forEach { line ->
val resource = header.find(line)
if (resource != null) {
currentName = resource.groupValues[2].takeIf { resource.groupValues[1] in visibleTypes }
if (currentName != null) inspected++
} else if (currentName != null && currentName !in legalNames && line.contains("Wholphin", ignoreCase = true)) {
violations += "${File(artifact.outputFile).name}:$currentName"
}
}
check(inspected > 0) { "No packaged string resources were decoded; branding is unverified" }
logger.lifecycle("Inspected $inspected string/plural/array resources in ${File(artifact.outputFile).name}")
}
check(violations.isEmpty()) { "User-visible upstream branding remains: ${violations.joinToString()}" }
logger.lifecycle("Wolfskin branding check passed (${artifacts.size} packaged debug APKs; legal attribution preserved)")
}
}
tasks.matching { it.name == "assembleDefaultDebug" }.configureEach { dependsOn(checkWolfskinBranding) }
}
}
}
@ -288,6 +332,7 @@ tasks.named("preBuild") {
}
dependencies {
implementation(project(":wolfskin-design"))
implementation(project(":conductarr-client"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
@ -407,8 +452,18 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0")
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
}
tasks.withType<Test>().configureEach {
dependsOn(":wolfskin-design:testDebugUnitTest")
systemProperty("robolectric.dependency.dir", project(":wolfskin-design").layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
systemProperty("robolectric.offline", "true")
systemProperty("roborazzi.test.record", "true")
systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath)
}

View file

@ -0,0 +1,78 @@
{
"source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only",
"profiles": [
{
"key": "justin",
"name": "Justin",
"initial": "J",
"color": "#62c4ff",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Anime": {
"kind": "library",
"collection": "anime"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "mindel",
"name": "Mindel",
"initial": "M",
"color": "#ff9d72",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "shared",
"name": "Shared",
"initial": "S",
"color": "#9be38f",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
}
]
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_banner_foreground"
android:gravity="fill" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="display_preset_default">Wolfskin alapértelmezett beállítása</string>
<string name="display_preset_compact">Wolfskin kompakt elrendezés</string>
<string name="updated_toast">A Wolfskin frissült a %s verzióra</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="about">O Wolfskin</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Wolfskin</string>
<string name="app_name_long" translatable="false">Wolfskin</string>
<string name="display_preset_default">Wolfskin Default</string>
<string name="display_preset_compact">Wolfskin Compact</string>
<string name="updated_toast">Wolfskin updated to %s</string>
</resources>

View file

@ -66,7 +66,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
@ -234,7 +233,7 @@ class MainActivity : AppCompatActivity() {
viewModel.appStart(intent)
setContent {
MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) {
MaterialTheme {
Surface(Modifier.fillMaxSize()) {
val userPreferences by userPreferencesService.flow.collectAsState(null)
if (userPreferences == null) {

View file

@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import org.justinvisser.wolfskin.tv.WolfskinApp
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
@ -111,7 +111,7 @@ fun MainContent(
}
if (showContent) {
ApplicationContent(
WolfskinApp(
user = current.user,
server = current.server,
navigationManager = navigationManager,

View file

@ -557,7 +557,7 @@ sealed interface AppPreference<Pref, T> {
val ThemeColors =
AppChoicePreference<AppPreferences, AppThemeColors>(
title = R.string.app_theme,
defaultValue = AppThemeColors.WOLFSKIN,
defaultValue = AppThemeColors.PURPLE,
getter = { it.interfacePreferences.appThemeColors },
setter = { prefs, value ->
prefs.updateInterfacePreferences { appThemeColors = value }
@ -647,6 +647,19 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
)
val WolfskinScreens =
AppSwitchPreference<AppPreferences>(
title = R.string.wolfskin_screens,
defaultValue = true,
getter = { org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled },
setter = { prefs, value ->
org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled = value
prefs
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val UserProfileSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.override_user_profile_settings,
@ -1306,6 +1319,7 @@ val advancedPreferences =
title = R.string.ui_interface,
preferences =
listOf(
AppPreference.WolfskinScreens,
AppPreference.ShowClock,
AppPreference.BackdropStylePref,
AppPreference.ShowLogos,

View file

@ -41,7 +41,6 @@ fun WatchedIconBackground(): Color =
when (LocalTheme.current) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
@ -56,8 +55,6 @@ fun WatchedIconBackground(): Color =
@Composable
fun WatchedIconColor(): Color =
when (LocalTheme.current) {
AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.BLUE,

View file

@ -143,7 +143,6 @@ fun sliderActiveColor(focused: Boolean): Color {
return when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,
@ -180,7 +179,6 @@ fun sliderInactiveColor(focused: Boolean): Color {
return when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
-> {
MaterialTheme.colorScheme.border
.copy(alpha = .25f)

View file

@ -35,6 +35,9 @@ import java.util.UUID
sealed class Destination(
val fullScreen: Boolean = false,
) : NavKey {
@Serializable
data class Wolfskin(val route: String) : Destination(false)
@Serializable
data class Home(
val id: Long = 0L,

View file

@ -69,6 +69,10 @@ fun DestinationContent(
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
}
when (destination) {
is Destination.Wolfskin -> {
org.justinvisser.wolfskin.tv.WolfskinDestinationContent(destination.route, modifier)
}
is Destination.Home -> {
HomePage(
preferences = preferences,

View file

@ -771,7 +771,6 @@ fun navItemColor(
when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
AppThemeColors.GREEN,
AppThemeColors.ORANGE,

View file

@ -70,7 +70,6 @@ fun SwitchColors(): SwitchColors {
return when (theme) {
AppThemeColors.UNRECOGNIZED,
AppThemeColors.PURPLE,
AppThemeColors.WOLFSKIN,
AppThemeColors.BLUE,
-> {
SwitchDefaults.colors()

View file

@ -13,10 +13,9 @@ import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
val LocalTheme =
compositionLocalOf<AppThemeColors> { AppThemeColors.WOLFSKIN }
compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE }
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
when (appThemeColors) {
@ -28,14 +27,13 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
AppThemeColors.RED -> RedThemeColors
AppThemeColors.BROWN -> BrownThemeColors
AppThemeColors.WOLFSKIN -> WolfskinThemeColors
AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
}
@Composable
fun WholphinTheme(
darkTheme: Boolean = true,
appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN,
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
content: @Composable () -> Unit,
) {
val themeColors = getThemeColors(appThemeColors)

View file

@ -1,59 +0,0 @@
package com.github.damontecres.wholphin.ui.theme.colors
import androidx.compose.ui.graphics.Color
import com.github.damontecres.wholphin.ui.theme.ThemeColors
/** Lilac accents on near-black surfaces, with upstream Purple available separately. */
val WolfskinThemeColors =
object : ThemeColors {
private val background = Color(0xFF07070A)
private val lilac = Color(0xFFD2BCFF)
private val onLilac = Color(0xFF241334)
private val container = Color(0xFF382A49)
private val onContainer = Color(0xFFEADCFF)
private val surfaceVariant = Color(0xFF24202D)
override val lightSchemeMaterial = PurpleThemeColors.lightSchemeMaterial
override val lightScheme = PurpleThemeColors.lightScheme
override val darkSchemeMaterial =
PurpleThemeColors.darkSchemeMaterial.copy(
primary = lilac,
onPrimary = onLilac,
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = lilac,
onSecondary = onLilac,
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = lilac,
onTertiary = onLilac,
tertiaryContainer = container,
onTertiaryContainer = onContainer,
background = background,
surface = background,
surfaceVariant = surfaceVariant,
inversePrimary = Color(0xFF705090),
)
override val darkScheme =
PurpleThemeColors.darkScheme.copy(
primary = lilac,
onPrimary = onLilac,
primaryContainer = container,
onPrimaryContainer = onContainer,
secondary = lilac,
onSecondary = onLilac,
secondaryContainer = container,
onSecondaryContainer = onContainer,
tertiary = lilac,
onTertiary = onLilac,
tertiaryContainer = container,
onTertiaryContainer = onContainer,
background = background,
surface = background,
surfaceVariant = surfaceVariant,
inversePrimary = Color(0xFF705090),
border = lilac.copy(alpha = .75f),
)
}

View file

@ -128,7 +128,7 @@ enum AppThemeColors {
OLED_BLACK = 5;
RED = 6;
BROWN = 7;
WOLFSKIN = 8;
reserved 8;
}
enum EdgeStyle{

View file

@ -1,4 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_banner_foreground"
android:gravity="fill" />
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/>
<foreground>
<inset
android:inset="18%"
android:drawable="@mipmap/ic_banner_foreground"/>
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

@ -1,6 +1,6 @@
<resources>
<string name="app_name" translatable="false">Wolfskin</string>
<string name="app_name_long" translatable="false">Wolfskin</string>
<string name="app_name" translatable="false">Wholphin</string>
<string name="app_name_long" translatable="false">Wholphin</string>
<string name="about">About</string>
<string name="active_recordings">Active Recordings</string>
@ -604,8 +604,8 @@
<string name="content_scale_fill">Fill</string>
<string name="content_scale_fill_width">Fill width</string>
<string name="content_scale_fill_height">Fill height</string>
<string name="display_preset_default">Wolfskin Default</string>
<string name="display_preset_compact">Wolfskin Compact</string>
<string name="display_preset_default">Wholphin Default</string>
<string name="display_preset_compact">Wholphin Compact</string>
<string name="display_preset_series_thumb">Series Thumb images</string>
<string name="display_preset_episode_thumbnails">Episode Thumbnail images</string>
@ -637,7 +637,6 @@
<item>@string/black</item>
<item>@string/red</item>
<item>@string/brown</item>
<item>@string/wolfskin_theme</item>
</string-array>
<string name="skip_ignore">Ignore</string>
@ -831,7 +830,7 @@
<string name="select_all">Select all</string>
<string name="separate_types">Separate types</string>
<string name="prefer_logos">Prefer showing logos for titles</string>
<string name="updated_toast">Wolfskin updated to %s</string>
<string name="updated_toast">Wholphin updated to %s</string>
<string name="remove_continue_watching">Remove from continue watching</string>
<string name="remove_next_up">Remove series from next up</string>
<string name="view_removed_next_up">View series removed from next up</string>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="wolfskin_theme" translatable="false">Wolfskin</string>
<string name="wolfskin_screens" translatable="false">Wolfskin screens</string>
<string name="wolfskin_upstream_credit" translatable="false">based on Wholphin by damontecres</string>
<string name="wolfskin_upstream_url" translatable="false">https://github.com/damontecres/Wholphin</string>
</resources>

View file

@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
import org.justinvisser.wolfskin.design.WolfskinColors
import org.junit.Assert.assertTrue
import org.junit.Test
@ -10,8 +10,8 @@ import org.junit.Test
class WolfskinThemeColorsTest {
@Test
fun darkPaletteHasReadableNormalAndFocusedText() {
val television = WolfskinThemeColors.darkScheme
val material = WolfskinThemeColors.darkSchemeMaterial
val television = WolfskinColors.darkScheme
val material = WolfskinColors.darkSchemeMaterial
val textAndBackgrounds =
listOf(
television.onBackground to television.background,

View file

@ -0,0 +1,76 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel
import com.github.damontecres.wholphin.util.LoadingState
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.every
import io.mockk.mockk
import java.io.File
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The frozen st-094 state opens User profile settings, not the separate Playback press path. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class SettingsHandOffCaptureTest {
@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
}
@Test fun st094_realUserProfileHomeSettingsHandOff() {
val model = mockk<HomeSettingsViewModel>(relaxed = true)
every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success))
every { model.serverPluginActive } returns MutableStateFlow(false)
every { model.settingsChanged } returns MutableStateFlow(false)
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true))
val images = mockk<ImageUrlService>(relaxed = true)
rule.setContent {
WolfskinTheme {
CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) {
HomeSettingsPage(preferences, Modifier.fillMaxSize().background(WolfskinTokens.Color.bg), viewModel = model)
}
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
rule.onNodeWithText(rule.activity.getString(R.string.customize_home)).assertIsDisplayed()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "st-094.png").absolutePath)
}
}

View file

@ -0,0 +1,57 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import java.util.UUID
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** The actual Advanced preference persists its value and changes the shell's renderer policy. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ShellRoutingTest {
private val context: Application get() = ApplicationProvider.getApplicationContext()
private val movies = Destination.MediaItem(UUID.fromString("00000000-0000-0000-0000-000000000001"), BaseItemKind.COLLECTION_FOLDER, CollectionType.MOVIES)
private val libraries = mapOf("movies" to movies, "shows" to movies, "anime" to movies)
@Before fun initialize() { WolfskinScreenPreference.initialize(context); WolfskinScreenPreference.enabled = true }
@After fun restore() { WolfskinScreenPreference.enabled = true }
@Test fun advancedSwitchRestoresUpstreamHomeAndLibrariesAndPersistsAcrossReload() {
val preference = AppPreference.WolfskinScreens
val preferences = AppPreferences.getDefaultInstance()
assertNull(resolveUpstreamDestination(Destination.Home(), true, libraries))
assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), true, libraries))
preference.setter(preferences, false)
WolfskinScreenPreference.initialize(context)
assertFalse(preference.getter(preferences))
assertTrue(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries) is Destination.Home)
listOf("movies", "shows", "anime").forEach { route ->
assertEquals(movies, resolveUpstreamDestination(Destination.Wolfskin(route), WolfskinScreenPreference.enabled, libraries))
}
preference.setter(preferences, true)
assertNull(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries))
assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), WolfskinScreenPreference.enabled, libraries))
}
@Test fun settingsAndPickerNeverBecomeUpstreamScreensButUnrelatedRoutesPassThrough() {
val settings = Destination.Wolfskin("settings")
val upstream = Destination.Settings(PreferenceScreenOption.ADVANCED)
for (enabled in listOf(false, true)) {
assertNull(resolveUpstreamDestination(settings, enabled, libraries))
assertEquals(upstream, resolveUpstreamDestination(upstream, enabled, libraries))
assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries))
}
}
}

View file

@ -0,0 +1,141 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.captureToImage
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.unit.dp
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.BackdropStyle
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The rollout-off path must preserve native content focus and its backdrop beneath the shell. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class UpstreamContentFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun prepare() {
WolfskinScreenPreference.initialize(rule.activity)
WolfskinScreenPreference.enabled = false
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
}
@After fun restore() { WolfskinScreenPreference.enabled = true }
private fun shellState() = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
navFocused = false
}
@Test fun switchOffUpMovesBetweenContentRowsBeforeEnteringNav() {
val state = shellState()
val first = FocusRequester()
val second = FocusRequester()
rule.setContent {
val upstream = resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, emptyMap())
WolfskinTheme {
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
Column(contentModifier.padding(start = 100.dp, top = 150.dp), verticalArrangement = Arrangement.spacedBy(32.dp)) {
Box(Modifier.size(200.dp, 60.dp).testTag("upstream-first").focusRequester(first).focusable())
Box(Modifier.size(200.dp, 60.dp).testTag("upstream-second").focusRequester(second).focusable())
}
}
}
}
rule.runOnIdle { second.requestFocus() }
rule.onNodeWithTag("upstream-second").assertIsFocused()
rule.onNodeWithTag("upstream-second").performKeyInput { pressKey(Key.DirectionUp) }
rule.onNodeWithTag("upstream-first").assertIsFocused()
rule.runOnIdle { assertFalse("Up from the second row must not enter nav", state.navFocused) }
rule.onNodeWithTag("upstream-first").performKeyInput { pressKey(Key.DirectionUp) }
rule.runOnIdle { assertTrue("Only the first row exits upward to nav", state.navFocused) }
rule.onNodeWithTag("upstream-first").assertIsNotFocused()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionDown) }
rule.onNodeWithTag("upstream-first").assertIsFocused()
}
@Test fun switchOffShowsRealUpstreamBackdropAndSwitchOnRestoresOwnedBackground() {
val state = shellState()
val model = mockk<WolfskinShellViewModel>(relaxed = true)
every { model.session } returns WolfskinSession().also { it.state = state }
every { model.libraryDestinations } returns emptyMap()
// An unavailable saved identity uses the real sign-in placeholder, avoiding any server reads.
every { model.signedInPerson } returns "unavailable"
every { model.backdropFlow } returns MutableStateFlow(BackdropResult("fixture", null, Color.Red, Color.Red, Color.Red))
coEvery { model.load(any(), any()) } returns Unit
val server = JellyfinServer(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Fixture", "https://example.invalid", null)
val user = JellyfinUser(id = UUID.fromString("00000000-0000-0000-0000-000000000002"), name = "Justin", serverId = server.id, accessToken = null)
val defaults = AppPreferences.getDefaultInstance()
val preferences = UserPreferences(defaults.toBuilder().setInterfacePreferences(defaults.interfacePreferences.toBuilder().setBackdropStyle(BackdropStyle.BACKDROP_DYNAMIC_COLOR)).build(), null)
val navigation = NavigationManager()
rule.setContent { WolfskinApp(server, user, navigation, preferences, Modifier.fillMaxSize(), model) }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
val off = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
assertTrue("The real dynamic backdrop must remain visible through native content", off.red > .4f && off.green < .1f && off.blue < .1f)
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "review-upstream-off.png").absolutePath)
rule.runOnIdle { WolfskinScreenPreference.enabled = true }
rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle()
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)
org.junit.Assert.assertEquals("Owned background green", background.green, on.green, 1f / 255f)
org.junit.Assert.assertEquals("Owned background blue", background.blue, on.blue, 1f / 255f)
rule.onRoot().captureRoboImage(File(directory, "review-upstream-on.png").absolutePath)
}
}

View file

@ -0,0 +1,23 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.data.model.JellyfinUser
import org.json.JSONObject
import org.justinvisser.wolfskin.design.ShellPerson
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
if (BuildConfig.DEBUG) {
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
val profiles = json.getJSONArray("profiles")
return List(profiles.length()) { index ->
val person = profiles.getJSONObject(index)
ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime"))
}
}
return saved.map { user ->
val name = user.name.orEmpty()
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false)
}
}

View file

@ -0,0 +1,13 @@
package org.justinvisser.wolfskin.tv
import com.github.damontecres.wholphin.ui.nav.Destination
/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */
fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? =
when (destination) {
is Destination.Home, is Destination.MediaItem -> if (wolfskinEnabled) null else destination
is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null
else -> destination
}
private val LIBRARY_ROUTES = setOf("movies", "shows", "anime")

View file

@ -0,0 +1,207 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropResult
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.ui.nav.Backdrop
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.DestinationContent
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.justinvisser.wolfskin.design.PeopleStatus
import org.justinvisser.wolfskin.design.SettingsList
import org.justinvisser.wolfskin.design.ShellAction
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellPlaceholder
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
/** View memory must outlive the upstream setup entry replaced by a user switch. */
@Singleton
class WolfskinSession @Inject constructor() {
var state by mutableStateOf<ShellState?>(null)
var serverId: String? = null
var onAction: (ShellAction) -> Unit = {}
}
/** The shell routes only through the existing NavigationManager and real settings pages. */
@HiltViewModel
class WolfskinShellViewModel @Inject constructor(
@param:ApplicationContext private val context: Context,
val session: WolfskinSession,
private val repository: ServerRepository,
private val navigation: NavigationManager,
private val setupNavigation: SetupNavigationManager,
private val backdrop: BackdropService,
private val libraries: LibraryReadFacade,
) : ViewModel() {
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
private set
var signedInPerson by mutableStateOf("")
private set
suspend fun load(server: JellyfinServer, user: JellyfinUser) {
WolfskinScreenPreference.initialize(context)
signedInPerson = user.name.orEmpty().lowercase()
if (session.state == null || session.serverId != server.id.toString()) {
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val people = readShellPeople(context, saved.ifEmpty { listOf(user) })
session.serverId = server.id.toString()
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
}
session.onAction = { action -> handleAction(action, server, user) }
try {
libraryDestinations = libraries.readDestinations(user.id)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
libraryDestinations = emptyMap()
}
}
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
when (action) {
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
ShellAction.Pop -> navigation.goBack()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user)
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
}
}
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) {
navigation.replace(Destination.Home())
if (person.key == user.name.orEmpty().lowercase()) return
viewModelScope.launch {
val saved = withContext(Dispatchers.IO) {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.name.equals(person.name, ignoreCase = true) }
}
if (saved == null || saved.isProtected) {
signedInPerson = ""
return@launch
}
try {
val current = repository.changeUser(server, saved)
setupNavigation.navigateTo(SetupDestination.AppContent(current))
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
signedInPerson = ""
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
}
}
}
}
/** Settings groups open the fork's own pages; the prototype hand-off panel is never shipped. */
fun handOffDestination(group: String): Destination = when (group) {
"advanced" -> Destination.Settings(PreferenceScreenOption.ADVANCED)
"profile" -> Destination.HomeSettings
else -> Destination.Settings(PreferenceScreenOption.BASIC)
}
private val LocalShellState = compositionLocalOf<ShellState> { error("Wolfskin destination requires the shell") }
/** The one upstream hook preserves destination decorators and full-screen hand-offs. */
@Composable
fun WolfskinApp(
server: JellyfinServer,
user: JellyfinUser,
navigationManager: NavigationManager,
preferences: UserPreferences,
modifier: Modifier = Modifier,
viewModel: WolfskinShellViewModel = hiltViewModel(),
) {
LaunchedEffect(server.id, user.id) { viewModel.load(server, user) }
val loading = remember(user.id) {
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
}
val state = viewModel.session.state ?: loading
WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state) {
Box(modifier) {
val current = navigationManager.backStack.last()
if (!state.pickerVisible && resolveUpstreamDestination(current, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) != null) {
val backdrop by viewModel.backdropFlow.collectAsStateWithLifecycle()
Backdrop(
backdrop = backdrop,
drawerIsOpen = false,
backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle,
)
}
NavDisplay(
backStack = navigationManager.backStack,
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}") {
val route = when (destination) {
is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route
else -> "pushed"
}
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) }
if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else {
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
when {
upstream != null && 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.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
}
}
}
}
},
)
}
}
}
}
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
@Composable
fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier)
}

View file

@ -0,0 +1,27 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import androidx.preference.PreferenceManager
/** The single rollout preference is exposed only in upstream Advanced settings. */
object WolfskinScreenPreference {
private const val KEY = "wolfskin.screens.enabled"
private lateinit var preferences: SharedPreferences
private var current by mutableStateOf(true)
fun initialize(context: Context) {
preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
current = preferences.getBoolean(KEY, true)
}
var enabled: Boolean
get() = current
set(value) {
current = value
preferences.edit { putBoolean(KEY, value) }
}
}

View file

@ -0,0 +1,28 @@
package org.justinvisser.wolfskin.tv.read
import com.github.damontecres.wholphin.ui.nav.Destination
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import java.util.UUID
import javax.inject.Inject
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
suspend fun readDestinations(userId: UUID): Map<String, Destination> {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
return buildMap {
libraries.forEach { library ->
val name = library.name.orEmpty().lowercase()
val key = when {
"anime" in name -> "anime"
library.collectionType == CollectionType.MOVIES -> "movies"
library.collectionType == CollectionType.TVSHOWS -> "shows"
else -> null
}
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
}
}
}
}

View file

@ -7,7 +7,7 @@ Android SDK.
```sh
git fetch origin --tags
make apk # clean, signed default release: three ABI splits + universal
make test # upstream testDefaultDebugUnitTest in the same image, no secrets
make test # app default/debug suite plus wolfskin-design JVM/capture suite
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
```
@ -48,6 +48,78 @@ 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.
## Wolfskin shell
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
entry in `MainContent.kt` calls `WolfskinApp`; Wolfskin-only routes use the single
`Destination.Wolfskin` subtype in the existing navigation stack. Other destinations
continue through upstream `DestinationContent`. Full-screen player/settings pages
cover the Wolfskin nav.
The independent `:wolfskin-design` module owns the shell components, fixed person
colours, focus policy, per-person root-view memory, palette and motion. Its
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
SHA and provenance. `shell-tokens.json` adds shell measurements from that same
prototype. Generate the committed Kotlin, CSS and checksum manifest together:
```sh
python3 wolfskin-design/generate_tokens.py
python3 wolfskin-design/generate_tokens.py --check
```
`preBuild` checks the manifest against the source JSON, generator and generated
outputs. Editing any of them without regenerating fails the build. The shell uses
the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old
numeric value is reserved.
Compose output maps source weights 600/650 to the platform Bold (700) face, which
resolves consistently in native captures; the approved JSON weights stay unchanged.
Nav, picker names, labels and pill weights use that same mapping. Nav label colour
fades for 200 ms ease-out; the blob retains its critically damped spring.
Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring.
The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
The small gear under the final person opens Settings with Requests focused.
Settings groups hand off to existing upstream pages; the Requests entry explicitly
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations with
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
Settings list. Native Up traversal stays in content until the first focusable row,
then enters the nav. Turning the switch on restores the labelled placeholders
until the corresponding screen slices ship.
Debug people, tabs and initials come from the shell-only projection of the locked
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
do not package it. Saved Jellyfin identities use existing upstream services. The
shell makes no Conductarr calls and does not add Conductarr sign-in.
Native Roborazzi captures use Robolectric SDK 34 and
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Key replays
exercise the Android event adapter, including the 6/8/10/11-press shell paths.
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
separate press-count path.
Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent
dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers
use that local directory in offline mode instead of downloading Android JARs into
the container's ephemeral home. A cache miss still needs Maven Central; rate-limited
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
changing the wrapper or adding volumes.
Default-flavour launcher artwork and English, Hungarian and Polish string overrides
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
`main` resource folders have not yet been moved; non-default `appstore`/`firetv`
flavours therefore combine upstream icons with some legacy Wolfskin text. Those
flavours and a full locale migration are not certified by this slice. Debug
assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public
artifact provider and AAPT2. It checks all string, plural and array configurations,
rejecting user-visible upstream branding while preserving the upstream legal
attribution and project URL. The check does not depend on incremental merge files.
## Toolchain and disk use
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools

View file

@ -30,4 +30,5 @@ dependencyResolutionManagement {
rootProject.name = "Wholphin"
include(":app")
include(":wholphin-mpv-stub")
include(":wolfskin-design")
include(":conductarr-client")

View file

@ -0,0 +1,67 @@
import java.security.MessageDigest
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "org.justinvisser.wolfskin.design"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig { minSdk = libs.versions.minSdk.get().toInt() }
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
testOptions.unitTests.isIncludeAndroidResources = true
}
kotlin.compilerOptions.jvmTarget = JvmTarget.JVM_11
val robolectricSdk by configurations.creating {
isCanBeResolved = true
isCanBeConsumed = false
isTransitive = false
}
val cacheRobolectricSdk by tasks.registering(Copy::class) {
from(robolectricSdk)
into(layout.buildDirectory.dir("robolectric-sdk"))
}
val checkTokenDrift by tasks.registering {
inputs.files(fileTree(projectDir) { include("tokens.json", "shell-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)
val actual = MessageDigest.getInstance("SHA-256").digest(file(name).readBytes()).joinToString("") { "%02x".format(it) }
check(actual == expected) { "Token drift in $name: run python3 wolfskin-design/generate_tokens.py" }
}
}
}
tasks.named("preBuild") { dependsOn(checkTokenDrift) }
tasks.withType<Test>().configureEach {
dependsOn(cacheRobolectricSdk)
systemProperty("robolectric.dependency.dir", layout.buildDirectory.dir("robolectric-sdk").get().asFile.absolutePath)
systemProperty("robolectric.offline", "true")
systemProperty("robolectric.graphicsMode", "NATIVE")
systemProperty("roborazzi.test.record", "true")
systemProperty("wolfskin.captureDirectory", rootProject.layout.buildDirectory.dir("wolfskin-captures").get().asFile.absolutePath)
}
dependencies {
robolectricSdk("org.robolectric:android-all-instrumented:9-robolectric-4913185-2-i7")
robolectricSdk("org.robolectric:android-all-instrumented:11-robolectric-6757853-i7")
robolectricSdk("org.robolectric:android-all-instrumented:14-robolectric-10818077-i7")
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.tv.material)
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
implementation(libs.androidx.compose.material3)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.77.0")
}

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Generate Compose and CSS tokens and a build-verifiable drift manifest."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parent
KOTLIN = Path("src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt")
def number(value: float) -> str:
"""Render stable Kotlin decimal literals."""
return str(float(value))
def property_name(name: str) -> str:
"""Keep source names valid as Kotlin identifiers."""
return name.replace("-", "_")
def compose_weight(weight: int) -> str:
"""Use the platform bold face for intermediate weights that resolve to Regular in native captures."""
return "FontWeight.Bold" if 600 <= weight <= 700 else f"FontWeight({weight})"
def render_tokens(tokens: dict) -> tuple[str, str]:
"""Translate locked values into platform-resolvable Compose and CSS tokens."""
lines = [
"// Generated by generate_tokens.py; edit the JSON sources instead.",
"package org.justinvisser.wolfskin.design",
"",
"import androidx.compose.ui.graphics.Color as ComposeColor",
"import androidx.compose.ui.text.TextStyle",
"import androidx.compose.ui.text.font.FontWeight",
"import androidx.compose.ui.unit.dp",
"import androidx.compose.ui.unit.em",
"import androidx.compose.ui.unit.sp",
"",
"/** The locked TV design, converted at the source density. */",
"object WolfskinTokens {",
]
css = ["/* Generated from the locked TV token sources. */", ":root {"]
for group_name, group in tokens.items():
if group_name.startswith("$") or group_name in ("meta", "font"):
continue
lines.append(f" object {group_name[0].upper() + group_name[1:]} {{")
for name, token in group.items():
if not isinstance(token, dict):
continue
name = property_name(name)
kind = token.get("$type")
value = token.get("$value")
if kind == "color":
rgba = value.lstrip("#")
argb = (rgba[6:8] if len(rgba) == 8 else "ff") + rgba[:6]
lines.append(f" val {name} = ComposeColor(0x{argb.upper()})")
css.append(f" --{group_name}-{name}: {value};")
elif kind == "typography" and "sp" in token:
size = token["sp"]
weight = token.get("weight", token["$value"]["fontWeight"])
height = size * token.get("lineHeight", 1.3)
tracking = token.get("letterSpacingEm", 0)
lines.append(f" val {name} = TextStyle(fontSize = {number(size)}.sp, fontWeight = {compose_weight(weight)}, lineHeight = {number(height)}.sp, letterSpacing = {number(tracking)}.em)")
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
elif kind == "typography" and "sizes" in token:
for context, pixels in token["sizes"].items():
lines.append(f" val {name}{context[0].upper() + context[1:]} = TextStyle(fontSize = {number(pixels / 2)}.sp, fontWeight = {compose_weight(token['weight'])})")
elif kind == "duration":
lines.append(f" const val {name} = {int(value.removesuffix('ms'))}")
css.append(f" --{group_name}-{name}: {value};")
elif kind == "transition" and value.get("type") == "spring":
lines.append(f" const val {name}DampingRatio = {number(value['dampingRatio'])}f")
lines.append(f" const val {name}Stiffness = {number(value['stiffness'])}f")
elif kind == "shadow":
for suffix in ("gapPx", "ringPx"):
lines.append(f" val {name}{suffix[0].upper() + suffix[1:-2]} = {number(token[suffix] / 2)}.dp")
elif isinstance(token.get("dp"), (float, int)):
lines.append(f" val {name} = {number(token['dp'])}.dp")
css.append(f" --{group_name}-{name}: {number(token['px'])}px;")
elif isinstance(token.get("dp"), list):
for suffix, dimension in zip(("Width", "Height"), token["dp"]):
lines.append(f" val {name}{suffix} = {number(dimension)}.dp")
elif isinstance(token.get("dp"), dict):
for suffix, dimension in token["dp"].items():
lines.append(f" val {name}{suffix[0].upper() + suffix[1:]} = {number(dimension)}.dp")
elif isinstance(value, (float, int)) and not isinstance(value, bool):
lines.append(f" const val {name} = {number(value)}f")
elif kind in ("gradient", "filter", "rule") and isinstance(value, str):
lines.append(f" const val {name} = {json.dumps(value)}")
if kind == "gradient":
css.append(f" --{group_name}-{name}: {value};")
lines.append(" }")
lines.append("}")
css.append("}")
return "\n".join(lines) + "\n", "\n".join(css) + "\n"
def main() -> None:
"""Write generated artifacts, or fail when committed artifacts drift."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
arguments = parser.parse_args()
tokens = json.loads((ROOT / "tokens.json").read_text())
tokens.update(json.loads((ROOT / "shell-tokens.json").read_text()))
kotlin, css = render_tokens(tokens)
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
inputs = ["tokens.json", "shell-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()))
for name, content in outputs.items():
path = ROOT / name
if arguments.check:
if not path.exists() or path.read_text() != content:
raise SystemExit(f"Token drift: run python3 wolfskin-design/generate_tokens.py ({name})")
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
print("Token drift check passed" if arguments.check else "Generated Kotlin, CSS and drift manifest")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,320 @@
{
"shell": {
"navLabelFade": {
"$type": "duration",
"$value": "200ms",
"source": "index.html:81; nav label color transition uses 200 ms ease-out"
},
"zero": {
"$type": "dimension",
"px": 0,
"dp": 0.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"stageWidth": {
"$type": "dimension",
"px": 1920,
"dp": 960.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"stageHeight": {
"$type": "dimension",
"px": 1080,
"dp": 540.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"wordmarkWidth": {
"$type": "dimension",
"px": 169,
"dp": 84.5,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"wordmarkTop": {
"$type": "dimension",
"px": 73.6,
"dp": 36.8,
"source": "index.html:74,79: centre a 46.8 px line in the 64 px nav item"
},
"navHomeWidth": {
"$type": "dimension",
"px": 149,
"dp": 74.5,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"navDiscoverWidth": {
"$type": "dimension",
"px": 198,
"dp": 99.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"navMoviesWidth": {
"$type": "dimension",
"px": 171,
"dp": 85.5,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"navShowsWidth": {
"$type": "dimension",
"px": 161,
"dp": 80.5,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"navAnimeWidth": {
"$type": "dimension",
"px": 158,
"dp": 79.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"navItemTop": {
"$type": "dimension",
"px": 65,
"dp": 32.5,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerTitleTop": {
"$type": "dimension",
"px": 272.3,
"dp": 136.15,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerPeopleTop": {
"$type": "dimension",
"px": 425.5,
"dp": 212.75,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerNameGap": {
"$type": "dimension",
"px": 26,
"dp": 13.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerGearTop": {
"$type": "dimension",
"px": 751.7,
"dp": 375.85,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerGearCaptionTop": {
"$type": "dimension",
"px": 72,
"dp": 36.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerLetter": {
"$type": "dimension",
"px": 100,
"dp": 50.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerSkeletonNameWidth": {
"$type": "dimension",
"px": 140,
"dp": 70.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerSkeletonNameHeight": {
"$type": "dimension",
"px": 40,
"dp": 20.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerLoadingTitleTop": {
"$type": "dimension",
"px": 247.4,
"dp": 123.7,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerLoadingPeopleTop": {
"$type": "dimension",
"px": 400.6,
"dp": 200.3,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerLoadingButtonTop": {
"$type": "dimension",
"px": 756.6,
"dp": 378.3,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerErrorTitleTop": {
"$type": "dimension",
"px": 297.6,
"dp": 148.8,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerErrorPanelTop": {
"$type": "dimension",
"px": 450.8,
"dp": 225.4,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerErrorPanelWidth": {
"$type": "dimension",
"px": 980,
"dp": 490.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerErrorPanelHeight": {
"$type": "dimension",
"px": 185.6,
"dp": 92.8,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"pickerErrorButtonTop": {
"$type": "dimension",
"px": 706.4,
"dp": 353.2,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsHeaderTop": {
"$type": "dimension",
"px": 150,
"dp": 75.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsHeaderAvatar": {
"$type": "dimension",
"px": 52,
"dp": 26.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsHeaderGap": {
"$type": "dimension",
"px": 24,
"dp": 12.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsRowTop": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsRowPadding": {
"$type": "dimension",
"px": 30,
"dp": 15.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsRowGap": {
"$type": "dimension",
"px": 24,
"dp": 12.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsIcon": {
"$type": "dimension",
"px": 36,
"dp": 18.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsPillHeight": {
"$type": "dimension",
"px": 40,
"dp": 20.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsPillStroke": {
"$type": "dimension",
"px": 2,
"dp": 1.0,
"source": "index.html:122; pill border 2 px inside its 40 px border-box"
},
"settingsPillDashRatio": {
"$type": "number",
"$value": 3,
"source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width"
},
"settingsPillDashGapRatio": {
"$type": "number",
"$value": 2,
"source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width"
},
"settingsPillBorder": {
"$type": "color",
"$value": "#ffffff38",
"source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha"
},
"settingsPillFocusedBorder": {
"$type": "color",
"$value": "#0b0b1066",
"source": "index.html:328; focused pill ink at 40 percent opacity"
},
"settingsPillPadding": {
"$type": "dimension",
"px": 16,
"dp": 8.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsPillGap": {
"$type": "dimension",
"px": 12,
"dp": 6.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsChevron": {
"$type": "dimension",
"px": 30,
"dp": 15.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"settingsEndPadding": {
"$type": "dimension",
"px": 620,
"dp": 310.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"panelPaddingX": {
"$type": "dimension",
"px": 30,
"dp": 15.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"panelPaddingY": {
"$type": "dimension",
"px": 28,
"dp": 14.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"panelGap": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"panelHeadingGap": {
"$type": "dimension",
"px": 14,
"dp": 7.0,
"source": "index.html:244: receipt heading icon-to-title gap"
},
"panelIcon": {
"$type": "dimension",
"px": 36,
"dp": 18.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"skeletonRadius": {
"$type": "dimension",
"px": 10,
"dp": 5.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
},
"gearOpacity": {
"$type": "number",
"$value": 0.7,
"source": "locked index.html CSS / measured st-* bounds"
},
"navBandOpacity": {
"$type": "number",
"$value": 0.72,
"source": "locked index.html CSS / measured st-* bounds"
},
"navSolidStop": {
"$type": "number",
"$value": 0.84,
"source": "locked index.html CSS / measured st-* bounds"
}
}
}

View file

@ -0,0 +1,21 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.tv.material3.Text
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) {
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "caption-fade")
Column(modifier.graphicsLayer { this.alpha = alpha }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1)
Text(facts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1)
}
}

View file

@ -0,0 +1,41 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */
@Composable
fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
val animated by animateFloatAsState(
if (focused) scale else 1f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)),
label = "focus-scale",
)
return graphicsLayer { scaleX = animated; scaleY = animated }
}
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier {
val alpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
return drawBehind {
if (alpha > 0f) {
val outer = (gap + width).toPx()
val inner = gap.toPx()
drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
}
}
}

View file

@ -0,0 +1,26 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */
@Composable
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"playback" -> R.drawable.wolfskin_play
"nextup" -> R.drawable.wolfskin_tv
"profile" -> R.drawable.wolfskin_users
"about" -> R.drawable.wolfskin_info
"seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
}

View file

@ -0,0 +1,39 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text
/** Fixed household colours never depend on preference or avatar presets. */
fun personColor(key: String): Color = when (key.lowercase()) {
"justin" -> WolfskinTokens.PersonColor.justin
"mindel" -> WolfskinTokens.PersonColor.mindel
"shared" -> WolfskinTokens.PersonColor.shared
else -> WolfskinTokens.Color.accent
}
/** The same mark anchors the picker and the persistent navigation. */
@Composable
fun Wordmark(modifier: Modifier = Modifier) {
Row(modifier) {
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) {
Box(modifier.size(diameter).background(personColor(person.key), CircleShape), contentAlignment = Alignment.Center) {
Text(person.initial, color = WolfskinTokens.Color.onFocus, style = textStyle)
}
}

View file

@ -0,0 +1,104 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusProperties
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.platform.LocalDensity
import androidx.compose.ui.unit.Dp
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
/** Blob bounds retain their measurement while their draw coordinates spring between targets. */
data class NavBox(val left: Dp, val top: Dp, val width: Dp, val height: Dp)
fun navBoxes(tabs: List<String>): List<NavBox> {
var left = L.safeSide + S.wordmarkWidth + L.wordmarkGap + L.navItemGap
return tabs.map { tab ->
val width = when (tab) {
"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)
}
/** There is one blob, including when focus wraps around the nav avatar. */
@Composable
fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) {
val boxes = remember(state.person.hasAnime) { navBoxes(state.tabs) }
val labelFade = remember { tween<Color>(S.navLabelFade, easing = CubicBezierEasing(0f, 0f, .58f, 1f)) }
val index = if (state.navFocused) state.navIndex else state.currentTab
val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
val damping = WolfskinTokens.Motion.navBlobDampingRatio
val stiffness = WolfskinTokens.Motion.navBlobStiffness
val x by animateFloatAsState((box.left - pad).value, spring(damping, stiffness), label = "blob-x")
val y by animateFloatAsState((box.top - pad).value, spring(damping, stiffness), label = "blob-y")
val width by animateFloatAsState((box.width + pad * 2).value, spring(damping, stiffness), label = "blob-width")
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height")
val color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
val density = LocalDensity.current.density
val band = remember(scrolled) {
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f))
else Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), C.bg.copy(alpha = 0f)))
}
Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) {
drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
}
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 (itemIndex == state.currentTab) C.fg else C.muted,
animationSpec = labelFade,
label = "nav-label-$tab",
)
Box(Modifier.offset(item.left, item.top).size(item.width, item.height)
.focusProperties { canFocus = false }
.clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
if (itemIndex == state.currentTab && !focused) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
}
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
.focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
}
}

View file

@ -0,0 +1,128 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
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.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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
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
/** Settings routes identify real upstream destinations rather than stand-in panels. */
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
val SETTINGS_GROUPS = listOf(
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
)
/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
val list = state.view.listState
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect
val y = L.row_lhead + L.row_srow * state.settingsRow
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
}
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)
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)
}
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && index == state.settingsRow,
if (group.key == "requests") openRequests else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
}
}
}
}
}
/** The inverse focus fill has no focus ring and never changes row layout. */
@Composable
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else ""
Text(group.title + count, color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
}
}
@Composable
private fun SettingsPill(label: String, focused: Boolean) {
val foreground = if (focused) C.onFocus else C.dim
val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder
Box(Modifier.height(S.settingsPillHeight).drawWithCache {
val stroke = S.settingsPillStroke.toPx()
val inset = Offset(stroke / 2, stroke / 2)
val bounds = Size(size.width - stroke, size.height - stroke)
val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(
floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio),
))
val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx())
onDrawBehind {
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
}
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
}
}

View file

@ -0,0 +1,216 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/** Household identity data contains no credentials or server objects. */
data class ShellPerson(val key: String, val name: String, val initial: String, val hasAnime: Boolean)
/** The shell owns keys; the integration owns navigation and authenticated user changes. */
sealed interface ShellAction {
data class Destination(val route: String) : ShellAction
data class Person(val person: ShellPerson) : ShellAction
data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction
data object RetryPeople : ShellAction
}
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
enum class ShellKey { Up, Down, Left, Right, Ok, Back }
enum class PeopleStatus { Ready, Loading, Unavailable }
/** A root destination retains focus, horizontal tracks and its actual Compose scroller. */
class RememberedView {
val listState = LazyListState()
var row by mutableStateOf(0)
val cards = mutableStateMapOf<String, Int>()
}
/** Focus and Back transitions are independent of drawing and server availability. */
class ShellState(
val people: List<ShellPerson>,
initialPerson: String,
private val onAction: (ShellAction) -> Unit = {},
) {
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set
var route by mutableStateOf("home")
private set
var pickerVisible by mutableStateOf(true)
var pickerLaunch by mutableStateOf(true)
var pickerIndex by mutableStateOf(people.indexOf(person))
var gearFocused by mutableStateOf(false)
var navFocused by mutableStateOf(false)
var navIndex by mutableStateOf(0)
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
private var rootRoute by mutableStateOf("home")
private var pickerReturnRoute = "home"
private val views = mutableStateMapOf<Pair<String, String>, RememberedView>()
private var armedCard: String? = null
val tabs: List<String> get() = if (person.hasAnime) ANIME_TABS else BASE_TABS
val currentTab: Int get() = tabs.indexOf(rootRoute).coerceAtLeast(0)
val view: RememberedView get() = views.getOrPut(person.key to route) { RememberedView() }
/** External intents and upstream pops remain authoritative over the screen. */
fun enterDestination(destination: String) {
route = destination
if (destination in tabs) rootRoute = destination
handOff = null
}
/** Pointer navigation uses the same transitions as D-pad selection. */
fun focusNav(index: Int) { navIndex = index; navFocused = true }
fun focusPerson(index: Int) { pickerIndex = index; gearFocused = false }
/** Repeats are consumed, and a row activation is cancelled when focus moves. */
fun key(key: ShellKey, down: Boolean = true, repeat: Int = 0, card: String? = null): Boolean {
if (key == ShellKey.Ok) {
if (repeat > 0) return true
if (card != null) {
if (down) armedCard = card else {
if (armedCard == card) activate()
armedCard = null
}
return true
}
if (!down) return true
} else {
if (!down) return true
armedCard = null
}
if (requestsVisible) {
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
return true
}
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
return true
}
private fun pickerKey(key: ShellKey) {
if (peopleStatus != PeopleStatus.Ready) {
when (key) {
ShellKey.Left -> recoveryIndex = 0
ShellKey.Right -> if (peopleStatus == PeopleStatus.Unavailable) recoveryIndex = 1
ShellKey.Back -> closePicker()
ShellKey.Ok -> if (peopleStatus == PeopleStatus.Unavailable && recoveryIndex == 0) onAction(ShellAction.RetryPeople) else choosePerson(person)
else -> Unit
}
} else if (gearFocused) {
when (key) {
ShellKey.Up -> gearFocused = false
ShellKey.Ok -> {
pickerVisible = false
navFocused = false
settingsRow = 0
route = "settings"
onAction(ShellAction.Destination(route))
}
ShellKey.Back -> closePicker()
else -> Unit
}
} else {
when (key) {
ShellKey.Left -> pickerIndex = (pickerIndex - 1).coerceAtLeast(0)
ShellKey.Right -> pickerIndex = (pickerIndex + 1).coerceAtMost(people.lastIndex)
ShellKey.Down -> if (pickerIndex == people.lastIndex) gearFocused = true
ShellKey.Ok -> choosePerson(people[pickerIndex])
ShellKey.Back -> closePicker()
else -> Unit
}
}
}
private fun choosePerson(selected: ShellPerson) {
person = selected
pickerVisible = false
pickerLaunch = false
route = "home"
navFocused = false
navIndex = 0
rootRoute = "home"
views[person.key to route] = RememberedView()
onAction(ShellAction.Person(selected))
}
private fun closePicker() {
if (!pickerLaunch) { pickerVisible = false; navFocused = true; navIndex = tabs.size }
}
private fun navKey(key: ShellKey) {
when (key) {
ShellKey.Left -> navIndex = (navIndex + tabs.size) % (tabs.size + 1)
ShellKey.Right -> navIndex = (navIndex + 1) % (tabs.size + 1)
ShellKey.Down -> navFocused = false
ShellKey.Ok -> activate()
else -> Unit
}
}
private fun contentKey(key: ShellKey) {
when (key) {
ShellKey.Up -> if (route == "settings" && settingsRow > 0) settingsRow-- else { navFocused = true; navIndex = currentTab }
ShellKey.Down -> if (route == "settings") settingsRow = (settingsRow + 1).coerceAtMost(SETTINGS_GROUPS.lastIndex)
ShellKey.Ok -> activate()
else -> Unit
}
}
private fun activate() {
if (navFocused) {
if (navIndex == tabs.size) {
pickerReturnRoute = route
pickerVisible = true
pickerLaunch = false
pickerIndex = people.indexOf(person)
gearFocused = false
} else {
val target = tabs[navIndex]
if (target == route) views[person.key to target] = RememberedView()
route = target
rootRoute = target
navFocused = false
onAction(ShellAction.Destination(target))
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") requestsVisible = true else {
handOff = group
onAction(ShellAction.HandOff(group))
}
}
}
/** Back never discards a root view, including the card below a focused nav. */
fun back() {
if (route == "settings") {
route = pickerReturnRoute
onAction(ShellAction.Pop)
pickerVisible = true
pickerLaunch = false
gearFocused = true
pickerIndex = people.lastIndex
} else if (route !in tabs) {
onAction(ShellAction.Pop)
} else if (!navFocused) {
navFocused = true
navIndex = currentTab
} else if (route != "home") {
route = "home"
rootRoute = "home"
navIndex = 0
onAction(ShellAction.Destination(route))
}
}
companion object {
private val BASE_TABS = listOf("home", "discover", "movies", "shows")
private val ANIME_TABS = BASE_TABS + "anime"
}
}

View file

@ -0,0 +1,111 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
/** Map Android key events once, preserving repeat count and key-up semantics. */
fun ShellState.handleKey(event: KeyEvent): Boolean {
val key = when (event.key) {
Key.DirectionUp -> ShellKey.Up
Key.DirectionDown -> ShellKey.Down
Key.DirectionLeft -> ShellKey.Left
Key.DirectionRight -> ShellKey.Right
Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> ShellKey.Ok
Key.Back, Key.Escape -> ShellKey.Back
else -> return false
}
return this.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount)
}
/** Content and nav share a single focus policy; upstream full-screen pages bypass this surface. */
@Composable
fun ShellSurface(
state: ShellState,
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null,
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
val focus = remember { FocusRequester() }
val contentFocus = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.pickerVisible, state.navFocused, handlesContentKeys) {
if (state.pickerVisible || state.navFocused || handlesContentKeys) focus.requestFocus()
else contentFocus.requestFocus()
}
Box(modifier.fillMaxSize().background(if (handlesContentKeys || state.pickerVisible) C.bg else Color.Transparent).testTag("wolfskin-shell")
.semantics { stateDescription = "${state.person.key}:${state.route}:${if (state.pickerVisible) "picker" else if (state.navFocused) "nav-${state.navIndex}" else "content-${state.settingsRow}"}" }
.focusRequester(focus)
.onPreviewKeyEvent {
if (state.pickerVisible || state.navFocused || state.requestsVisible || handlesContentKeys) state.handleKey(it) else false
}
.onKeyEvent {
if (it.key == Key.DirectionUp && it.type == KeyEventType.KeyDown) {
if (!focusManager.moveFocus(FocusDirection.Up)) {
state.navFocused = true
state.navIndex = state.currentTab
}
true
} else false
}.focusable()) {
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) 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) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide),
verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label)
Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
PickerButton("Close", true, true) { state.requestsVisible = false }
}
}
}
}
}
}
/** Future screens are explicitly out of scope, never simulated library or title data. */
@Composable
fun ShellPlaceholder(route: String, modifier: Modifier = Modifier) {
Column(modifier.offset(L.safeSide, L.detailCopyTop), verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text(route.replaceFirstChar { it.uppercase() }, color = C.fg, style = WolfskinTokens.Type.screenTitle)
Text("Wolfskin screen — not built in this slice", color = C.accentText, style = WolfskinTokens.Type.body)
Text("Home and Library remain available through the Wolfskin screens switch in Advanced settings.", color = C.muted, style = WolfskinTokens.Type.body)
}
}

View file

@ -0,0 +1,113 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.requiredWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
/** The picker covers the nav and exposes Settings only below the final person. */
@Composable
fun WhosWatching(state: ShellState, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize().background(C.bg)) {
Wordmark(Modifier.offset(L.safeSide, L.safeTopPicker))
if (state.peopleStatus == PeopleStatus.Ready) PeoplePicker(state) else RecoveryPicker(state)
}
}
@Composable
private fun PeoplePicker(state: ShellState) {
Box(Modifier.fillMaxWidth().offset(y = S.pickerTitleTop), contentAlignment = Alignment.TopCenter) {
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
}
Row(Modifier.fillMaxWidth().offset(y = S.pickerPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
state.people.forEachIndexed { index, person ->
val focused = !state.gearFocused && state.pickerIndex == index
Column(Modifier.width(L.pickerAvatar).focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable {
state.focusPerson(index); state.key(ShellKey.Ok)
}, horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg))
Spacer(Modifier.height(S.pickerNameGap))
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
}
}
}
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (state.gearFocused) {
Box(Modifier.align(Alignment.TopCenter).offset(y = S.pickerGearCaptionTop).requiredWidth(L.pickerAvatar), contentAlignment = Alignment.TopCenter) {
Text("Settings", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
}
}
}
}
@Composable
private fun RecoveryPicker(state: ShellState) {
val loading = state.peopleStatus == PeopleStatus.Loading
val titleTop = if (loading) S.pickerLoadingTitleTop else S.pickerErrorTitleTop
Box(Modifier.fillMaxWidth().offset(y = titleTop), contentAlignment = Alignment.TopCenter) {
Text("Who’s watching?", color = C.fg, style = WolfskinTokens.Type.screenTitle)
}
if (loading) {
Row(Modifier.fillMaxWidth().offset(y = S.pickerLoadingPeopleTop), horizontalArrangement = Arrangement.spacedBy(L.pickerGap, Alignment.CenterHorizontally)) {
repeat(state.people.size) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(L.pickerAvatar).background(C.surface2, CircleShape))
Spacer(Modifier.height(S.pickerNameGap))
Box(Modifier.size(S.pickerSkeletonNameWidth, S.pickerSkeletonNameHeight).background(C.surface, RoundedCornerShape(S.skeletonRadius)))
}
}
}
} else {
Column(Modifier.offset((S.stageWidth - S.pickerErrorPanelWidth) / 2, S.pickerErrorPanelTop)
.size(S.pickerErrorPanelWidth, S.pickerErrorPanelHeight).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = S.panelPaddingX, vertical = S.panelPaddingY), verticalArrangement = Arrangement.spacedBy(S.panelGap)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.panelHeadingGap)) {
Glyph("alert", C.warn, Modifier.size(S.panelIcon))
Text("Can’t read who’s here", color = C.warn, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold))
}
Text("Conductarr didn’t answer. You can continue as the last person on\nthis TV.", color = C.muted, style = WolfskinTokens.Type.body)
}
}
Row(Modifier.fillMaxWidth().offset(y = if (loading) S.pickerLoadingButtonTop else S.pickerErrorButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap, Alignment.CenterHorizontally)) {
if (!loading) PickerButton("Try again", state.recoveryIndex == 0, true) { state.recoveryIndex = 0; state.key(ShellKey.Ok) }
PickerButton("Continue as ${state.person.name}", loading || state.recoveryIndex == 1, false) { state.recoveryIndex = 1; state.key(ShellKey.Ok) }
}
}
/** Recovery actions remain focusable while profiles cannot be read. */
@Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)
}
}

View file

@ -0,0 +1,41 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
/** Both Compose Material families use the same opaque TV palette for hand-offs. */
object WolfskinColors {
private val c = WolfskinTokens.Color
val darkScheme = darkColorScheme(
primary = c.accent, onPrimary = c.onFocus,
primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText,
secondary = c.accent, onSecondary = c.onFocus,
secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText,
tertiary = c.accent, onTertiary = c.onFocus,
tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText,
background = c.bg, onBackground = c.fg,
surface = c.bg, onSurface = c.fg,
surfaceVariant = c.surface2, onSurfaceVariant = c.fg,
border = c.accent,
)
val darkSchemeMaterial = androidx.compose.material3.darkColorScheme(
primary = c.accent, onPrimary = c.onFocus,
primaryContainer = c.accentSoft, onPrimaryContainer = c.accentText,
secondary = c.accent, onSecondary = c.onFocus,
secondaryContainer = c.accentSoft, onSecondaryContainer = c.accentText,
tertiary = c.accent, onTertiary = c.onFocus,
tertiaryContainer = c.accentSoft, onTertiaryContainer = c.accentText,
background = c.bg, onBackground = c.fg,
surface = c.bg, onSurface = c.fg,
surfaceVariant = c.surface2, onSurfaceVariant = c.fg,
)
}
/** Upstream content inherits tokens without a fork-specific theme enum. */
@Composable
fun WolfskinTheme(content: @Composable () -> Unit) {
androidx.compose.material3.MaterialTheme(colorScheme = WolfskinColors.darkSchemeMaterial) {
MaterialTheme(colorScheme = WolfskinColors.darkScheme, content = content)
}
}

View file

@ -0,0 +1,288 @@
// Generated by generate_tokens.py; edit the JSON sources instead.
package org.justinvisser.wolfskin.design
import androidx.compose.ui.graphics.Color as ComposeColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
/** The locked TV design, converted at the source density. */
object WolfskinTokens {
object Color {
val bg = ComposeColor(0xFF07070A)
val surface = ComposeColor(0xFF13131A)
val surface2 = ComposeColor(0xFF1B1B25)
val surface3 = ComposeColor(0xFF252532)
val fg = ComposeColor(0xFFF4F4F6)
val muted = ComposeColor(0xBDF4F4F6)
val dim = ComposeColor(0x8FF4F4F6)
val border = ComposeColor(0x1AFFFFFF)
val accent = ComposeColor(0xFFCC9FFF)
val accentSoft = ComposeColor(0xFF2C1A3F)
val accentText = ComposeColor(0xFFE4CBFF)
val ok = ComposeColor(0xFF89DA9B)
val warn = ComposeColor(0xFFF4C26A)
val fail = ComposeColor(0xFFF97770)
val onFocus = ComposeColor(0xFF0B0B10)
val buttonSecondary = ComposeColor(0x24FFFFFF)
val buttonSecondaryFocused = ComposeColor(0x42FFFFFF)
val titleButtonSecondary = ComposeColor(0xC72C2C38)
val titleButtonSecondaryFocused = ComposeColor(0xE6464654)
val chipRest = ComposeColor(0x1AFFFFFF)
val navBlobRest = ComposeColor(0x1AFFFFFF)
val listRowRest = ComposeColor(0x0DFFFFFF)
val sheetScrim = ComposeColor(0x9E000000)
val badgeRest = ComposeColor(0xBF07070A)
val badgeWatched = ComposeColor(0xD9F4F4F6)
val progressTrack = ComposeColor(0x38FFFFFF)
val railEmpty = ComposeColor(0x38F4F4F6)
}
object PersonColor {
val justin = ComposeColor(0xFF62C4FF)
val mindel = ComposeColor(0xFFFF9D72)
val shared = ComposeColor(0xFF9BE38F)
}
object Gradient {
const val heroScrim = "linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%)"
const val detailScrim = "linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%)"
const val navBand = "linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0))"
const val navBandSolid = "linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%)"
const val ratingPlate = "linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%)"
const val tileOverlay = "linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0))"
const val fallbackArt = "linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%))"
const val entryTile = "linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%))"
const val initialsCircle = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
const val heroAmbientFilter = "blur(60px) saturate(1.2) brightness(.55)"
const val cardAmbientFilter = "blur(26px) brightness(.5)"
}
object Type {
val displayDetail = TextStyle(fontSize = 44.0.sp, fontWeight = FontWeight(800), lineHeight = 44.88.sp, letterSpacing = -0.02.em)
val displayHero = TextStyle(fontSize = 42.0.sp, fontWeight = FontWeight(800), lineHeight = 42.84.sp, letterSpacing = -0.02.em)
val screenTitle = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = -0.02.em)
val sectionTitle = TextStyle(fontSize = 28.0.sp, fontWeight = FontWeight(800), lineHeight = 36.4.sp, letterSpacing = -0.02.em)
val sheetTitle = TextStyle(fontSize = 22.0.sp, fontWeight = FontWeight(800), lineHeight = 28.6.sp, letterSpacing = -0.01.em)
val wordmark = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 23.400000000000002.sp, letterSpacing = -0.02.em)
val stateTitle = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(400), lineHeight = 23.400000000000002.sp, letterSpacing = 0.0.em)
val rowHeading = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = -0.01.em)
val pickerName = TextStyle(fontSize = 17.0.sp, fontWeight = FontWeight.Bold, lineHeight = 22.1.sp, letterSpacing = 0.0.em)
val nav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val listTitle = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight.Bold, lineHeight = 19.5.sp, letterSpacing = 0.0.em)
val body = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(400), lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val button = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 18.2.sp, letterSpacing = 0.0.em)
val label = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val caption = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val ratingPlate = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.8.sp, letterSpacing = 0.0.em)
val avatarLetterPicker = TextStyle(fontSize = 50.0.sp, fontWeight = FontWeight(800))
val avatarLetterNav = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800))
val avatarLetterSheetRow = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
val avatarLetterShelfHead = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800))
val avatarLetterChip = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(800))
val avatarLetterLibHeader = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight(800))
val avatarLetterSectionHeader = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(800))
const val stageLineHeight = 1.3f
}
object Radius {
val card = 8.0.dp
val button = 19.0.dp
val chip = 17.0.dp
val navItem = 16.0.dp
val listRow = 12.0.dp
val panel = 11.0.dp
val menu = 13.0.dp
val heroSheet = 9.0.dp
val cardSheet = 5.0.dp
val thumb = 6.0.dp
val pill = 11.0.dp
val progress = 2.0.dp
}
object Layout {
val safeSide = 48.0.dp
val contentWidth = 864.0.dp
val safeTopPicker = 27.0.dp
val navBand = 75.0.dp
val navPaddingTop = 22.0.dp
val navItemHeight = 32.0.dp
val navItemPaddingX = 13.0.dp
val navItemGap = 6.0.dp
val wordmarkGap = 20.0.dp
val navUnderline = 2.0.dp
val navBlobAvatarPad = 4.0.dp
val navSolidAfter = 20.0.dp
val pinnedLine = 84.0.dp
val windowAhead = 350.0.dp
val rowHeadingBand = 30.0.dp
val rowGapAfter = 22.0.dp
val gridRowGap = 14.0.dp
val row_hero = 410.0.dp
val row_phero = 370.0.dp
val row_section = 75.0.dp
val row_chips = 52.0.dp
val row_note = 42.0.dp
val row_state = 150.0.dp
val row_lhead = 118.0.dp
val row_dhead = 450.0.dp
val row_phead = 235.0.dp
val row_gh = 42.0.dp
val row_endcta = 100.0.dp
val row_srow = 62.0.dp
val heroCopyLeft = 48.0.dp
val heroCopyBottom = 55.0.dp
val heroCopyWidth = 500.0.dp
val heroOverviewMax = 480.0.dp
val detailCopyTop = 100.0.dp
val detailCopyWidth = 530.0.dp
val heroSheetWidth = 210.0.dp
val heroSheetHeight = 315.0.dp
val buttonHeight = 38.0.dp
val buttonPaddingX = 19.0.dp
val buttonGap = 10.0.dp
val buttonIcon = 15.0.dp
val iconButton = 38.0.dp
val iconCaptionLine = 19.0.dp
val sheetWidth = 400.0.dp
val sheetRow = 50.0.dp
val settingsRowWidth = 650.0.dp
val settingsRowHeight = 52.0.dp
val stateWidth = 550.0.dp
val captionOffset = 9.0.dp
val badge = 24.0.dp
val badgeInset = 7.0.dp
val progressBar = 4.0.dp
val pickerAvatar = 110.0.dp
val pickerGap = 45.0.dp
val pickerStackGap = 35.0.dp
val gear = 28.0.dp
val navAvatar = 32.0.dp
val castCircle = 100.0.dp
val personPortrait = 120.0.dp
val thumbWidth = 48.0.dp
val thumbHeight = 72.0.dp
}
object Card {
val posterW = 128.0.dp
val posterH = 192.0.dp
val posterGap = 12.0.dp
val posterPitch = 140.0.dp
val posterCaptionBand = 38.0.dp
val tileW = 208.0.dp
val tileH = 117.0.dp
val tileGap = 12.0.dp
val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp
val wideW = 280.0.dp
val wideH = 157.5.dp
val wideGap = 12.0.dp
val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp
val entryW = 128.0.dp
val entryH = 72.0.dp
val entryGap = 12.0.dp
val entryPitch = 140.0.dp
val entryCaptionBand = 0.0.dp
val personW = 100.0.dp
val personH = 145.0.dp
val personGap = 14.0.dp
val personPitch = 114.0.dp
val personCaptionBand = 0.0.dp
}
object Focus {
const val scaleCard = 1.08f
const val scaleControl = 1.05f
const val scaleRow = 1.02f
val ringCardGap = 2.0.dp
val ringCardRing = 2.0.dp
val ringButtonGap = 2.0.dp
val ringButtonRing = 2.0.dp
val ringAvatarGap = 2.0.dp
val ringAvatarRing = 2.0.dp
val ringPickerGap = 3.0.dp
val ringPickerRing = 3.0.dp
const val fillInverse = "background color.fg, text color.onFocus"
}
object Motion {
const val fast = 140
const val base = 160
const val scroll = 180
const val navBlobDampingRatio = 1.0f
const val navBlobStiffness = 650.0f
const val ratingDwell = 350
const val toast = 3200
const val reducedMotion = "all transitions instant"
}
object Image {
const val memoryCacheMB = 64.0f
const val diskCacheMB = 128.0f
}
object Behaviour {
const val rowLimit = 20.0f
const val letterRailMin = 200.0f
const val gridColumns = 6.0f
const val libraryChunk = 200.0f
const val laneLoadRows = 2.0f
const val nextPageCards = 10.0f
const val heroSlides = 5.0f
const val cacheRatingsHours = 6.0f
const val laneCacheMinutes = 30.0f
const val libraryRefreshMinutes = 10.0f
const val requestsPollSeconds = 30.0f
const val comingInVisibleRows = 5.0f
}
object Shell {
const val navLabelFade = 200
val zero = 0.0.dp
val stageWidth = 960.0.dp
val stageHeight = 540.0.dp
val wordmarkWidth = 84.5.dp
val wordmarkTop = 36.8.dp
val navHomeWidth = 74.5.dp
val navDiscoverWidth = 99.0.dp
val navMoviesWidth = 85.5.dp
val navShowsWidth = 80.5.dp
val navAnimeWidth = 79.0.dp
val navItemTop = 32.5.dp
val pickerTitleTop = 136.15.dp
val pickerPeopleTop = 212.75.dp
val pickerNameGap = 13.0.dp
val pickerGearTop = 375.85.dp
val pickerGearCaptionTop = 36.0.dp
val pickerLetter = 50.0.dp
val pickerSkeletonNameWidth = 70.0.dp
val pickerSkeletonNameHeight = 20.0.dp
val pickerLoadingTitleTop = 123.7.dp
val pickerLoadingPeopleTop = 200.3.dp
val pickerLoadingButtonTop = 378.3.dp
val pickerErrorTitleTop = 148.8.dp
val pickerErrorPanelTop = 225.4.dp
val pickerErrorPanelWidth = 490.0.dp
val pickerErrorPanelHeight = 92.8.dp
val pickerErrorButtonTop = 353.2.dp
val settingsHeaderTop = 75.0.dp
val settingsHeaderAvatar = 26.0.dp
val settingsHeaderGap = 12.0.dp
val settingsRowTop = 5.0.dp
val settingsRowPadding = 15.0.dp
val settingsRowGap = 12.0.dp
val settingsIcon = 18.0.dp
val settingsPillHeight = 20.0.dp
val settingsPillStroke = 1.0.dp
const val settingsPillDashRatio = 3.0f
const val settingsPillDashGapRatio = 2.0f
val settingsPillBorder = ComposeColor(0x38FFFFFF)
val settingsPillFocusedBorder = ComposeColor(0x660B0B10)
val settingsPillPadding = 8.0.dp
val settingsPillGap = 6.0.dp
val settingsChevron = 15.0.dp
val settingsEndPadding = 310.0.dp
val panelPaddingX = 15.0.dp
val panelPaddingY = 14.0.dp
val panelGap = 5.0.dp
val panelHeadingGap = 7.0.dp
val panelIcon = 18.0.dp
val skeletonRadius = 5.0.dp
const val gearOpacity = 0.7f
const val navBandOpacity = 0.72f
const val navSolidStop = 0.84f
}
}

View file

@ -0,0 +1,4 @@
<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="M12 3 2 20h20z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M12 10v4.5M12 17v.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,5 @@
<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="M 8.8,12.0 a 3.2,3.2 0 1,0 6.4,0 a 3.2,3.2 0 1,0 -6.4,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
<path android:pathData="M12 2.8v2.6M12 18.6v2.6M2.8 12h2.6M18.6 12h2.6M5.5 5.5l1.9 1.9M16.6 16.6l1.9 1.9M5.5 18.5l1.9-1.9M16.6 7.4l1.9-1.9" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" />
<path android:pathData="M 5.4,12.0 a 6.6,6.6 0 1,0 13.2,0 a 6.6,6.6 0 1,0 -13.2,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" />
</vector>

View file

@ -0,0 +1,3 @@
<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="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,4 @@
<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="M 3.0,12.0 a 9.0,9.0 0 1,0 18.0,0 a 9.0,9.0 0 1,0 -18.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
<path android:pathData="M12 11v6M12 7.5v.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,3 @@
<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="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -0,0 +1,3 @@
<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="m9 6 6 6-6 6" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.6" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M7 5v14m0 0-3-3m3 3 3-3M17 19V5m0 0-3 3m3-3 3 3" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -0,0 +1,4 @@
<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="M3 13.5 5.5 5h13l2.5 8.5V19H3z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M3 13.5h5.5a3.5 3.5 0 0 0 7 0H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
</vector>

View file

@ -0,0 +1,4 @@
<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="M 5.5,5.0 h 13.0 a 2.5,2.5 0 0,1 2.5,2.5 v 7.0 a 2.5,2.5 0 0,1 -2.5,2.5 h -13.0 a 2.5,2.5 0 0,1 -2.5,-2.5 v -7.0 a 2.5,2.5 0 0,1 2.5,-2.5 z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,4 @@
<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="M 5.5,8.5 a 3.5,3.5 0 1,0 7.0,0 a 3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" />
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,38 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.TargetBasedAnimation
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** Sample the shipped Compose spring, not a separately approximated motion function. */
class NavSpringTest {
@Test
fun criticallyDampedBlobMatchesLockedCurveWithoutOvershoot() {
val animation = TargetBasedAnimation(
animationSpec = spring<Float>(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
typeConverter = Float.VectorConverter,
initialValue = 478f,
targetValue = 688f,
)
val times = listOf(50, 99, 150, 200)
val expected = listOf(554.5f, 628.7f, 665.9f, 680.2f)
val values = times.map { animation.getValueFromNanos(it * 1_000_000L) }
values.zip(expected).forEach { (actual, target) -> assertEquals(target, actual, .2f) }
assertTrue((values[1] - 478) / 210 in .55f.. .8f)
assertEquals(688f, animation.getValueFromNanos(300_000_000), 1f)
var previous = 478f
for (millisecond in 0..1000) {
val value = animation.getValueFromNanos(millisecond * 1_000_000L)
assertTrue("Spring reversed at $millisecond ms", value >= previous)
assertTrue("Spring overshot at $millisecond ms", value <= 688f)
previous = value
}
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
File(directory, "nav-spring.json").writeText(times.zip(values).joinToString(prefix = "[", postfix = "]") { (time, value) -> "{\"ms\":$time,\"x\":$value}" })
}
}

View file

@ -0,0 +1,81 @@
package org.justinvisser.wolfskin.design
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.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.json.JSONObject
import org.junit.Before
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
/** Native 2.0-density captures are compared against the frozen browser states outside the build tree. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ShellCaptureTest {
@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
}
private fun capture(name: String, openRequests: Int? = null, configure: (ShellState) -> Unit = {}) {
val state = ShellState(fixturePeople(), "justin")
configure(state)
rule.setContent {
WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests)
}
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st001_launch() = capture("st-001")
@Test fun st002_gear() {
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Settings").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals("The caption must not wrap under the small gear", 1, layouts.single().lineCount)
}
@Test fun st003_loading() = capture("st-003") { it.peopleStatus = PeopleStatus.Loading }
@Test fun st004_unavailable() = capture("st-004") { it.peopleStatus = PeopleStatus.Unavailable }
@Test fun st010_homeNav() = capture("st-010") { it.pickerVisible = false; it.navFocused = true }
@Test fun st011_rememberedNav() = capture("st-011") { it.pickerVisible = false; it.navFocused = true; it.view.row = 4; it.view.cards["row-4"] = 2 }
@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", openRequests = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@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. */
fun fixturePeople(): List<ShellPerson> {
val json = ShellCaptureTest::class.java.getResourceAsStream("/wolfskin-shell.json")!!.bufferedReader().use { JSONObject(it.readText()) }
val people = json.getJSONArray("profiles")
return List(people.length()) { index ->
val person = people.getJSONObject(index)
ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime"))
}
}

View file

@ -0,0 +1,114 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
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
/** Press counts exercise the actual Android-to-shell event path, not a second focus implementation. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class ShellKeysTest {
@get:Rule val rule = createComposeRule()
private val state = ShellState(fixturePeople(), "justin")
private val settings = listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionCenter)
private fun replay(keys: List<Key>, presses: Int) {
assertEquals("Acceptance press count", presses, keys.size)
rule.setContent { WolfskinTheme { ShellSurface(state) } }
keys.forEach { key -> rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) } }
}
@Test fun switchPersonInSixPresses() {
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter, Key.DirectionRight, Key.DirectionCenter), 6)
assertEquals("mindel", state.person.key)
assertEquals("home", state.route)
assertFalse(state.pickerVisible)
assertFalse(state.navFocused)
assertFalse(state.tabs.contains("anime"))
}
@Test fun openSettingsInEightPresses() {
replay(settings, 8)
assertEquals("settings", state.route)
assertEquals(0, state.settingsRow)
assertFalse(state.pickerVisible)
assertFalse("Requests content, not the nav, must own focus", state.navFocused)
}
@Test fun settingsBackBackInTenPresses() {
replay(settings + listOf(Key.Back, Key.Back), 10)
assertEquals("home", state.route)
assertFalse(state.pickerVisible)
assertTrue(state.navFocused)
assertEquals(state.tabs.size, state.navIndex)
}
@Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
assertEquals("playback", state.handOff)
assertEquals(2, state.settingsRow)
}
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {
replay(listOf(Key.DirectionDown, Key.DirectionRight, Key.DirectionDown), 3)
assertFalse(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight); pressKey(Key.DirectionDown) }
assertTrue(state.gearFocused)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp) }
assertFalse(state.gearFocused)
assertEquals(2, state.pickerIndex)
}
@Test fun rootBackAndNavDownPreserveViewAndWrap() {
replay(listOf(Key.DirectionCenter), 1)
val view = state.view
rule.runOnIdle { view.row = 4; view.cards["row-4"] = 2 }
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.Back); pressKey(Key.DirectionDown) }
assertSame(view, state.view)
assertEquals(4, state.view.row)
assertEquals(2, state.view.cards["row-4"])
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionUp); pressKey(Key.DirectionLeft) }
assertEquals(state.tabs.size, state.navIndex)
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
assertEquals(0, state.navIndex)
}
@Test fun destinationViewsAreIsolatedAndReselectResets() {
replay(listOf(Key.DirectionCenter), 1)
rule.runOnIdle {
state.enterDestination("movies")
val movies = state.view
movies.row = 3; movies.cards["movies"] = 2
state.enterDestination("shows")
state.view.row = 1
state.enterDestination("movies")
assertSame(movies, state.view)
state.focusNav(2); state.key(ShellKey.Ok)
assertNotSame(movies, state.view)
assertEquals(0, state.view.row)
assertTrue(state.view.cards.isEmpty())
}
}
@Test fun heldOkCannotActivateNewOverlay() {
replay(listOf(Key.DirectionCenter, Key.DirectionUp, Key.DirectionLeft), 3)
rule.runOnIdle {
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
repeat(5) { repeat -> state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_DOWN, AndroidKeyEvent.KEYCODE_DPAD_CENTER, repeat + 1))) }
state.handleKey(KeyEvent(AndroidKeyEvent(0, 0, AndroidKeyEvent.ACTION_UP, AndroidKeyEvent.KEYCODE_DPAD_CENTER, 0)))
}
assertTrue(state.pickerVisible)
assertEquals("justin", state.person.key)
}
}

View file

@ -0,0 +1,78 @@
{
"source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only",
"profiles": [
{
"key": "justin",
"name": "Justin",
"initial": "J",
"color": "#62c4ff",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Anime": {
"kind": "library",
"collection": "anime"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "mindel",
"name": "Mindel",
"initial": "M",
"color": "#ff9d72",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
},
{
"key": "shared",
"name": "Shared",
"initial": "S",
"color": "#9be38f",
"destinations": {
"Home": {
"kind": "home"
},
"Movies": {
"kind": "library",
"collection": "movies"
},
"Shows": {
"kind": "library",
"collection": "shows"
},
"Discover": {
"kind": "discover",
"collection": "discover"
}
}
}
]
}

179
wolfskin-design/tokens.css Normal file
View file

@ -0,0 +1,179 @@
/* Generated from the locked TV token sources. */
:root {
--color-bg: #07070a;
--color-surface: #13131a;
--color-surface2: #1b1b25;
--color-surface3: #252532;
--color-fg: #f4f4f6;
--color-muted: #f4f4f6bd;
--color-dim: #f4f4f68f;
--color-border: #ffffff1a;
--color-accent: #cc9fff;
--color-accentSoft: #2c1a3f;
--color-accentText: #e4cbff;
--color-ok: #89da9b;
--color-warn: #f4c26a;
--color-fail: #f97770;
--color-onFocus: #0b0b10;
--color-buttonSecondary: #ffffff24;
--color-buttonSecondaryFocused: #ffffff42;
--color-titleButtonSecondary: #2c2c38c7;
--color-titleButtonSecondaryFocused: #464654e6;
--color-chipRest: #ffffff1a;
--color-navBlobRest: #ffffff1a;
--color-listRowRest: #ffffff0d;
--color-sheetScrim: #0000009e;
--color-badgeRest: #07070abf;
--color-badgeWatched: #f4f4f6d9;
--color-progressTrack: #ffffff38;
--color-railEmpty: #f4f4f638;
--personColor-justin: #62c4ff;
--personColor-mindel: #ff9d72;
--personColor-shared: #9be38f;
--gradient-heroScrim: linear-gradient(90deg,rgb(7 7 10 / .92) 0%,rgb(7 7 10 / .55) 42%,rgb(7 7 10 / 0) 70%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .6) 26%,rgb(7 7 10 / 0) 48%);
--gradient-detailScrim: linear-gradient(90deg,rgb(7 7 10 / .94) 0%,rgb(7 7 10 / .6) 46%,rgb(7 7 10 / .05) 75%),linear-gradient(0deg,var(--bg) 0%,rgb(7 7 10 / .35) 34%,rgb(7 7 10 / 0) 55%);
--gradient-navBand: linear-gradient(180deg,rgb(7 7 10 / .72),rgb(7 7 10 / 0));
--gradient-navBandSolid: linear-gradient(180deg,var(--bg) 0%,var(--bg) 84%,rgb(7 7 10 / 0) 100%);
--gradient-ratingPlate: linear-gradient(180deg,rgb(7 7 10 / .9) 0%,rgb(7 7 10 / .72) 52%,rgb(7 7 10 / 0) 100%);
--gradient-tileOverlay: linear-gradient(0deg,rgb(7 7 10 / .92),rgb(7 7 10 / 0));
--gradient-fallbackArt: linear-gradient(160deg,hsl(var(--h,260) 22% 22%),hsl(var(--h,260) 26% 10%));
--gradient-entryTile: linear-gradient(150deg,hsl(var(--h) 30% 26%),hsl(var(--h) 30% 12%));
--gradient-initialsCircle: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%));
--type-displayDetail: 88.0px;
--type-displayHero: 84.0px;
--type-screenTitle: 64.0px;
--type-sectionTitle: 56.0px;
--type-sheetTitle: 44.0px;
--type-wordmark: 36.0px;
--type-stateTitle: 36.0px;
--type-rowHeading: 34.0px;
--type-pickerName: 34.0px;
--type-nav: 30.0px;
--type-listTitle: 30.0px;
--type-body: 28.0px;
--type-button: 28.0px;
--type-label: 26.0px;
--type-caption: 24.0px;
--type-ratingPlate: 28.0px;
--radius-card: 16.0px;
--radius-button: 38.0px;
--radius-chip: 34.0px;
--radius-navItem: 32.0px;
--radius-listRow: 24.0px;
--radius-panel: 22.0px;
--radius-menu: 26.0px;
--radius-heroSheet: 18.0px;
--radius-cardSheet: 10.0px;
--radius-thumb: 12.0px;
--radius-pill: 22.0px;
--radius-progress: 4.0px;
--layout-safeSide: 96.0px;
--layout-contentWidth: 1728.0px;
--layout-safeTopPicker: 54.0px;
--layout-navBand: 150.0px;
--layout-navPaddingTop: 44.0px;
--layout-navItemHeight: 64.0px;
--layout-navItemPaddingX: 26.0px;
--layout-navItemGap: 12.0px;
--layout-wordmarkGap: 40.0px;
--layout-navUnderline: 4.0px;
--layout-navBlobAvatarPad: 8.0px;
--layout-navSolidAfter: 40.0px;
--layout-pinnedLine: 168.0px;
--layout-windowAhead: 700.0px;
--layout-rowHeadingBand: 60.0px;
--layout-rowGapAfter: 44.0px;
--layout-gridRowGap: 28.0px;
--layout-row_hero: 820.0px;
--layout-row_phero: 740.0px;
--layout-row_section: 150.0px;
--layout-row_chips: 104.0px;
--layout-row_note: 84.0px;
--layout-row_state: 300.0px;
--layout-row_lhead: 236.0px;
--layout-row_dhead: 900.0px;
--layout-row_phead: 470.0px;
--layout-row_gh: 84.0px;
--layout-row_endcta: 200.0px;
--layout-row_srow: 124.0px;
--layout-heroCopyLeft: 96.0px;
--layout-heroCopyBottom: 110.0px;
--layout-heroCopyWidth: 1000.0px;
--layout-heroOverviewMax: 960.0px;
--layout-detailCopyTop: 200.0px;
--layout-detailCopyWidth: 1060.0px;
--layout-buttonHeight: 76.0px;
--layout-buttonPaddingX: 38.0px;
--layout-buttonGap: 20.0px;
--layout-buttonIcon: 30.0px;
--layout-iconButton: 76.0px;
--layout-iconCaptionLine: 38.0px;
--layout-sheetWidth: 800.0px;
--layout-sheetRow: 100.0px;
--layout-stateWidth: 1100.0px;
--layout-captionOffset: 18.0px;
--layout-badge: 48.0px;
--layout-badgeInset: 14.0px;
--layout-progressBar: 8.0px;
--layout-pickerAvatar: 220.0px;
--layout-pickerGap: 90.0px;
--layout-pickerStackGap: 70.0px;
--layout-gear: 56.0px;
--layout-navAvatar: 64.0px;
--layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px;
--motion-fast: 140ms;
--motion-base: 160ms;
--motion-scroll: 180ms;
--motion-ratingDwell: 350ms;
--motion-toast: 3200ms;
--shell-navLabelFade: 200ms;
--shell-zero: 0.0px;
--shell-stageWidth: 1920.0px;
--shell-stageHeight: 1080.0px;
--shell-wordmarkWidth: 169.0px;
--shell-wordmarkTop: 73.6px;
--shell-navHomeWidth: 149.0px;
--shell-navDiscoverWidth: 198.0px;
--shell-navMoviesWidth: 171.0px;
--shell-navShowsWidth: 161.0px;
--shell-navAnimeWidth: 158.0px;
--shell-navItemTop: 65.0px;
--shell-pickerTitleTop: 272.3px;
--shell-pickerPeopleTop: 425.5px;
--shell-pickerNameGap: 26.0px;
--shell-pickerGearTop: 751.7px;
--shell-pickerGearCaptionTop: 72.0px;
--shell-pickerLetter: 100.0px;
--shell-pickerSkeletonNameWidth: 140.0px;
--shell-pickerSkeletonNameHeight: 40.0px;
--shell-pickerLoadingTitleTop: 247.4px;
--shell-pickerLoadingPeopleTop: 400.6px;
--shell-pickerLoadingButtonTop: 756.6px;
--shell-pickerErrorTitleTop: 297.6px;
--shell-pickerErrorPanelTop: 450.8px;
--shell-pickerErrorPanelWidth: 980.0px;
--shell-pickerErrorPanelHeight: 185.6px;
--shell-pickerErrorButtonTop: 706.4px;
--shell-settingsHeaderTop: 150.0px;
--shell-settingsHeaderAvatar: 52.0px;
--shell-settingsHeaderGap: 24.0px;
--shell-settingsRowTop: 10.0px;
--shell-settingsRowPadding: 30.0px;
--shell-settingsRowGap: 24.0px;
--shell-settingsIcon: 36.0px;
--shell-settingsPillHeight: 40.0px;
--shell-settingsPillStroke: 2.0px;
--shell-settingsPillBorder: #ffffff38;
--shell-settingsPillFocusedBorder: #0b0b1066;
--shell-settingsPillPadding: 16.0px;
--shell-settingsPillGap: 12.0px;
--shell-settingsChevron: 30.0px;
--shell-settingsEndPadding: 620.0px;
--shell-panelPaddingX: 30.0px;
--shell-panelPaddingY: 28.0px;
--shell-panelGap: 10.0px;
--shell-panelHeadingGap: 14.0px;
--shell-panelIcon: 36.0px;
--shell-skeletonRadius: 10.0px;
}

1592
wolfskin-design/tokens.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,5 @@
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
1037c46c9d804f9349e927257de13ba32cbd1c05e208ec0bc1d345f4b465f1eb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
c133085ef60612df9753ef3681446d9ccb2eb29b1ee1955b86254714c67525e3 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json