Compare commits
6 commits
2be4e30826
...
7a878b2d81
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a878b2d81 | |||
| 74033ee1da | |||
| 2ea9301069 | |||
| 4b89a73908 | |||
| 9be55c570a | |||
| f60a22e435 |
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
78
app/src/debug/assets/wolfskin-shell.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
app/src/default/res/mipmap-anydpi-v26/ic_banner.xml
Normal 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" />
|
||||
BIN
app/src/default/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
app/src/default/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
app/src/default/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/src/default/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/default/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
6
app/src/default/res/values-hu/strings.xml
Normal 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>
|
||||
4
app/src/default/res/values-pl/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">O Wolfskin</string>
|
||||
</resources>
|
||||
8
app/src/default/res/values/strings.xml
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -771,7 +771,6 @@ fun navItemColor(
|
|||
when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ fun SwitchColors(): SwitchColors {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
-> {
|
||||
SwitchDefaults.colors()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ enum AppThemeColors {
|
|||
OLED_BLACK = 5;
|
||||
RED = 6;
|
||||
BROWN = 7;
|
||||
WOLFSKIN = 8;
|
||||
reserved 8;
|
||||
}
|
||||
|
||||
enum EdgeStyle{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,4 +30,5 @@ dependencyResolutionManagement {
|
|||
rootProject.name = "Wholphin"
|
||||
include(":app")
|
||||
include(":wholphin-mpv-stub")
|
||||
include(":wolfskin-design")
|
||||
include(":conductarr-client")
|
||||
|
|
|
|||
67
wolfskin-design/build.gradle.kts
Normal 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")
|
||||
}
|
||||
126
wolfskin-design/generate_tokens.py
Executable 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()
|
||||
320
wolfskin-design/shell-tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_alert.xml
Normal 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>
|
||||
5
wolfskin-design/src/main/res/drawable/wolfskin_gear.xml
Normal 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>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_grid.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_info.xml
Normal 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>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_play.xml
Normal 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>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_right.xml
Normal 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>
|
||||
3
wolfskin-design/src/main/res/drawable/wolfskin_sort.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_tray.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_tv.xml
Normal 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>
|
||||
4
wolfskin-design/src/main/res/drawable/wolfskin_users.xml
Normal 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>
|
||||
|
|
@ -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}" })
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
78
wolfskin-design/src/test/resources/wolfskin-shell.json
Normal 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
|
|
@ -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
5
wolfskin-design/tokens.sha256
Normal 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
|
||||