Build the Wolfskin TV shell and navigation
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
78
app/src/debug/assets/wolfskin-shell.json
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"source": "Locked Opus fixture/fixture.json; shell-only projection; debug/test only",
|
||||
"profiles": [
|
||||
{
|
||||
"key": "justin",
|
||||
"name": "Justin",
|
||||
"initial": "J",
|
||||
"color": "#62c4ff",
|
||||
"destinations": {
|
||||
"Home": {
|
||||
"kind": "home"
|
||||
},
|
||||
"Movies": {
|
||||
"kind": "library",
|
||||
"collection": "movies"
|
||||
},
|
||||
"Shows": {
|
||||
"kind": "library",
|
||||
"collection": "shows"
|
||||
},
|
||||
"Anime": {
|
||||
"kind": "library",
|
||||
"collection": "anime"
|
||||
},
|
||||
"Discover": {
|
||||
"kind": "discover",
|
||||
"collection": "discover"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mindel",
|
||||
"name": "Mindel",
|
||||
"initial": "M",
|
||||
"color": "#ff9d72",
|
||||
"destinations": {
|
||||
"Home": {
|
||||
"kind": "home"
|
||||
},
|
||||
"Movies": {
|
||||
"kind": "library",
|
||||
"collection": "movies"
|
||||
},
|
||||
"Shows": {
|
||||
"kind": "library",
|
||||
"collection": "shows"
|
||||
},
|
||||
"Discover": {
|
||||
"kind": "discover",
|
||||
"collection": "discover"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "shared",
|
||||
"name": "Shared",
|
||||
"initial": "S",
|
||||
"color": "#9be38f",
|
||||
"destinations": {
|
||||
"Home": {
|
||||
"kind": "home"
|
||||
},
|
||||
"Movies": {
|
||||
"kind": "library",
|
||||
"collection": "movies"
|
||||
},
|
||||
"Shows": {
|
||||
"kind": "library",
|
||||
"collection": "shows"
|
||||
},
|
||||
"Discover": {
|
||||
"kind": "discover",
|
||||
"collection": "discover"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
app/src/default/res/mipmap-anydpi-v26/ic_banner.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:src="@mipmap/ic_banner_foreground"
|
||||
android:gravity="fill" />
|
||||
BIN
app/src/default/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
app/src/default/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
app/src/default/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
app/src/default/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
app/src/default/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
app/src/default/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_banner.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_banner_foreground.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/default/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
app/src/default/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/src/default/res/mipmap-xxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
app/src/default/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
app/src/default/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
app/src/default/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
app/src/default/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 22 KiB |
6
app/src/default/res/values-hu/strings.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="display_preset_default">Wolfskin alapértelmezett beállítása</string>
|
||||
<string name="display_preset_compact">Wolfskin kompakt elrendezés</string>
|
||||
<string name="updated_toast">A Wolfskin frissült a %s verzióra</string>
|
||||
</resources>
|
||||
4
app/src/default/res/values-pl/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about">O Wolfskin</string>
|
||||
</resources>
|
||||
8
app/src/default/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Wolfskin</string>
|
||||
<string name="app_name_long" translatable="false">Wolfskin</string>
|
||||
<string name="display_preset_default">Wolfskin Default</string>
|
||||
<string name="display_preset_compact">Wolfskin Compact</string>
|
||||
<string name="updated_toast">Wolfskin updated to %s</string>
|
||||
</resources>
|
||||
|
|
@ -66,7 +66,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -234,7 +233,7 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
viewModel.appStart(intent)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) {
|
||||
MaterialTheme {
|
||||
Surface(Modifier.fillMaxSize()) {
|
||||
val userPreferences by userPreferencesService.flow.collectAsState(null)
|
||||
if (userPreferences == null) {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
|||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import org.justinvisser.wolfskin.tv.WolfskinApp
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||
|
|
@ -111,7 +111,7 @@ fun MainContent(
|
|||
}
|
||||
|
||||
if (showContent) {
|
||||
ApplicationContent(
|
||||
WolfskinApp(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
navigationManager = navigationManager,
|
||||
|
|
|
|||
|
|
@ -557,7 +557,7 @@ sealed interface AppPreference<Pref, T> {
|
|||
val ThemeColors =
|
||||
AppChoicePreference<AppPreferences, AppThemeColors>(
|
||||
title = R.string.app_theme,
|
||||
defaultValue = AppThemeColors.WOLFSKIN,
|
||||
defaultValue = AppThemeColors.PURPLE,
|
||||
getter = { it.interfacePreferences.appThemeColors },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateInterfacePreferences { appThemeColors = value }
|
||||
|
|
@ -647,6 +647,19 @@ sealed interface AppPreference<Pref, T> {
|
|||
destination = Destination.Settings(PreferenceScreenOption.ADVANCED),
|
||||
)
|
||||
|
||||
val WolfskinScreens =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.wolfskin_screens,
|
||||
defaultValue = true,
|
||||
getter = { org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled },
|
||||
setter = { prefs, value ->
|
||||
org.justinvisser.wolfskin.tv.WolfskinScreenPreference.enabled = value
|
||||
prefs
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val UserProfileSettings =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.override_user_profile_settings,
|
||||
|
|
@ -1306,6 +1319,7 @@ val advancedPreferences =
|
|||
title = R.string.ui_interface,
|
||||
preferences =
|
||||
listOf(
|
||||
AppPreference.WolfskinScreens,
|
||||
AppPreference.ShowClock,
|
||||
AppPreference.BackdropStylePref,
|
||||
AppPreference.ShowLogos,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ fun WatchedIconBackground(): Color =
|
|||
when (LocalTheme.current) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
@ -56,8 +55,6 @@ fun WatchedIconBackground(): Color =
|
|||
@Composable
|
||||
fun WatchedIconColor(): Color =
|
||||
when (LocalTheme.current) {
|
||||
AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary
|
||||
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.BLUE,
|
||||
|
|
|
|||
|
|
@ -143,7 +143,6 @@ fun sliderActiveColor(focused: Boolean): Color {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
@ -180,7 +179,6 @@ fun sliderInactiveColor(focused: Boolean): Color {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
-> {
|
||||
MaterialTheme.colorScheme.border
|
||||
.copy(alpha = .25f)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ import java.util.UUID
|
|||
sealed class Destination(
|
||||
val fullScreen: Boolean = false,
|
||||
) : NavKey {
|
||||
@Serializable
|
||||
data class Wolfskin(val route: String) : Destination(false)
|
||||
|
||||
@Serializable
|
||||
data class Home(
|
||||
val id: Long = 0L,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ fun DestinationContent(
|
|||
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
|
||||
}
|
||||
when (destination) {
|
||||
is Destination.Wolfskin -> {
|
||||
org.justinvisser.wolfskin.tv.WolfskinDestinationContent(destination.route, modifier)
|
||||
}
|
||||
|
||||
is Destination.Home -> {
|
||||
HomePage(
|
||||
preferences = preferences,
|
||||
|
|
|
|||
|
|
@ -771,7 +771,6 @@ fun navItemColor(
|
|||
when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
AppThemeColors.GREEN,
|
||||
AppThemeColors.ORANGE,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ fun SwitchColors(): SwitchColors {
|
|||
return when (theme) {
|
||||
AppThemeColors.UNRECOGNIZED,
|
||||
AppThemeColors.PURPLE,
|
||||
AppThemeColors.WOLFSKIN,
|
||||
AppThemeColors.BLUE,
|
||||
-> {
|
||||
SwitchDefaults.colors()
|
||||
|
|
|
|||
|
|
@ -13,10 +13,9 @@ import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors
|
|||
import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors
|
||||
import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors
|
||||
import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors
|
||||
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
|
||||
|
||||
val LocalTheme =
|
||||
compositionLocalOf<AppThemeColors> { AppThemeColors.WOLFSKIN }
|
||||
compositionLocalOf<AppThemeColors> { AppThemeColors.PURPLE }
|
||||
|
||||
fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
||||
when (appThemeColors) {
|
||||
|
|
@ -28,14 +27,13 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors =
|
|||
AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors
|
||||
AppThemeColors.RED -> RedThemeColors
|
||||
AppThemeColors.BROWN -> BrownThemeColors
|
||||
AppThemeColors.WOLFSKIN -> WolfskinThemeColors
|
||||
AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors
|
||||
AppThemeColors.UNRECOGNIZED -> PurpleThemeColors
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WholphinTheme(
|
||||
darkTheme: Boolean = true,
|
||||
appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN,
|
||||
appThemeColors: AppThemeColors = AppThemeColors.PURPLE,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val themeColors = getThemeColors(appThemeColors)
|
||||
|
|
|
|||
|
|
@ -1,59 +0,0 @@
|
|||
package com.github.damontecres.wholphin.ui.theme.colors
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.github.damontecres.wholphin.ui.theme.ThemeColors
|
||||
|
||||
/** Lilac accents on near-black surfaces, with upstream Purple available separately. */
|
||||
val WolfskinThemeColors =
|
||||
object : ThemeColors {
|
||||
private val background = Color(0xFF07070A)
|
||||
private val lilac = Color(0xFFD2BCFF)
|
||||
private val onLilac = Color(0xFF241334)
|
||||
private val container = Color(0xFF382A49)
|
||||
private val onContainer = Color(0xFFEADCFF)
|
||||
private val surfaceVariant = Color(0xFF24202D)
|
||||
|
||||
override val lightSchemeMaterial = PurpleThemeColors.lightSchemeMaterial
|
||||
override val lightScheme = PurpleThemeColors.lightScheme
|
||||
|
||||
override val darkSchemeMaterial =
|
||||
PurpleThemeColors.darkSchemeMaterial.copy(
|
||||
primary = lilac,
|
||||
onPrimary = onLilac,
|
||||
primaryContainer = container,
|
||||
onPrimaryContainer = onContainer,
|
||||
secondary = lilac,
|
||||
onSecondary = onLilac,
|
||||
secondaryContainer = container,
|
||||
onSecondaryContainer = onContainer,
|
||||
tertiary = lilac,
|
||||
onTertiary = onLilac,
|
||||
tertiaryContainer = container,
|
||||
onTertiaryContainer = onContainer,
|
||||
background = background,
|
||||
surface = background,
|
||||
surfaceVariant = surfaceVariant,
|
||||
inversePrimary = Color(0xFF705090),
|
||||
)
|
||||
|
||||
override val darkScheme =
|
||||
PurpleThemeColors.darkScheme.copy(
|
||||
primary = lilac,
|
||||
onPrimary = onLilac,
|
||||
primaryContainer = container,
|
||||
onPrimaryContainer = onContainer,
|
||||
secondary = lilac,
|
||||
onSecondary = onLilac,
|
||||
secondaryContainer = container,
|
||||
onSecondaryContainer = onContainer,
|
||||
tertiary = lilac,
|
||||
onTertiary = onLilac,
|
||||
tertiaryContainer = container,
|
||||
onTertiaryContainer = onContainer,
|
||||
background = background,
|
||||
surface = background,
|
||||
surfaceVariant = surfaceVariant,
|
||||
inversePrimary = Color(0xFF705090),
|
||||
border = lilac.copy(alpha = .75f),
|
||||
)
|
||||
}
|
||||
|
|
@ -128,7 +128,7 @@ enum AppThemeColors {
|
|||
OLED_BLACK = 5;
|
||||
RED = 6;
|
||||
BROWN = 7;
|
||||
WOLFSKIN = 8;
|
||||
reserved 8;
|
||||
}
|
||||
|
||||
enum EdgeStyle{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:src="@mipmap/ic_banner_foreground"
|
||||
android:gravity="fill" />
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_banner_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:inset="18%"
|
||||
android:drawable="@mipmap/ic_banner_foreground"/>
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
|
|
@ -1,6 +1,6 @@
|
|||
<resources>
|
||||
<string name="app_name" translatable="false">Wolfskin</string>
|
||||
<string name="app_name_long" translatable="false">Wolfskin</string>
|
||||
<string name="app_name" translatable="false">Wholphin</string>
|
||||
<string name="app_name_long" translatable="false">Wholphin</string>
|
||||
|
||||
<string name="about">About</string>
|
||||
<string name="active_recordings">Active Recordings</string>
|
||||
|
|
@ -604,8 +604,8 @@
|
|||
<string name="content_scale_fill">Fill</string>
|
||||
<string name="content_scale_fill_width">Fill width</string>
|
||||
<string name="content_scale_fill_height">Fill height</string>
|
||||
<string name="display_preset_default">Wolfskin Default</string>
|
||||
<string name="display_preset_compact">Wolfskin Compact</string>
|
||||
<string name="display_preset_default">Wholphin Default</string>
|
||||
<string name="display_preset_compact">Wholphin Compact</string>
|
||||
<string name="display_preset_series_thumb">Series Thumb images</string>
|
||||
<string name="display_preset_episode_thumbnails">Episode Thumbnail images</string>
|
||||
|
||||
|
|
@ -637,7 +637,6 @@
|
|||
<item>@string/black</item>
|
||||
<item>@string/red</item>
|
||||
<item>@string/brown</item>
|
||||
<item>@string/wolfskin_theme</item>
|
||||
</string-array>
|
||||
|
||||
<string name="skip_ignore">Ignore</string>
|
||||
|
|
@ -831,7 +830,7 @@
|
|||
<string name="select_all">Select all</string>
|
||||
<string name="separate_types">Separate types</string>
|
||||
<string name="prefer_logos">Prefer showing logos for titles</string>
|
||||
<string name="updated_toast">Wolfskin updated to %s</string>
|
||||
<string name="updated_toast">Wholphin updated to %s</string>
|
||||
<string name="remove_continue_watching">Remove from continue watching</string>
|
||||
<string name="remove_next_up">Remove series from next up</string>
|
||||
<string name="view_removed_next_up">View series removed from next up</string>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="wolfskin_theme" translatable="false">Wolfskin</string>
|
||||
<string name="wolfskin_screens" translatable="false">Wolfskin screens</string>
|
||||
<string name="wolfskin_upstream_credit" translatable="false">based on Wholphin by damontecres</string>
|
||||
<string name="wolfskin_upstream_url" translatable="false">https://github.com/damontecres/Wholphin</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.ui
|
|||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors
|
||||
import org.justinvisser.wolfskin.design.WolfskinColors
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -10,8 +10,8 @@ import org.junit.Test
|
|||
class WolfskinThemeColorsTest {
|
||||
@Test
|
||||
fun darkPaletteHasReadableNormalAndFocusedText() {
|
||||
val television = WolfskinThemeColors.darkScheme
|
||||
val material = WolfskinThemeColors.darkSchemeMaterial
|
||||
val television = WolfskinColors.darkScheme
|
||||
val material = WolfskinColors.darkSchemeMaterial
|
||||
val textAndBackgrounds =
|
||||
listOf(
|
||||
television.onBackground to television.background,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomePageSettingsState
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage
|
||||
import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsViewModel
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||
import org.justinvisser.wolfskin.design.WolfskinTokens
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/** The frozen st-094 state opens User profile settings, not the separate Playback press path. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class SettingsHandOffCaptureTest {
|
||||
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Before fun fullscreen() {
|
||||
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
|
||||
@Suppress("DEPRECATION")
|
||||
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||
}
|
||||
|
||||
@Test fun st094_realUserProfileHomeSettingsHandOff() {
|
||||
val model = mockk<HomeSettingsViewModel>(relaxed = true)
|
||||
every { model.state } returns MutableStateFlow(HomePageSettingsState.EMPTY.copy(loading = LoadingState.Success))
|
||||
every { model.serverPluginActive } returns MutableStateFlow(false)
|
||||
every { model.settingsChanged } returns MutableStateFlow(false)
|
||||
val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null)
|
||||
val ratings = SeerrRatingsService(mockk(relaxed = true), mockk(relaxed = true))
|
||||
val images = mockk<ImageUrlService>(relaxed = true)
|
||||
rule.setContent {
|
||||
WolfskinTheme {
|
||||
CompositionLocalProvider(LocalSeerrRatingsService provides ratings, LocalImageUrlService provides images) {
|
||||
HomeSettingsPage(preferences, Modifier.fillMaxSize().background(WolfskinTokens.Color.bg), viewModel = model)
|
||||
}
|
||||
}
|
||||
}
|
||||
rule.mainClock.advanceTimeBy(1000)
|
||||
rule.waitForIdle()
|
||||
rule.onNodeWithText(rule.activity.getString(R.string.customize_home)).assertIsDisplayed()
|
||||
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")))
|
||||
directory.mkdirs()
|
||||
rule.onRoot().captureRoboImage(File(directory, "st-094.png").absolutePath)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
|
||||
import java.util.UUID
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** The actual Advanced preference persists its value and changes the shell's renderer policy. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34], application = Application::class)
|
||||
class ShellRoutingTest {
|
||||
private val context: Application get() = ApplicationProvider.getApplicationContext()
|
||||
private val movies = Destination.MediaItem(UUID.fromString("00000000-0000-0000-0000-000000000001"), BaseItemKind.COLLECTION_FOLDER, CollectionType.MOVIES)
|
||||
private val libraries = mapOf("movies" to movies, "shows" to movies, "anime" to movies)
|
||||
|
||||
@Before fun initialize() { WolfskinScreenPreference.initialize(context); WolfskinScreenPreference.enabled = true }
|
||||
@After fun restore() { WolfskinScreenPreference.enabled = true }
|
||||
|
||||
@Test fun advancedSwitchRestoresUpstreamHomeAndLibrariesAndPersistsAcrossReload() {
|
||||
val preference = AppPreference.WolfskinScreens
|
||||
val preferences = AppPreferences.getDefaultInstance()
|
||||
assertNull(resolveUpstreamDestination(Destination.Home(), true, libraries))
|
||||
assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), true, libraries))
|
||||
preference.setter(preferences, false)
|
||||
WolfskinScreenPreference.initialize(context)
|
||||
assertFalse(preference.getter(preferences))
|
||||
assertTrue(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries) is Destination.Home)
|
||||
listOf("movies", "shows", "anime").forEach { route ->
|
||||
assertEquals(movies, resolveUpstreamDestination(Destination.Wolfskin(route), WolfskinScreenPreference.enabled, libraries))
|
||||
}
|
||||
preference.setter(preferences, true)
|
||||
assertNull(resolveUpstreamDestination(Destination.Home(), WolfskinScreenPreference.enabled, libraries))
|
||||
assertNull(resolveUpstreamDestination(Destination.Wolfskin("movies"), WolfskinScreenPreference.enabled, libraries))
|
||||
}
|
||||
|
||||
@Test fun settingsAndPickerNeverBecomeUpstreamScreensButUnrelatedRoutesPassThrough() {
|
||||
val settings = Destination.Wolfskin("settings")
|
||||
val upstream = Destination.Settings(PreferenceScreenOption.ADVANCED)
|
||||
for (enabled in listOf(false, true)) {
|
||||
assertNull(resolveUpstreamDestination(settings, enabled, libraries))
|
||||
assertEquals(upstream, resolveUpstreamDestination(upstream, enabled, libraries))
|
||||
assertEquals(Destination.Search("name"), resolveUpstreamDestination(Destination.Search("name"), enabled, libraries))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import org.json.JSONObject
|
||||
import org.justinvisser.wolfskin.design.ShellPerson
|
||||
|
||||
/** Prototype identities are packaged exclusively in debug assets, never release resources. */
|
||||
fun readShellPeople(context: Context, saved: List<JellyfinUser>): List<ShellPerson> {
|
||||
if (BuildConfig.DEBUG) {
|
||||
val json = context.assets.open("wolfskin-shell.json").bufferedReader().use { JSONObject(it.readText()) }
|
||||
val profiles = json.getJSONArray("profiles")
|
||||
return List(profiles.length()) { index ->
|
||||
val person = profiles.getJSONObject(index)
|
||||
ShellPerson(person.getString("key"), person.getString("name"), person.getString("initial"), person.getJSONObject("destinations").has("Anime"))
|
||||
}
|
||||
}
|
||||
return saved.map { user ->
|
||||
val name = user.name.orEmpty()
|
||||
ShellPerson(name.lowercase(), name, name.take(1).uppercase(), false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
||||
/** The rollout switch changes only intercepted screens; unrelated destinations always pass through. */
|
||||
fun resolveUpstreamDestination(destination: Destination, wolfskinEnabled: Boolean, libraries: Map<String, Destination>): Destination? =
|
||||
when (destination) {
|
||||
is Destination.Home, is Destination.MediaItem -> if (wolfskinEnabled) null else destination
|
||||
is Destination.Wolfskin -> if (!wolfskinEnabled && destination.route in LIBRARY_ROUTES) libraries[destination.route] else null
|
||||
else -> destination
|
||||
}
|
||||
|
||||
private val LIBRARY_ROUTES = setOf("movies", "shows", "anime")
|
||||
|
|
@ -0,0 +1,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)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package org.justinvisser.wolfskin.tv
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
|
||||
/** The single rollout preference is exposed only in upstream Advanced settings. */
|
||||
object WolfskinScreenPreference {
|
||||
private const val KEY = "wolfskin.screens.enabled"
|
||||
private lateinit var preferences: SharedPreferences
|
||||
private var current by mutableStateOf(true)
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||
current = preferences.getBoolean(KEY, true)
|
||||
}
|
||||
var enabled: Boolean
|
||||
get() = current
|
||||
set(value) {
|
||||
current = value
|
||||
preferences.edit { putBoolean(KEY, value) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package org.justinvisser.wolfskin.tv.read
|
||||
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/** SDK reads are confined here so shell and screen state never depend on SDK responses. */
|
||||
class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
|
||||
suspend fun readDestinations(userId: UUID): Map<String, Destination> {
|
||||
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
|
||||
return buildMap {
|
||||
libraries.forEach { library ->
|
||||
val name = library.name.orEmpty().lowercase()
|
||||
val key = when {
|
||||
"anime" in name -> "anime"
|
||||
library.collectionType == CollectionType.MOVIES -> "movies"
|
||||
library.collectionType == CollectionType.TVSHOWS -> "shows"
|
||||
else -> null
|
||||
}
|
||||
if (key != null) put(key, Destination.MediaItem(library.id, BaseItemKind.COLLECTION_FOLDER, library.collectionType))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||