Compare commits
No commits in common. "7a878b2d81f21d23e3e4b52d2c9a5a0385a6ecdd" and "2be4e3082610b7cedfe53025dbebfae7db7afa67" have entirely different histories.
7a878b2d81
...
2be4e30826
|
|
@ -1,4 +1,3 @@
|
||||||
import com.android.build.api.artifact.SingleArtifact
|
|
||||||
import com.android.build.api.dsl.ApplicationExtension
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
import com.android.build.api.dsl.ProductFlavor
|
import com.android.build.api.dsl.ProductFlavor
|
||||||
import com.android.build.api.variant.FilterConfiguration
|
import com.android.build.api.variant.FilterConfiguration
|
||||||
|
|
@ -6,7 +5,6 @@ import com.google.protobuf.gradle.id
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
import com.mikepenz.aboutlibraries.plugin.DuplicateRule
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import java.io.File
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
|
|
@ -196,7 +194,6 @@ configure<ApplicationExtension> {
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
kotlin.directories += "src/wolfskin/java"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,47 +224,6 @@ androidComponents {
|
||||||
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
"Wholphin-${variant.flavorName}-${variant.buildType}-${output.versionName.get()}-${output.versionCode.get()}$abi.apk"
|
||||||
output.outputFileName = outputFileName
|
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) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -332,7 +288,6 @@ tasks.named("preBuild") {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":wolfskin-design"))
|
|
||||||
implementation(project(":conductarr-client"))
|
implementation(project(":conductarr-client"))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|
@ -452,18 +407,8 @@ dependencies {
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
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(platform(libs.androidx.compose.bom))
|
||||||
androidTestImplementation(libs.mockk.android)
|
androidTestImplementation(libs.mockk.android)
|
||||||
androidTestImplementation(libs.hilt.android.testing)
|
androidTestImplementation(libs.hilt.android.testing)
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.manifest)
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?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" />
|
|
||||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
|
@ -1,6 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="about">O Wolfskin</string>
|
|
||||||
</resources>
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?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,6 +66,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
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.ui.util.ProvideLocalClock
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
|
|
@ -233,7 +234,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
viewModel.appStart(intent)
|
viewModel.appStart(intent)
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) {
|
||||||
Surface(Modifier.fillMaxSize()) {
|
Surface(Modifier.fillMaxSize()) {
|
||||||
val userPreferences by userPreferencesService.flow.collectAsState(null)
|
val userPreferences by userPreferencesService.flow.collectAsState(null)
|
||||||
if (userPreferences == 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.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||||
import org.justinvisser.wolfskin.tv.WolfskinApp
|
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
|
|
@ -111,7 +111,7 @@ fun MainContent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showContent) {
|
if (showContent) {
|
||||||
WolfskinApp(
|
ApplicationContent(
|
||||||
user = current.user,
|
user = current.user,
|
||||||
server = current.server,
|
server = current.server,
|
||||||
navigationManager = navigationManager,
|
navigationManager = navigationManager,
|
||||||
|
|
|
||||||
|
|
@ -557,7 +557,7 @@ sealed interface AppPreference<Pref, T> {
|
||||||
val ThemeColors =
|
val ThemeColors =
|
||||||
AppChoicePreference<AppPreferences, AppThemeColors>(
|
AppChoicePreference<AppPreferences, AppThemeColors>(
|
||||||
title = R.string.app_theme,
|
title = R.string.app_theme,
|
||||||
defaultValue = AppThemeColors.PURPLE,
|
defaultValue = AppThemeColors.WOLFSKIN,
|
||||||
getter = { it.interfacePreferences.appThemeColors },
|
getter = { it.interfacePreferences.appThemeColors },
|
||||||
setter = { prefs, value ->
|
setter = { prefs, value ->
|
||||||
prefs.updateInterfacePreferences { appThemeColors = value }
|
prefs.updateInterfacePreferences { appThemeColors = value }
|
||||||
|
|
@ -647,19 +647,6 @@ sealed interface AppPreference<Pref, T> {
|
||||||
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
|
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 =
|
val UserProfileSettings =
|
||||||
AppDestinationPreference<AppPreferences>(
|
AppDestinationPreference<AppPreferences>(
|
||||||
title = R.string.override_user_profile_settings,
|
title = R.string.override_user_profile_settings,
|
||||||
|
|
@ -1319,7 +1306,6 @@ val advancedPreferences =
|
||||||
title = R.string.ui_interface,
|
title = R.string.ui_interface,
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.WolfskinScreens,
|
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
AppPreference.BackdropStylePref,
|
AppPreference.BackdropStylePref,
|
||||||
AppPreference.ShowLogos,
|
AppPreference.ShowLogos,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ fun WatchedIconBackground(): Color =
|
||||||
when (LocalTheme.current) {
|
when (LocalTheme.current) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
AppThemeColors.PURPLE,
|
AppThemeColors.PURPLE,
|
||||||
|
AppThemeColors.WOLFSKIN,
|
||||||
AppThemeColors.BLUE,
|
AppThemeColors.BLUE,
|
||||||
AppThemeColors.GREEN,
|
AppThemeColors.GREEN,
|
||||||
AppThemeColors.ORANGE,
|
AppThemeColors.ORANGE,
|
||||||
|
|
@ -55,6 +56,8 @@ fun WatchedIconBackground(): Color =
|
||||||
@Composable
|
@Composable
|
||||||
fun WatchedIconColor(): Color =
|
fun WatchedIconColor(): Color =
|
||||||
when (LocalTheme.current) {
|
when (LocalTheme.current) {
|
||||||
|
AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary
|
||||||
|
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
AppThemeColors.PURPLE,
|
AppThemeColors.PURPLE,
|
||||||
AppThemeColors.BLUE,
|
AppThemeColors.BLUE,
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ fun sliderActiveColor(focused: Boolean): Color {
|
||||||
return when (theme) {
|
return when (theme) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
AppThemeColors.PURPLE,
|
AppThemeColors.PURPLE,
|
||||||
|
AppThemeColors.WOLFSKIN,
|
||||||
AppThemeColors.BLUE,
|
AppThemeColors.BLUE,
|
||||||
AppThemeColors.GREEN,
|
AppThemeColors.GREEN,
|
||||||
AppThemeColors.ORANGE,
|
AppThemeColors.ORANGE,
|
||||||
|
|
@ -179,6 +180,7 @@ fun sliderInactiveColor(focused: Boolean): Color {
|
||||||
return when (theme) {
|
return when (theme) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
AppThemeColors.PURPLE,
|
AppThemeColors.PURPLE,
|
||||||
|
AppThemeColors.WOLFSKIN,
|
||||||
-> {
|
-> {
|
||||||
MaterialTheme.colorScheme.border
|
MaterialTheme.colorScheme.border
|
||||||
.copy(alpha = .25f)
|
.copy(alpha = .25f)
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,6 @@ import java.util.UUID
|
||||||
sealed class Destination(
|
sealed class Destination(
|
||||||
val fullScreen: Boolean = false,
|
val fullScreen: Boolean = false,
|
||||||
) : NavKey {
|
) : NavKey {
|
||||||
@Serializable
|
|
||||||
data class Wolfskin(val route: String) : Destination(false)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Home(
|
data class Home(
|
||||||
val id: Long = 0L,
|
val id: Long = 0L,
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,6 @@ fun DestinationContent(
|
||||||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||||
}
|
}
|
||||||
when (destination) {
|
when (destination) {
|
||||||
is Destination.Wolfskin -> {
|
|
||||||
org.justinvisser.wolfskin.tv.WolfskinDestinationContent(destination.route, modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Destination.Home -> {
|
is Destination.Home -> {
|
||||||
HomePage(
|
HomePage(
|
||||||
preferences = preferences,
|
preferences = preferences,
|
||||||
|
|
|
||||||
|
|
@ -771,6 +771,7 @@ fun navItemColor(
|
||||||
when (theme) {
|
when (theme) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
AppThemeColors.PURPLE,
|
AppThemeColors.PURPLE,
|
||||||
|
AppThemeColors.WOLFSKIN,
|
||||||
AppThemeColors.BLUE,
|
AppThemeColors.BLUE,
|
||||||
AppThemeColors.GREEN,
|
AppThemeColors.GREEN,
|
||||||
AppThemeColors.ORANGE,
|
AppThemeColors.ORANGE,
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ fun SwitchColors(): SwitchColors {
|
||||||
return when (theme) {
|
return when (theme) {
|
||||||
AppThemeColors.UNRECOGNIZED,
|
AppThemeColors.UNRECOGNIZED,
|
||||||
AppThemeColors.PURPLE,
|
AppThemeColors.PURPLE,
|
||||||
|
AppThemeColors.WOLFSKIN,
|
||||||
AppThemeColors.BLUE,
|
AppThemeColors.BLUE,
|
||||||
-> {
|
-> {
|
||||||
SwitchDefaults.colors()
|
SwitchDefaults.colors()
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ 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.OrangeThemeColors
|
||||||
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
|
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.RedThemeColors
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
|
||||||
|
|
||||||
val LocalTheme =
|
val LocalTheme =
|
||||||
compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE }
|
compositionLocalOf<AppThemeColors> { AppThemeColors.WOLFSKIN }
|
||||||
|
|
||||||
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
||||||
when (appThemeColors) {
|
when (appThemeColors) {
|
||||||
|
|
@ -27,13 +28,14 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
||||||
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
|
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
|
||||||
AppThemeColors.RED -> RedThemeColors
|
AppThemeColors.RED -> RedThemeColors
|
||||||
AppThemeColors.BROWN -> BrownThemeColors
|
AppThemeColors.BROWN -> BrownThemeColors
|
||||||
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
|
AppThemeColors.WOLFSKIN -> WolfskinThemeColors
|
||||||
|
AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WholphinTheme(
|
fun WholphinTheme(
|
||||||
darkTheme: Boolean = true,
|
darkTheme: Boolean = true,
|
||||||
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
|
appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val themeColors = getThemeColors(appThemeColors)
|
val themeColors = getThemeColors(appThemeColors)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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;
|
OLED_BLACK = 5;
|
||||||
RED = 6;
|
RED = 6;
|
||||||
BROWN = 7;
|
BROWN = 7;
|
||||||
reserved 8;
|
WOLFSKIN = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EdgeStyle{
|
enum EdgeStyle{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<background android:drawable="@color/ic_banner_background"/>
|
android:src="@mipmap/ic_banner_foreground"
|
||||||
<foreground>
|
android:gravity="fill" />
|
||||||
<inset
|
|
||||||
android:inset="18%"
|
|
||||||
android:drawable="@mipmap/ic_banner_foreground"/>
|
|
||||||
</foreground>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 7 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 22 KiB |
|
|
@ -1,6 +1,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name" translatable="false">Wholphin</string>
|
<string name="app_name" translatable="false">Wolfskin</string>
|
||||||
<string name="app_name_long" translatable="false">Wholphin</string>
|
<string name="app_name_long" translatable="false">Wolfskin</string>
|
||||||
|
|
||||||
<string name="about">About</string>
|
<string name="about">About</string>
|
||||||
<string name="active_recordings">Active Recordings</string>
|
<string name="active_recordings">Active Recordings</string>
|
||||||
|
|
@ -604,8 +604,8 @@
|
||||||
<string name="content_scale_fill">Fill</string>
|
<string name="content_scale_fill">Fill</string>
|
||||||
<string name="content_scale_fill_width">Fill width</string>
|
<string name="content_scale_fill_width">Fill width</string>
|
||||||
<string name="content_scale_fill_height">Fill height</string>
|
<string name="content_scale_fill_height">Fill height</string>
|
||||||
<string name="display_preset_default">Wholphin Default</string>
|
<string name="display_preset_default">Wolfskin Default</string>
|
||||||
<string name="display_preset_compact">Wholphin Compact</string>
|
<string name="display_preset_compact">Wolfskin Compact</string>
|
||||||
<string name="display_preset_series_thumb">Series Thumb images</string>
|
<string name="display_preset_series_thumb">Series Thumb images</string>
|
||||||
<string name="display_preset_episode_thumbnails">Episode Thumbnail images</string>
|
<string name="display_preset_episode_thumbnails">Episode Thumbnail images</string>
|
||||||
|
|
||||||
|
|
@ -637,6 +637,7 @@
|
||||||
<item>@string/black</item>
|
<item>@string/black</item>
|
||||||
<item>@string/red</item>
|
<item>@string/red</item>
|
||||||
<item>@string/brown</item>
|
<item>@string/brown</item>
|
||||||
|
<item>@string/wolfskin_theme</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
<string name="skip_ignore">Ignore</string>
|
<string name="skip_ignore">Ignore</string>
|
||||||
|
|
@ -830,7 +831,7 @@
|
||||||
<string name="select_all">Select all</string>
|
<string name="select_all">Select all</string>
|
||||||
<string name="separate_types">Separate types</string>
|
<string name="separate_types">Separate types</string>
|
||||||
<string name="prefer_logos">Prefer showing logos for titles</string>
|
<string name="prefer_logos">Prefer showing logos for titles</string>
|
||||||
<string name="updated_toast">Wholphin updated to %s</string>
|
<string name="updated_toast">Wolfskin updated to %s</string>
|
||||||
<string name="remove_continue_watching">Remove from continue watching</string>
|
<string name="remove_continue_watching">Remove from continue watching</string>
|
||||||
<string name="remove_next_up">Remove series from next up</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>
|
<string name="view_removed_next_up">View series removed from next up</string>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="wolfskin_screens" translatable="false">Wolfskin screens</string>
|
<string name="wolfskin_theme" translatable="false">Wolfskin</string>
|
||||||
<string name="wolfskin_upstream_credit" translatable="false">based on Wholphin by damontecres</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>
|
<string name="wolfskin_upstream_url" translatable="false">https://github.com/damontecres/Wholphin</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import org.justinvisser.wolfskin.design.WolfskinColors
|
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -10,8 +10,8 @@ import org.junit.Test
|
||||||
class WolfskinThemeColorsTest {
|
class WolfskinThemeColorsTest {
|
||||||
@Test
|
@Test
|
||||||
fun darkPaletteHasReadableNormalAndFocusedText() {
|
fun darkPaletteHasReadableNormalAndFocusedText() {
|
||||||
val television = WolfskinColors.darkScheme
|
val television = WolfskinThemeColors.darkScheme
|
||||||
val material = WolfskinColors.darkSchemeMaterial
|
val material = WolfskinThemeColors.darkSchemeMaterial
|
||||||
val textAndBackgrounds =
|
val textAndBackgrounds =
|
||||||
listOf(
|
listOf(
|
||||||
television.onBackground to television.background,
|
television.onBackground to television.background,
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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")
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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
|
```sh
|
||||||
git fetch origin --tags
|
git fetch origin --tags
|
||||||
make apk # clean, signed default release: three ABI splits + universal
|
make apk # clean, signed default release: three ABI splits + universal
|
||||||
make test # app default/debug suite plus wolfskin-design JVM/capture suite
|
make test # upstream testDefaultDebugUnitTest in the same image, no secrets
|
||||||
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
|
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -48,78 +48,6 @@ not the working tree. An uncommitted merge still builds with the old version nam
|
||||||
Neither an upstream merge nor its validation build authorizes a release or TV
|
Neither an upstream merge nor its validation build authorizes a release or TV
|
||||||
installation.
|
installation.
|
||||||
|
|
||||||
## 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
|
## Toolchain and disk use
|
||||||
|
|
||||||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
||||||
|
|
|
||||||
|
|
@ -30,5 +30,4 @@ dependencyResolutionManagement {
|
||||||
rootProject.name = "Wholphin"
|
rootProject.name = "Wholphin"
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":wholphin-mpv-stub")
|
include(":wholphin-mpv-stub")
|
||||||
include(":wolfskin-design")
|
|
||||||
include(":conductarr-client")
|
include(":conductarr-client")
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
#!/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()
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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}" })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,179 +0,0 @@
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
|
|
||||||
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
|
|
||||||
1037c46c9d804f9349e927257de13ba32cbd1c05e208ec0bc1d345f4b465f1eb src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
|
||||||
c133085ef60612df9753ef3681446d9ccb2eb29b1ee1955b86254714c67525e3 tokens.css
|
|
||||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
|
||||||