Build the Wolfskin TV shell and navigation

This commit is contained in:
justinzeus 2026-11-05 14:38:38 +01:00
parent de452448ba
commit f60a22e435
97 changed files with 4363 additions and 91 deletions

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,190 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import androidx.activity.compose.BackHandler
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.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.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.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.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() {
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) {
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, modifier, handlesContentKeys = upstream == null) { contentModifier ->
when {
upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)
else -> ShellPlaceholder(route, contentModifier)
}
}
}
}
},
)
}
}
}
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
@Composable
fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier)
}

View file

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

View file

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