Compare commits

..

28 commits

Author SHA1 Message Date
5162c299db Merge pull request 'Ground Discover watch claims and request history in evidence' (#46) from fm/tv-fb1-truth into main 2026-11-06 20:37:47 +01:00
278335f009 Present watched recommendation seeds and request history truthfully
All checks were successful
review/code-gpt Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
review/design-claude Firstmate read the PR evidence: TV-only labels and seed grouping, recorded-wire acceptance matrix, fixtures contain no secrets (checked), native before/after frames, full serial suite 697/190/17 green plus apk-debug; no tokens, ring or spacing changes; device check follows
2026-11-06 20:12:06 +01:00
deb24e3c9f Merge pull request 'Persist the debug signing key across container builds' (#45) from fm/tv-stable-key into main 2026-11-06 19:54:26 +01:00
556d633e88 Persist the debug signing key across container builds
All checks were successful
review/code-gpt Build-script only (docker/gradle.sh + README): persistent debug keystore outside the checkout, readonly bind-mount, fails loudly; firstmate read the diff; lane showed identical signer SHA-256 across two optimized builds and the debug build
2026-11-06 17:37:36 +01:00
34087d8eb5 Merge pull request 'Unify TV control focus rings with the approved card style' (#44) from fm/tv-focus-style into main 2026-11-06 15:58:25 +01:00
33dc65bf18 Merge remote-tracking branch 'origin/main' into fm/tv-focus-style
All checks were successful
review/code-gpt Firstmate read the PR evidence and viewed the before/after pairs and native captures against the approved card ring (4 px straddle, no gap); measured ring bounds, inventory with unchanged fill surfaces listed; 20+ targeted native captures and key/session tests; device verification follows on the optimized build
review/design-claude Firstmate read the PR evidence and viewed the before/after pairs and native captures against the approved card ring (4 px straddle, no gap); measured ring bounds, inventory with unchanged fill surfaces listed; 20+ targeted native captures and key/session tests; device verification follows on the optimized build
2026-11-06 15:52:02 +01:00
ef3ae3f21b Unify focus rings on the approved accent straddle style 2026-11-06 15:52:02 +01:00
d0e38306b3 Merge pull request 'Add a release-derived optimized TV comparison build' (#43) from fm/tv-opt-build into main 2026-11-06 15:45:03 +01:00
5306f8ddc4 Keep optimized versions compatible with upgrade and update parsing
All checks were successful
review/code-gpt Build-only change (new optimized build type, own package, baseline seed, version parser test) read by firstmate; release and debug unchanged; the optimized APK was installed and run on the real box (sign-in, picker, Home, Movies work under R8; 0.4 s cold start, 14x lower p99 frame time than debug); version-suffix defect found on the box and fixed in this head
2026-11-06 15:36:20 +01:00
59f4a06815 Register the optimized baseline profile source explicitly 2026-11-06 15:21:19 +01:00
427b18cdc5 Add a release-derived optimized TV comparison variant 2026-11-06 15:12:01 +01:00
93a41ea2d3 Merge pull request 'Keep the person picker closed after switching users' (#42) from fm/tv-picker-ok into main 2026-11-06 15:01:46 +01:00
1c21f50acb Keep the person picker closed after switching users
All checks were successful
review/code-gpt Picker double-OK fix verified on the real box (B9): three single-OK person switches justin/mindel/shared, no crash, logcat clean; 45 targeted tests, final-head apk build
2026-11-06 14:48:36 +01:00
e8d0c52088 Merge pull request 'Keep TV person switches off the main-thread socket close path' (#41) from fm/tv-person-switch-fix into main 2026-11-06 14:18:51 +01:00
db2541feff Move person-switch client disposal off the main thread
All checks were successful
review/code-gpt Person-switch crash fix verified on the real box (B8: justin/mindel/shared switched via the picker three times, no crash, logcat clean); firstmate read the ConductarrSession disposal-scope delta (generation-guarded, IO disposal, no double close); 53 targeted tests
2026-11-06 14:05:01 +01:00
f37ad0f0fe Merge pull request 'Give TV cards roomy spacing and edge-straddling focus rings' (#40) from fm/tv-spacing-b7 into main 2026-11-06 13:24:16 +01:00
92c5661be0 Preserve poster progress and focus across library rail changes
All checks were successful
review/code-gpt GPT review blockers fixed and read by firstmate: poster progress inset back to Title.cardProgressInset (220 px track) with test tag + regression; library focus-follow effect keyed on columns/focusedRow/header/rowHeight with a 199<->200 regression; token values/ring bounds/toolbar gap passed review on c17447c5; walked on the real box
2026-11-06 13:21:06 +01:00
b9ea593735 Merge remote-tracking branch 'origin/main' into fm/tv-spacing-b7 2026-11-06 13:17:37 +01:00
711dbebf28 Merge pull request 'Fix TV startup navigation and complete title pages' (#39) from fm/tv-walk-fix1 into main 2026-11-06 13:16:43 +01:00
6e0f9952cf Reconcile removed Anime roots without discarding pushed pages
All checks were successful
review/code-gpt GPT review blocker B1 (Anime root removal strands Back) fixed: delta read by firstmate (ShellState reconciles route/root to Home, preservePushed replaces stack root, 111-line regression, 16 targeted tests green); all other review areas cleared on 9e15dbee
2026-11-06 13:11:08 +01:00
c17447c528 Give TV cards roomy spacing and edge-straddling focus rings
Some checks failed
review/code-gpt Spacing review: progress-bar inset regression; rail transition focus invalidation
2026-11-06 12:53:51 +01:00
9e15dbee8c Preserve navigation focus when Anime availability changes
Some checks failed
review/code-gpt Anime removal leaves active root invalid; Back pops a singleton stack (B1).
2026-11-06 12:33:10 +01:00
53b8358317 Hide Anime destinations without visible library titles 2026-11-06 12:25:51 +01:00
5a84666074 Synchronize fresh-sign-in navigation and restore test globals 2026-11-06 12:11:42 +01:00
22850632ec Restrict shell route and Back handling to the current entry 2026-11-06 12:03:20 +01:00
afff383b75 Read live shell state inside cached navigation entries 2026-11-06 11:52:48 +01:00
d401ebc355 Fix TV startup navigation and complete title pages 2026-11-06 11:27:35 +01:00
e794dec253 Merge pull request 'Fix post-sign-in crash from snapshot state writes' (#38) from fm/tv-hotfix-snapshot-write into main 2026-11-06 11:09:51 +01:00
75 changed files with 13108 additions and 327 deletions

View file

@ -133,6 +133,13 @@ configure<ApplicationExtension> {
isDebuggable = true isDebuggable = true
applicationIdSuffix = ".debug" applicationIdSuffix = ".debug"
} }
create("optimized") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".opt"
matchingFallbacks += "release"
}
} }
flavorDimensions += "version" flavorDimensions += "version"
productFlavors { productFlavors {
@ -199,6 +206,10 @@ configure<ApplicationExtension> {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
kotlin.directories += "src/wolfskin/java" kotlin.directories += "src/wolfskin/java"
} }
getByName("optimized") {
kotlin.directories += "src/release/java"
baselineProfiles.directories += "src/optimized/baselineProfiles"
}
} }
testOptions { testOptions {
@ -228,6 +239,11 @@ 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 == "defaultOptimized") {
tasks.withType<Test>().configureEach {
systemProperty("wolfskin.optimizedVersionName", variant.outputs.first().versionName.get())
}
}
if (variant.name == "defaultDebug") { if (variant.name == "defaultDebug") {
val packagedApks = variant.artifacts.get(SingleArtifact.APK) val packagedApks = variant.artifacts.get(SingleArtifact.APK)
val artifactLoader = variant.artifacts.getBuiltArtifactsLoader() val artifactLoader = variant.artifacts.getBuiltArtifactsLoader()

View file

@ -0,0 +1,8 @@
# Hand-maintained startup/shell seed; supplement with device-recorded navigation profiles.
Lcom/github/damontecres/wholphin/WholphinApplication;
HSPLcom/github/damontecres/wholphin/WholphinApplication;->onCreate()V
Lcom/github/damontecres/wholphin/MainActivity;
HSPLcom/github/damontecres/wholphin/MainActivity;->onCreate(Landroid/os/Bundle;)V
Lorg/justinvisser/wolfskin/tv/WolfskinAppKt;
Lorg/justinvisser/wolfskin/tv/WolfskinScreenPreference;
Lorg/justinvisser/wolfskin/tv/ConductarrPreference;

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">Wolfskin (Opt)</string>
</resources>

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.test package com.github.damontecres.wholphin.test
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import org.junit.Assert import org.junit.Assert
import org.junit.Test import org.junit.Test
@ -18,6 +19,12 @@ class VersionCompareTests {
val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234") val V_0_1_1_5 = Version.fromString("v0.1.1-5-gabc1234")
} }
@Test
fun optimizedVersionRemainsCompatibleWithUpgradeAndUpdateChecks() {
val optimizedVersion = Version.fromString(requireNotNull(System.getProperty("wolfskin.optimizedVersionName")))
Assert.assertEquals(Version.fromString(BuildConfig.VERSION_NAME), optimizedVersion)
}
@Test @Test
fun testIsAtLeast() { fun testIsAtLeast() {
Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0)) Assert.assertTrue(V_0_2_0.isAtLeast(V_0_2_0))

View file

@ -0,0 +1,74 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.serialization.json.Json
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.justinvisser.wolfskin.conductarr.model.ReadinessStage
import org.justinvisser.wolfskin.design.ComingInStage
import org.justinvisser.wolfskin.design.ShellPerson
import org.junit.Assert.assertEquals
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.model.ActivityFeed
/** Recorded history separates acquisition, visibility proof, entitlement and file health. */
class ActivityTruthProjectionTest {
private val rows = Json { ignoreUnknownKeys = true }.decodeFromString<ActivityFeed>(
javaClass.getResourceAsStream("/conductarr/activity-history.json")!!.bufferedReader().use { it.readText() }).items.orEmpty().associateBy { it.id }
@Test fun recordedHistoryDoesNotClaimActiveWorkOrFailedDownloadsWithoutEvidence() {
val viewer = ShellPerson("justin", "Justin", "J", false)
val expected = mapOf(
"request-2" to "Check needed", // Steins;Gate: represented file, failed visibility proof.
"request-1" to "Check needed", // Yu Yu Hakusho: represented files, failed visibility proof.
"dispatch-732" to "Check needed", // Love is Blind: same proof failure, not failed acquisition.
"dispatch-750" to "In another library", // Spider-Man: Homecoming: held elsewhere.
"dispatch-725" to "Removed", // One Hour Photo: deleted, absent actor.
"dispatch-723" to "Removed", // A historical play is not a current holding.
"dispatch-728" to "Not out yet", // The Body: unreleased.
"dispatch-721" to "File health needs attention", // Avatar: playback fault despite a holding.
"request-8" to "Waiting for a file", // Partition: no active transfer is established.
"dispatch-755" to "Waiting for a file", // Netherlands: primary import not observed.
)
assertNull(rows.getValue("dispatch-725").comingIn().actor(viewer))
expected.forEach { (id, label) -> assertEquals(rows.getValue(id).title, label, rows.getValue(id).comingIn().stage.label) }
listOf("request-2", "request-1", "dispatch-732", "dispatch-721").forEach { id ->
val source = rows.getValue(id)
val projected = source.comingIn()
val affected = source.stages.orEmpty().first { it.state == ReadinessStage.State.FAILED }
assertEquals(affected.summary, projected.summary)
assertFalse(projected.readiness[1] == "Failed")
}
}
@Test fun currentHealthSummaryKeepsEpisodeAndSourceScopeEvenWhenVisibilityIsDone() {
val row = rows.getValue("dispatch-721")
val summary = "S01E15 · primary source: unreadable; S02E08 · mirror source: unreadable."
val projected = row.copy(stages = row.stages.orEmpty().map {
when (it.name) {
ReadinessStage.Name.PLAYBACK -> it.copy(summary = summary)
ReadinessStage.Name.VISIBILITY -> it.copy(state = ReadinessStage.State.DONE)
else -> it
}
}).comingIn()
assertEquals(ComingInStage.FileHealth, projected.stage)
assertEquals(summary, projected.summary)
}
@Test fun absentTransferEvidenceNeverTurnsInProgressIntoDownloading() {
val row = rows.getValue("request-8")
val projected = row.copy(stages = row.stages.orEmpty().map {
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(summary = "Dispatch accepted.") else it
}).comingIn()
assertEquals(ComingInStage.Waiting, projected.stage)
}
@Test fun missingMembershipIsNeutralButProvenReadinessStaysReady() {
val row = rows.getValue("dispatch-725")
val absent = row.copy(stages = row.stages.orEmpty().map {
if (it.name == ReadinessStage.Name.ACQUISITION) it.copy(state = ReadinessStage.State.UNKNOWN, summary = "No current observation.") else it
})
assertEquals(ComingInStage.NotInLibrary, absent.comingIn().stage)
val ready = rows.values.first { it.stages.orEmpty().any { stage -> stage.name == ReadinessStage.Name.VISIBILITY && stage.state == ReadinessStage.State.DONE } }
assertEquals(ComingInStage.Ready, ready.comingIn().stage)
}
}

View file

@ -0,0 +1,106 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.BaseItemDto
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.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** An audience grants a grouping, not evidence that this user's Jellyfin library has titles. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeAvailabilityTest {
private var previousFixtures = false
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun configuredAnimeAudienceNeedsActualTitlesBeforeItCreatesATabOrHomeShelf() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
for ((key, token) in listOf("shared" to SHARED_TOKEN, "mindel" to MINDEL_TOKEN)) {
for (count in listOf(0, 1)) FakeConductarr().use { fake ->
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = key, serverId = server.id, accessToken = token)
conductarr.select("${server.id}:${user.id}", token, fake.url)
conductarr.authenticate()
val identity = conductarr.identity.value!!
val audiences = identity.provider.audiences()!!.filter {
it.isAnime && (it.owner == identity.operator!!.requester || identity.operator.watchUser in it.users.orEmpty())
}
val names = audiences.flatMap { it.libraries.orEmpty() }.distinct()
assertTrue("The wire fixture must grant this person an Anime grouping", names.isNotEmpty())
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
val api = mockk<ApiClient>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(names.map {
BaseItemDto(id = UUID.randomUUID(), name = it, type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
})
val titles = List(count) { BaseItemDto(id = UUID.randomUUID(), name = "An Anime series", type = BaseItemKind.SERIES) }
coEvery { items.getItems(userId = user.id, parentId = any(), recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1) } returns successQueryResult(titles)
val libraries = LibraryReadFacade(api)
val session = WolfskinSession().apply {
state = ShellState(listOf(ShellPerson(key, key, key.take(1), count == 0, user.id.toString())), key).apply {
pickerVisible = false
focusNav(tabs.size)
}
}
val model = WolfskinShellViewModel(context, session, mockk(), mockk(), mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
requireNotNull(model.onForeground(server, user)).join()
assertEquals("$key with $count Anime titles", count > 0, "anime" in session.state!!.tabs)
assertEquals("The avatar must remain focused when Anime appears or disappears",
session.state!!.tabs.size, session.state!!.navIndex)
val home = HomeLibrary(destinations = mapOf("anime" to titles.map { HomeTitle(it.id.toString(), it.name.orEmpty(), "tv") }),
destinationTotals = mapOf("anime" to count))
assertEquals("An empty Anime audience must not create a Home shelf", count > 0,
buildHomeRows(home, emptyList()).any { it.kind == HomeRowKind.Shelf && it.target == "anime" })
} finally { model.viewModelScope.cancel() }
}
}
}
}
}

View file

@ -0,0 +1,111 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
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.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class AnimeRootRemovalTest {
private var previousFixtures = false
private var previousUrl = ""
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(ApplicationProvider.getApplicationContext<Application>())
previousFixtures = ConductarrPreference.fixtures
previousUrl = ConductarrPreference.baseUrl
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
}
@Test fun removedAnimeRootReturnsHomeWithoutDiscardingPushedPagesOrAvatarFocus() = runBlocking {
val title = Destination.Wolfskin("title/tv%3A123")
val person = Destination.Wolfskin("person/456")
val scenarios = listOf(emptyList<Destination.Wolfskin>() to false, emptyList<Destination.Wolfskin>() to true,
listOf(title) to false, listOf(title, person) to false)
for ((pushed, avatarFocused) in scenarios) FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val context = ApplicationProvider.getApplicationContext<Application>()
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns null
var hasTitles = true
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
coEvery { libraries.hasAnimeTitles(user.id) } coAnswers { hasTitles }
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, user)
requireNotNull(model.onForeground(server, user)).join()
val shell = requireNotNull(session.state)
assertTrue(shell.person.hasAnime)
shell.key(ShellKey.Ok)
shell.focusNav(shell.tabs.indexOf("anime"))
shell.key(ShellKey.Ok)
assertEquals(listOf(Destination.Wolfskin("anime")), navigation.backStack.toList())
for (page in pushed) {
navigation.navigateTo(page)
shell.enterDestination(page.route)
}
if (avatarFocused) shell.focusNav(shell.tabs.size)
hasTitles = false
requireNotNull(model.onForeground(server, user)).join()
assertFalse("The foreground read removed the last accessible Anime title", "anime" in shell.tabs)
assertEquals("Home must replace the missing root, not the current pushed page",
listOf(Destination.Home()) + pushed, navigation.backStack.toList())
assertEquals(pushed.lastOrNull()?.route ?: "home", shell.route)
assertEquals("The selected root must be Home even below a pushed page", 0, shell.currentTab)
if (avatarFocused) assertEquals(shell.tabs.size, shell.navIndex)
shell.key(ShellKey.Back)
if (pushed.isEmpty()) {
assertTrue("Back from the replacement Home must enter its nav ladder", shell.navFocused)
assertEquals("home", shell.route)
assertEquals(listOf(Destination.Home()), navigation.backStack.toList())
if (!avatarFocused) assertEquals(0, shell.navIndex)
} else {
assertEquals("Back pops only the current page and retains the replacement Home root",
listOf(Destination.Home()) + pushed.dropLast(1), navigation.backStack.toList())
}
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -41,7 +41,6 @@ class ConductarrProjectionTest {
assertEquals(ComingInStage.Downloading, request.stage) assertEquals(ComingInStage.Downloading, request.stage)
assertEquals("Mindel", request.requesterName) assertEquals("Mindel", request.requesterName)
assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true))) assertEquals("Shared", request.actor(ShellPerson("shared", "Shared", "S", true)))
assertEquals(1, requests.openCount)
} }
} }
} }

View file

@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-2.0-only
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.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.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.ConductarrClient
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native frames exercise recorded activity and Mindel's feed through the shipped provider. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ConductarrTruthCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val people = listOf(ShellPerson("justin", "Justin", "J", false), ShellPerson("mindel", "Mindel", "M", false))
@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 requestHistory() = requests("requests-history", "request-2")
@Test fun requestHealth() = requests("requests-health", "dispatch-721", expanded = true)
@Test fun requestProofDetails() = requests("requests-proof", "request-2", expanded = true)
@Test fun requestRemoved() = requests("requests-removed", "dispatch-725")
@Test fun discoverMovies() = discover("movie")
@Test fun discoverSeries() = discover("tv")
private fun requests(name: String, focused: String, expanded: Boolean = false) {
FakeConductarr().use { fake ->
fake.activityBody = resource("activity-history.json")
ConductarrClient(fake.url, JUSTIN_TOKEN, MemoryCacheStore()).use { client ->
val snapshot = runBlocking { ConductarrProvider(client).requests().last() }.let { it.copy(items = it.items.map { row -> row.copy(poster = null) }) }
val shell = ShellState(people, "justin").apply {
pickerVisible = false
enterDestination("settings")
requests.update(snapshot)
openRequests()
requests.focus(focused)
if (expanded) requests.key(ShellKey.Right)
}
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } }
capture(name)
rule.onAllNodesWithText("by Unknown", substring = true).assertCountEquals(0)
rule.onNodeWithText("Requests · 56 requests").assertExists()
if (expanded) rule.onNodeWithText(snapshot.items.first { it.id == focused }.summary!!).assertIsDisplayed()
}
}
}
private fun discover(type: String) {
FakeConductarr().use { fake ->
fake.feedBody = resource("mindel-feed.json")
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
val provider = ConductarrProvider(client)
val snapshot = runBlocking { provider.discover("mindel").last() }.let { original ->
fun noArt(title: DiscoverTitle) = title.copy(poster = null, backdrop = null)
original.copy(picks = original.picks.map(::noArt), seeds = original.seeds.map { it.copy(items = it.items.map(::noArt)) })
}
val shell = ShellState(people, "mindel").apply { pickerVisible = false; enterDestination("discover") }
val operator = runBlocking { (client.read(org.justinvisser.wolfskin.conductarr.TvRoutes.operator()).last() as org.justinvisser.wolfskin.conductarr.ReadResult.Value).saved.value }
val state = DiscoverState(shell, snapshot, provider.operations(requireNotNull(operator)), CoroutineScope(Dispatchers.Main.immediate))
state.filter = type
state.focus(state.firstShelf + state.lanes.indexOfFirst { it.title.startsWith("Because you watched") }, 0)
rule.setContent { WolfskinTheme { ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { DiscoverScreen(state, it) } } }
capture("discover-$type")
rule.onNodeWithText(if (type == "tv") "Because you watched Gilmore Girls" else "Because you watched Locke").assertIsDisplayed()
}
}
}
private fun capture(name: String) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "truth").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
private fun resource(name: String) = javaClass.getResourceAsStream("/conductarr/$name")!!.bufferedReader().use { it.readText() }
}

View file

@ -63,7 +63,6 @@ class DiscoverLoadingFocusTest {
rule.runOnIdle { rule.runOnIdle {
assertNotNull(current.title) assertNotNull(current.title)
assertEquals("The Amazing Spider-Man", current.title!!.title) assertEquals("The Amazing Spider-Man", current.title!!.title)
assertEquals("discover-title/${current.title!!.key}", shell.route)
} }
} }

View file

@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.*
import org.junit.Assert.*
import org.junit.Test
import org.justinvisser.wolfskin.conductarr.ConductarrClient
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
/** Equal captions are not equal seed identities; repeated contributions union their evidence. */
class DiscoverSeedProjectionTest {
@Test fun seedIdentitySurvivesTitleCollisionsAndWatchEvidenceAcrossCandidates() = runBlocking {
FakeConductarr().use { fake ->
val feed = fake.wire.getValue("feed").jsonObject
val candidate = feed.getValue("movies").jsonArray.first().jsonObject
val first = JsonObject(candidate + ("contributions" to Json.parseToJsonElement("""[
{"media_type":"movie","tmdb_id":1,"title":"Same title","watched":true,"added":false},
{"media_type":"tv","tmdb_id":1,"title":"Same title","watched":false,"added":true},
{"media_type":"movie","tmdb_id":2,"watched":true,"added":false}
]""")))
val second = JsonObject(candidate + mapOf("tmdb_id" to JsonPrimitive(43), "contributions" to Json.parseToJsonElement("""[
{"media_type":"movie","tmdb_id":1,"title":"Renamed seed","watched":false,"added":true}
]""")))
fake.feedBody = JsonObject(feed + ("movies" to JsonArray(listOf(first, second)))).toString()
ConductarrClient(fake.url, MINDEL_TOKEN, MemoryCacheStore()).use { client ->
val seeds = ConductarrProvider(client).discover("mindel").last().seeds
assertEquals(listOf("movie:1", "tv:1"), seeds.map { it.key })
assertEquals(listOf(42L, 43L), seeds.first().items.map { it.tmdb })
assertTrue(seeds.first().watched && seeds.first().added)
assertFalse(seeds.last().watched)
assertTrue(seeds.last().added)
}
}
}
}

View file

@ -0,0 +1,98 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import io.mockk.coEvery
import io.mockk.mockk
import java.util.concurrent.CountDownLatch
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Catalogue details must reach the same late-filling cast/person/related surface as held titles. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverTitleTimingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun discoverDetailsFillCastAndRelatedAfterAsyncReplyAndBackRestoresDiscover() {
FakeConductarr().use { fake ->
fake.localArt = true
ConductarrSession(MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
runBlocking { session.authenticate() }
val identity = session.identity.value!!
val facade = mockk<TitleReadFacade>()
coEvery { facade.findHeld(any(), any()) } returns null
val navigation = NavigationManager().apply { replace(Destination.Wolfskin("discover")) }
val model = WolfskinTitleViewModel(rule.activity, facade, mockk(), navigation, session, mockk(relaxed = true))
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}
lateinit var discover: DiscoverState
val release = CountDownLatch(1)
try {
rule.setContent {
val route = (navigation.backStack.last() as Destination.Wolfskin).route
LaunchedEffect(route) { shell.enterDestination(route) }
discover = rememberDiscoverState(shell, { navigation.navigateTo(Destination.Wolfskin(it)) }, navigation::goBack, identity = identity, fixtures = false)
WolfskinTheme {
if (route.startsWith("title/")) TitleDestination(route, shell, UserPreferences(AppPreferences.getDefaultInstance(), null), viewModel = model, fixtures = false)
else ShellSurface(shell) { DiscoverScreen(discover, it) }
}
}
rule.waitUntil(30_000) { discover.snapshot.status == DiscoverStatus.Ready }
rule.runOnIdle {
fake.beforeResponse = { if (it.endsWith("/cast")) release.await() }
discover.openTitle(DiscoverTitle(44, "tv", "Black Lightning"))
}
rule.waitForIdle()
assertTrue("Discover details must open the shared title destination, not its header-only preview", (navigation.backStack.last() as Destination.Wolfskin).route.startsWith("title/"))
rule.waitUntil(10_000) { fake.calls.any { it.url.encodedPath == "/catalogue/titles/44/tv/cast" } }
rule.runOnIdle { release.countDown() }
rule.waitUntil(30_000) {
org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
model.title?.data?.cast?.isNotEmpty() == true && model.title?.data?.related?.isNotEmpty() == true
}
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertEquals("cast", model.title!!.row)
rule.onNodeWithText("Sam Rockwell").assertIsDisplayed()
val captures = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "walk-fixes").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(captures, "discover-title-cast.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.DirectionDown) }
assertEquals("related", model.title!!.row)
rule.onNodeWithText("More like this").assertIsDisplayed()
rule.onRoot().captureRoboImage(File(captures, "discover-title-related.png").absolutePath)
rule.onRoot().performKeyInput { pressKey(Key.Back) }
rule.waitForIdle()
assertNull(discover.title)
assertEquals("discover", shell.route)
} finally {
release.countDown()
model.viewModelScope.cancel()
}
}
}
}
}

View file

@ -11,6 +11,7 @@ import mockwebserver3.RecordedRequest
internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef" internal const val SHARED_TOKEN = "0123456789abcdef0123456789abcdef"
internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210" internal const val MINDEL_TOKEN = "fedcba9876543210fedcba9876543210"
internal const val JUSTIN_TOKEN = "11111111111111111111111111111111"
/** A loopback server supplies wire JSON, never app projections or live service state. */ /** A loopback server supplies wire JSON, never app projections or live service state. */
internal class FakeConductarr : Closeable { internal class FakeConductarr : Closeable {
@ -26,6 +27,10 @@ internal class FakeConductarr : Closeable {
@Volatile var emptyAudiences = false @Volatile var emptyAudiences = false
@Volatile var seasonTwoScore = 8.1 @Volatile var seasonTwoScore = 8.1
@Volatile var localArt = false @Volatile var localArt = false
@Volatile var beforeResponse: ((String) -> Unit)? = null
@Volatile var audiencesBody: String? = null
@Volatile var feedBody: String? = null
@Volatile var activityBody: String? = null
private val imageBytes = java.util.Base64.getDecoder().decode( private val imageBytes = java.util.Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=") "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jYioAAAAASUVORK5CYII=")
@ -37,6 +42,7 @@ internal class FakeConductarr : Closeable {
if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder() if (localArt && request.url.encodedPath == "/image") return MockResponse.Builder()
.body(okio.Buffer().write(imageBytes)).build() .body(okio.Buffer().write(imageBytes)).build()
val path = request.url.encodedPath val path = request.url.encodedPath
beforeResponse?.invoke(path)
if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus) if (request.method == "POST" && path == "/api/v1/requests") return response(wire.getValue("receipt").toString(), postStatus)
if (request.method == "POST") return response(wire.getValue("hide").toString()) if (request.method == "POST") return response(wire.getValue("hide").toString())
if (request.method == "DELETE") return response(wire.getValue("restore").toString()) if (request.method == "DELETE") return response(wire.getValue("restore").toString())
@ -64,6 +70,9 @@ internal class FakeConductarr : Closeable {
} }
if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus)
if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus)
if (key == "audiences") audiencesBody?.let { return response(it) }
if (key == "feed") feedBody?.let { return response(it) }
if (key == "activity") activityBody?.let { return response(it) }
if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (notModified && request.headers["If-None-Match"] != null) return response("", 304)
if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") if (key == "requests" && !existingRequests) return response("{\"requests\":[]}")
var body = wire.getValue(key).jsonObject var body = wire.getValue(key).jsonObject
@ -71,6 +80,9 @@ internal class FakeConductarr : Closeable {
if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) {
body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel")))
} }
if (key == "operator" && request.headers["Authorization"]?.contains(JUSTIN_TOKEN) == true) {
body = JsonObject(body + mapOf("name" to JsonPrimitive("Justin"), "requester" to JsonPrimitive("justin"), "subject" to JsonPrimitive("jellyfin:justin"), "watch_user" to JsonPrimitive("justin")))
}
if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") { if (key in setOf("options", "discover") && request.url.queryParameter("media_type") == "tv") {
body = JsonObject(body + ("media_type" to JsonPrimitive("tv"))) body = JsonObject(body + ("media_type" to JsonPrimitive("tv")))
if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark"))))))) if (key == "discover") body = JsonObject(body + ("items" to JsonArray(listOf(JsonObject(body.getValue("items").jsonArray.first().jsonObject + mapOf("media_type" to JsonPrimitive("tv"), "tmdb_id" to JsonPrimitive(44), "title" to JsonPrimitive("Dark")))))))

View file

@ -0,0 +1,152 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.successQueryResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.UUID
import kotlinx.coroutines.cancel
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
import org.jellyfin.sdk.api.operations.ItemsApi
import org.jellyfin.sdk.api.operations.UserViewsApi
import org.jellyfin.sdk.model.api.*
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
/** Fresh login exercises the actual NavDisplay and renders user-prefixed Jellyfin libraries. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class FirstSignInNavigationTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var previousUrl: String
private var previousFixtures = false
private var previousScreens = true
@Before fun captureGlobalPreferences() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousUrl = ConductarrPreference.baseUrl
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
rule.mainClock.autoAdvance = true
}
@After fun restoreGlobalPreferences() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
rule.mainClock.autoAdvance = true
}
@Test fun firstContinueThenMoviesRendersMindelsGridWithoutReenteringThePicker() {
ConductarrPreference.updateFixtures(false)
WolfskinScreenPreference.enabled = true
FakeConductarr().use { fake ->
fake.audiencesBody = """{"audiences":[{"is_anime":false,"libraries":["Mindel Movies"],"media_type":"movie","owner":"mindel","users":["mindel"]},{"is_anime":false,"libraries":["Mindel Shows"],"media_type":"tv","owner":"mindel","users":["mindel"]}]}"""
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "mindel", serverId = server.id, accessToken = MINDEL_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, listOf(user))
val api = mockk<ApiClient>()
val views = mockk<UserViewsApi>()
val items = mockk<ItemsApi>()
every { api.userViewsApi } returns views
every { api.itemsApi } returns items
val movies = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Movies", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.MOVIES)
val shows = BaseItemDto(id = UUID.randomUUID(), name = "Mindel Shows", type = BaseItemKind.COLLECTION_FOLDER, collectionType = CollectionType.TVSHOWS)
coEvery { views.getUserViews(userId = user.id) } returns successQueryResult(listOf(movies, shows))
for ((library, kind, count) in listOf(Triple(movies, BaseItemKind.MOVIE, 6), Triple(shows, BaseItemKind.SERIES, 29))) {
coEvery { items.getItems(userId = user.id, parentId = library.id, recursive = true,
includeItemTypes = listOf(kind), fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.SORT_NAME, ItemFields.DATE_CREATED),
sortBy = listOf(ItemSortBy.SORT_NAME), startIndex = 0, limit = 200) } returns successQueryResult(
List(count) { BaseItemDto(id = UUID.randomUUID(), name = "${library.name} title $it", type = kind) })
}
val navigation = NavigationManager()
val session = WolfskinSession()
val model = WolfskinShellViewModel(rule.activity, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
LibraryReadFacade(api), WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val home = HomeSnapshot(listOf(HomeRow("resume", HomeRowKind.Hero, cards = listOf(
HomeTitle("episode", "Me and My Town", "episode", seriesTitle = "Desperate Housewives", season = 5, episode = 9)), heroKind = "resume")))
try {
rule.setContent {
WolfskinApp(server, user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null), Modifier, model,
homeContent = { shell, _, _, modifier -> HomeScreen(shell, home, modifier) {} })
}
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
conductarr.identity.value?.signedIn == true && model.libraries.audiences != null
}
rule.onNodeWithText("Mindel").assertIsDisplayed()
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
assertFalse("Continue must close the session's real picker, not a discarded launch shell", session.state!!.pickerVisible)
rule.onNodeWithTag("wolfskin-shell").performKeyInput {
pressKey(Key.DirectionUp)
pressKey(Key.DirectionRight)
pressKey(Key.DirectionRight)
pressKey(Key.DirectionCenter)
}
rule.waitForIdle()
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodesWithTag("library-grid").fetchSemanticsNodes().isNotEmpty()
}
rule.onNodeWithTag("library-grid").assertIsDisplayed()
rule.onAllNodesWithText("Mindel Movies title 0")[0].assertIsDisplayed()
rule.onNodeWithText("Desperate Housewives").assertDoesNotExist()
assertEquals(6, session.state!!.view.library!!.source.size)
assertFalse(session.state!!.person.hasAnime)
rule.onNodeWithText("Shows").performClick()
rule.waitForIdle()
try {
rule.waitUntil(timeoutMillis = 15_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
rule.onAllNodes(hasTestTag("library-grid") and
hasAnyDescendant(hasText("Mindel Shows title 0"))).fetchSemanticsNodes().isNotEmpty()
}
} catch (timeout: ComposeTimeoutException) {
val shell = session.state!!
throw AssertionError("Active=${navigation.backStack.last()}, shell=${shell.route}, " +
"movies=${shell.rememberedView("movies").library?.source?.size}, " +
"shows=${shell.rememberedView("shows").library?.source?.size}/" +
"${shell.rememberedView("shows").library?.status}", timeout)
}
rule.onNodeWithTag("library-grid").assertIsDisplayed()
assertEquals(29, session.state!!.view.library!!.source.size)
rule.onAllNodesWithText("Mindel Shows title 0")[0].assertIsDisplayed()
rule.onNodeWithText("Home").performClick()
rule.waitForIdle()
rule.onNodeWithText("Desperate Housewives").assertIsDisplayed()
assertFalse("Returning Home must not reopen the picker", session.state!!.pickerVisible)
} finally { model.viewModelScope.cancel() }
}
}
}
}

View file

@ -0,0 +1,54 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Scrolled cards must be occluded across the entire persistent navigation band. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class NavBandOcclusionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun scrolledRowsNeverShowThroughTheNavBand() {
val shell = ShellState(listOf(ShellPerson("shared", "Shared", "S", false)), "shared").apply {
pickerVisible = false; pickerLaunch = false; enterDestination("discover")
}
rule.setContent { WolfskinTheme { ShellSurface(shell) { modifier ->
LazyColumn(state = shell.view.listState, modifier = modifier.fillMaxSize()) {
items(20) { Box(Modifier.fillMaxSize().height(200.dp).background(Color.Red)) }
}
} } }
rule.runOnIdle { runBlocking { shell.view.listState.scrollToItem(2) } }
rule.waitForIdle()
val pixels = rule.onRoot().captureToImage().toPixelMap()
val background = WolfskinTokens.Color.bg
for (y in listOf(5, 50, 135)) {
val actual = pixels[20, y]
assertEquals("Nav red at y=$y", background.red, actual.red, 1f / 255f)
assertEquals("Nav green at y=$y", background.green, actual.green, 1f / 255f)
assertEquals("Nav blue at y=$y", background.blue, actual.blue, 1f / 255f)
}
}
}

View file

@ -0,0 +1,157 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.runtime.*
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import io.mockk.*
import java.util.UUID
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config
/** A selected identity gets a new app ViewModel without opening a second launch picker. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class PersonPickerSessionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var previousUrl: String
private var previousFixtures = false
private var previousScreens = true
private val models = mutableListOf<WolfskinShellViewModel>()
private lateinit var session: WolfskinSession
@Before fun capturePreferences() {
ConductarrPreference.initialize(rule.activity)
WolfskinScreenPreference.initialize(rule.activity)
previousUrl = ConductarrPreference.baseUrl
previousFixtures = ConductarrPreference.fixtures
previousScreens = WolfskinScreenPreference.enabled
ConductarrPreference.updateFixtures(false)
WolfskinScreenPreference.enabled = true
}
@After fun restorePreferences() {
models.forEach { it.viewModelScope.cancel() }
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
WolfskinScreenPreference.enabled = previousScreens
}
private fun select(initial: Int, selected: Int, fromAvatar: Boolean = false, fast: Boolean = false, moves: List<Key> = emptyList()) {
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val users = listOf("Justin" to JUSTIN_TOKEN, "Mindel" to MINDEL_TOKEN, "Shared" to SHARED_TOKEN).map { (name, token) ->
JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = name, accessToken = token)
}
var current by mutableStateOf(CurrentUser(server, users[initial]))
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
val setup = mockk<SetupNavigationManager>(relaxed = true)
every { setup.navigateTo(any()) } answers { current = (firstArg() as SetupDestination.AppContent).current }
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
coEvery { libraries.hasAnimeTitles(any()) } returns false
session = WolfskinSession()
try {
rule.setContent {
key(current.user.id) {
val navigation = remember { NavigationManager() }
val model = remember {
WolfskinShellViewModel(rule.activity, session, repository, navigation, setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true)).also { models.add(it) }
}
DisposableEffect(model) { onDispose { model.viewModelScope.cancel() } }
WolfskinApp(server, current.user, navigation, UserPreferences(AppPreferences.getDefaultInstance(), null),
viewModel = model, homeContent = { _, _, user, modifier ->
androidx.tv.material3.Text("Home for ${user.name}", modifier)
})
}
}
awaitLoaded(users[initial])
if (fromAvatar) {
press(listOf(Key.DirectionCenter))
press(listOf(Key.DirectionUp, Key.DirectionLeft, Key.DirectionCenter))
}
val actions = mutableListOf<ShellAction.Person>()
rule.runOnIdle {
val dispatch = session.onAction
session.onAction = { action ->
if (action is ShellAction.Person) actions.add(action)
dispatch(action)
}
}
if (fast) press(moves + Key.DirectionCenter) else {
press(moves)
press(listOf(Key.DirectionCenter))
}
awaitLoaded(users[selected])
rule.runOnIdle {
assertFalse("The replacement app must not reopen the picker after one OK", session.state!!.pickerVisible)
assertEquals("home", session.state!!.route)
assertEquals(users[selected].id.toString(), session.state!!.person.jellyfinUserId)
assertEquals(listOf(users[selected].id.toString()), actions.map { it.person.jellyfinUserId })
assertEquals(if (initial == selected) 1 else 2, models.size)
}
rule.onNodeWithText("Home for ${users[selected].name}").assertIsDisplayed()
} finally { models.forEach { it.viewModelScope.cancel() } }
}
}
}
private fun awaitLoaded(user: JellyfinUser) {
try {
rule.waitUntil(10_000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
models.lastOrNull()?.signedInPerson == user.name!!.lowercase() &&
models.last().conductarr.identity.value?.signedIn == true &&
models.last().conductarr.identity.value?.person?.endsWith(":${user.id}") == true &&
session.state?.people?.size == 3 &&
models.last().viewModelScope.coroutineContext[Job]!!.children.none { it.isActive }
}
} catch (timeout: ComposeTimeoutException) {
throw AssertionError("Expected ${user.name}; models=${models.size}, signedIn=${models.last().signedInPerson}, " +
"identity=${models.last().conductarr.identity.value?.person}, picker=${session.state?.pickerVisible}, " +
"person=${session.state?.person}, people=${session.state?.people?.size}", timeout)
}
rule.waitForIdle()
}
private fun press(keys: List<Key>) {
rule.onNodeWithTag("wolfskin-shell").performKeyInput { keys.forEach { pressKey(it) } }
}
@Test fun launchRightThenOneOkKeepsPickerClosed() = select(0, 1, moves = listOf(Key.DirectionRight))
@Test fun launchLeftThenOneOkKeepsPickerClosed() = select(1, 0, moves = listOf(Key.DirectionLeft))
@Test fun avatarRightThenOneOkKeepsPickerClosed() = select(0, 1, fromAvatar = true, moves = listOf(Key.DirectionRight))
@Test fun avatarLeftThenOneOkKeepsPickerClosed() = select(1, 0, fromAvatar = true, moves = listOf(Key.DirectionLeft))
@Test fun initialPersonNeedsOnlyOneOk() = select(0, 0)
@Test fun gearAndBackThenOneOkKeepsPickerClosed() = select(0, 2, moves = listOf(Key.DirectionRight, Key.DirectionRight, Key.DirectionDown, Key.DirectionUp))
@Test fun fastLeftLeftOkKeepsPickerClosed() = select(2, 0, fast = true, moves = listOf(Key.DirectionLeft, Key.DirectionLeft))
}

View file

@ -0,0 +1,252 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import android.os.Looper
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.*
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import io.mockk.*
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.design.ShellKey
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Picker changes invalidate identity immediately without closing sockets on the UI thread. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class PersonSwitchThreadingTest {
private val context = ApplicationProvider.getApplicationContext<Application>()
private lateinit var previousUrl: String
private var previousFixtures = false
@Before fun setUp() {
Dispatchers.setMain(Dispatchers.Unconfined)
ConductarrPreference.initialize(context)
previousUrl = ConductarrPreference.baseUrl
previousFixtures = ConductarrPreference.fixtures
ConductarrPreference.updateFixtures(false)
}
@After fun tearDown() {
ConductarrPreference.updateBaseUrl(previousUrl)
ConductarrPreference.updateFixtures(previousFixtures)
Dispatchers.resetMain()
unmockkConstructor(ConductarrClient::class)
}
@Test fun pickerSwitchesKeepPersonDataAndDisposeEveryClientOffMainWithoutWaiting() = runBlocking {
assertSame(Looper.getMainLooper(), Looper.myLooper())
val closeThreads = CopyOnWriteArrayList<Boolean>()
val releaseCloses = CountDownLatch(1)
val closed = CountDownLatch(4)
val disposalErrors = CopyOnWriteArrayList<Throwable>()
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, error -> disposalErrors.add(error) })
val failFirstClose = java.util.concurrent.atomic.AtomicBoolean(true)
mockkConstructor(ConductarrClient::class)
every { anyConstructed<ConductarrClient>().close() } answers {
val onMain = Looper.myLooper() == Looper.getMainLooper()
closeThreads.add(onMain)
check(!onMain) { "StrictMode: Conductarr socket close on main" }
check(releaseCloses.await(10, TimeUnit.SECONDS)) { "Picker waited for socket disposal" }
callOriginal()
closed.countDown()
if (failFirstClose.getAndSet(false)) throw java.io.IOException("Socket disposal fixture failure")
}
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
coEvery { repository.changeUser(server, any()) } coAnswers { CurrentUser(server, secondArg()) }
val setup = mockk<SetupNavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val clients = mutableListOf<ConductarrClient>()
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()
clients.add(conductarr.identity.value!!.client)
val shell = session.state!!
shell.key(ShellKey.Ok)
for (index in listOf(1, 2, 0)) {
val switched = CompletableDeferred<CurrentUser>()
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(index)
shell.key(ShellKey.Ok)
assertEquals(users[index].id.toString(), shell.person.jellyfinUserId)
assertEquals("home", shell.route)
assertFalse(shell.pickerVisible)
assertNull("Old identity must not serve the newly selected person", conductarr.identity.value)
val current = withTimeout(5_000) { switched.await() }
assertEquals(users[index].id, current.user.id)
model.load(current.server, current.user)
requireNotNull(model.onForeground(server, current.user)).join()
assertEquals(users[index].name!!.lowercase(), conductarr.identity.value!!.operator!!.requester)
assertEquals(users[index].name!!.lowercase(), model.signedInPerson)
clients.add(conductarr.identity.value!!.client)
}
conductarr.close()
conductarr.close()
model.viewModelScope.cancel()
releaseCloses.countDown()
assertTrue("Every retired client must finish disposal", withContext(Dispatchers.IO) { closed.await(5, TimeUnit.SECONDS) })
assertEquals(listOf(false, false, false, false), closeThreads.toList())
for (client in clients) {
val result = client.read(TvRoutes.operator(), ReadTrigger.FORCE).last()
assertEquals("Retired clients cannot start another network read", ClientError.Unreachable, (result as ReadResult.Failure).error)
}
disposalScope.coroutineContext.job.children.toList().joinAll()
assertTrue("A failing socket close must be contained", disposalErrors.isEmpty())
} finally {
releaseCloses.countDown()
model.viewModelScope.cancel()
withContext(Dispatchers.IO) { conductarr.close() }
disposalScope.coroutineContext.job.children.toList().joinAll()
disposalScope.cancel()
}
}
}
@Test fun rapidPickerChoicesIgnoreLateSignInAndCanReturnToTheOriginalPerson() = runBlocking {
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val justin = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
val users = listOf(justin, justin.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN),
justin.copy(id = UUID.randomUUID(), name = "Shared", accessToken = SHARED_TOKEN))
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns JellyfinServerUsers(server, users)
val pending = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
coEvery { repository.changeUser(server, any()) } coAnswers {
val user = secondArg<JellyfinUser>()
if (user.id == users[1].id) {
pending.complete(Unit)
withContext(NonCancellable) { release.await() }
}
CurrentUser(server, user)
}
val switched = CompletableDeferred<CurrentUser>()
val setup = mockk<SetupNavigationManager>(relaxed = true)
every { setup.navigateTo(any()) } answers { switched.complete((firstArg() as SetupDestination.AppContent).current) }
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, mockk(relaxed = true), setup, mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
try {
model.load(server, justin)
requireNotNull(model.onForeground(server, justin)).join()
val shell = session.state!!
shell.key(ShellKey.Ok)
for (index in listOf(1, 2, 0)) {
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(index)
shell.key(ShellKey.Ok)
if (index == 1) withTimeout(5_000) { pending.await() }
}
release.complete(Unit)
val current = withTimeout(5_000) { switched.await() }
assertEquals(justin.id, current.user.id)
verify(exactly = 1) { setup.navigateTo(any()) }
model.load(server, current.user)
requireNotNull(model.onForeground(server, current.user)).join()
assertEquals(justin.id.toString(), shell.person.jellyfinUserId)
assertEquals("justin", model.signedInPerson)
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
} finally {
release.complete(Unit)
model.viewModelScope.cancel()
}
}
}
}
@Test fun homeAndTitleRetriesReplaceSignedOutClientsWithoutMainThreadDisposal() = runBlocking {
val closeThreads = CopyOnWriteArrayList<Boolean>()
val closed = CountDownLatch(3)
val disposalScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
mockkConstructor(ConductarrClient::class)
every { anyConstructed<ConductarrClient>().close() } answers {
val onMain = Looper.myLooper() == Looper.getMainLooper()
closeThreads.add(onMain)
check(!onMain) { "StrictMode: retry socket close on main" }
callOriginal()
closed.countDown()
}
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
val conductarr = ConductarrSession(MemoryCacheStore(), disposalScope)
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), serverId = server.id, name = "Justin", accessToken = JUSTIN_TOKEN)
val repository = mockk<ServerRepository>()
every { repository.currentUser } returns user
every { repository.currentServer } returns server
val home = mockk<org.justinvisser.wolfskin.tv.read.HomeReadFacade>()
coEvery { home.read(user.id) } returns org.justinvisser.wolfskin.design.HomeLibrary(resume = listOf(
org.justinvisser.wolfskin.design.HomeTitle("justin-film", "Justin film", "movie", held = true)))
val cache = mockk<org.justinvisser.wolfskin.tv.read.HomeLibraryCache>(relaxed = true)
every { cache.read(any()) } returns null
val homeModel = WolfskinHomeViewModel(context, home, cache, mockk(relaxed = true), WolfskinSeeAllLists(), conductarr)
val titleModel = WolfskinTitleViewModel(context, mockk(), mockk(), mockk(relaxed = true), conductarr, repository)
try {
conductarr.select("${server.id}:${user.id}", user.accessToken, fake.url)
conductarr.authenticate()
fake.status = 401
conductarr.authenticate()
assertFalse(conductarr.identity.value!!.signedIn)
fake.status = 200
homeModel.load(server, user, org.justinvisser.wolfskin.design.ShellPerson("justin", "Justin", "J", false, user.id.toString()), retry = true)
withTimeout(5_000) { conductarr.identity.first { it?.signedIn == true } }
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
assertTrue(homeModel.snapshot.rows.any { row -> row.cards.any { it.id == "justin-film" } })
fake.status = 401
conductarr.authenticate()
assertFalse(conductarr.identity.value!!.signedIn)
fake.status = 200
titleModel.retrySignIn()
assertEquals("justin", conductarr.identity.value!!.operator!!.requester)
assertTrue(conductarr.identity.value!!.signedIn)
} finally {
homeModel.viewModelScope.cancel()
titleModel.viewModelScope.cancel()
conductarr.close()
disposalScope.coroutineContext.job.children.toList().joinAll()
disposalScope.cancel()
}
assertEquals(0L, closed.count)
assertEquals(listOf(false, false, false), closeThreads.toList())
}
}
}

View file

@ -0,0 +1,164 @@
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.lifecycle.viewModelScope
import androidx.test.core.app.ApplicationProvider
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.services.NavigationManager
import com.github.damontecres.wholphin.ui.nav.Destination
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.verify
import java.util.UUID
import kotlinx.coroutines.*
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.conductarr.MemoryCacheStore
import org.justinvisser.wolfskin.design.*
import org.justinvisser.wolfskin.tv.read.LibraryReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Saved-login I/O cannot leave an interactive but disconnected launch shell. */
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class)
class ShellStartupTimingTest {
@Before fun setMain() { Dispatchers.setMain(Dispatchers.Unconfined) }
@After fun resetMain() { Dispatchers.resetMain() }
@Test fun firstFramePickerAndEveryTabWorkBeforeSavedPeopleArrive() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previous = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
conductarr.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url)
conductarr.authenticate()
val activityEntered = CompletableDeferred<Unit>()
val releaseActivity = java.util.concurrent.CountDownLatch(1)
fake.beforeResponse = {
if (it == "/api/v1/activity") {
activityEntered.complete(Unit)
releaseActivity.await()
}
}
val activity = async(Dispatchers.IO) { conductarr.identity.value!!.provider.requests().last() }
withTimeout(5_000) { activityEntered.await() }
val savedPeople = CompletableDeferred<Unit>()
val savedPeopleStarted = CompletableDeferred<Unit>()
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } coAnswers { savedPeopleStarted.complete(Unit); savedPeople.await(); null }
val navigation = mockk<NavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(user.id) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val load = launch { model.load(server, user) }
try {
withTimeout(5_000) { savedPeopleStarted.await() }
val shell = requireNotNull(session.state) { "The current Jellyfin person must paint before saved-login I/O" }
assertEquals(PeopleStatus.Ready, shell.peopleStatus)
assertEquals(user.id.toString(), shell.person.jellyfinUserId)
shell.key(ShellKey.Ok)
for (route in listOf("movies", "shows", "discover")) {
shell.focusNav(shell.tabs.indexOf(route))
shell.key(ShellKey.Ok)
verify { navigation.replace(Destination.Wolfskin(route)) }
assertEquals(route, shell.route)
}
savedPeople.complete(Unit)
load.join()
assertSame(shell, session.state)
assertEquals("discover", shell.route)
assertFalse("Activity must still be pending while the picker and tabs are usable", activity.isCompleted)
} finally {
releaseActivity.countDown()
activity.cancelAndJoin()
savedPeople.complete(Unit)
load.cancelAndJoin()
model.viewModelScope.cancel()
}
}
}
ConductarrPreference.updateBaseUrl(previous)
}
@Test fun switchingPersonDuringHouseholdReadKeepsTheNewIdentityAndReturnsHome() = runBlocking {
val context = ApplicationProvider.getApplicationContext<Application>()
ConductarrPreference.initialize(context)
ConductarrPreference.updateFixtures(false)
val previous = ConductarrPreference.baseUrl
FakeConductarr().use { fake ->
ConductarrPreference.updateBaseUrl(fake.url)
ConductarrSession(MemoryCacheStore()).use { conductarr ->
val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null)
val shared = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN)
val mindel = shared.copy(id = UUID.randomUUID(), name = "Mindel", accessToken = MINDEL_TOKEN)
val repository = mockk<ServerRepository>(relaxed = true)
coEvery { repository.serverDao.getServer(server.id) } returns
com.github.damontecres.wholphin.data.model.JellyfinServerUsers(server, listOf(shared, mindel))
val switched = CompletableDeferred<Unit>()
coEvery { repository.changeUser(server, mindel) } coAnswers {
switched.complete(Unit)
com.github.damontecres.wholphin.data.CurrentUser(server, mindel)
}
val navigation = mockk<NavigationManager>(relaxed = true)
val libraries = mockk<LibraryReadFacade>(relaxed = true)
coEvery { libraries.readDestinations(any()) } returns emptyMap()
val session = WolfskinSession()
val model = WolfskinShellViewModel(context, session, repository, navigation, mockk(relaxed = true), mockk(relaxed = true),
libraries, WolfskinSeeAllLists(), conductarr, mockk(relaxed = true))
val pending = CompletableDeferred<Unit>()
val release = java.util.concurrent.CountDownLatch(1)
val firstAudience = java.util.concurrent.atomic.AtomicBoolean(true)
fake.beforeResponse = {
if (it == "/curation/library-audiences" && firstAudience.getAndSet(false)) {
pending.complete(Unit)
release.await()
}
}
try {
model.load(server, shared)
withTimeout(5_000) { pending.await() }
val shell = requireNotNull(session.state)
shell.key(ShellKey.Ok)
shell.enterDestination("discover")
shell.focusNav(shell.tabs.size)
shell.key(ShellKey.Ok)
shell.focusPerson(1)
shell.key(ShellKey.Ok)
assertEquals("home", shell.route)
verify { navigation.replace(Destination.Home()) }
withTimeout(5_000) { switched.await() }
model.load(server, mindel)
requireNotNull(model.onForeground(server, mindel)).join()
release.countDown()
assertSame(shell, session.state)
assertEquals("mindel", model.signedInPerson)
assertEquals(mindel.id.toString(), shell.person.jellyfinUserId)
assertEquals("Mindel", shell.person.name)
assertEquals("mindel", conductarr.identity.value!!.operator!!.requester)
assertEquals("home", shell.route)
} finally {
release.countDown()
model.viewModelScope.cancel()
}
}
}
ConductarrPreference.updateBaseUrl(previous)
}
}

View file

@ -1,6 +1,7 @@
package org.justinvisser.wolfskin.tv package org.justinvisser.wolfskin.tv
import android.app.Application import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
@ -35,6 +36,7 @@ import org.justinvisser.wolfskin.design.TitleVersion
import org.justinvisser.wolfskin.design.WolfskinTheme import org.justinvisser.wolfskin.design.WolfskinTheme
import org.justinvisser.wolfskin.tv.read.TitleReadFacade import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.util.UUID import java.util.UUID
@ -152,7 +154,10 @@ class TitleAsyncFocusTest {
assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id) assertEquals("retry", viewModel.title!!.options[viewModel.title!!.column].id)
rule.runOnIdle { viewModel.title!!.toast = null } rule.runOnIdle { viewModel.title!!.toast = null }
press(Key.DirectionCenter) press(Key.DirectionCenter)
rule.waitUntil(timeoutMillis = 5000) { reads.get() == 2 && viewModel.title?.toast != null } rule.waitUntil(timeoutMillis = 5000) {
Shadows.shadowOf(Looper.getMainLooper()).idle()
reads.get() == 2 && viewModel.title?.toast != null
}
rule.waitForIdle() rule.waitForIdle()
assertEquals("Saved title", viewModel.title!!.data.item.name) assertEquals("Saved title", viewModel.title!!.data.item.name)
assertEquals("20:42", shell.navSavedAt) assertEquals("20:42", shell.navSavedAt)

View file

@ -87,7 +87,7 @@ class TitleCaptureTest {
data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } }
data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } }
data.episodes.forEach { item -> data.episodes.forEach { item ->
(if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) } (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) }
} }
} }
return requests.distinctBy { it.first }.associate { (path, width, height) -> return requests.distinctBy { it.first }.associate { (path, width, height) ->
@ -215,4 +215,5 @@ class TitleCaptureTest {
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor") @Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
@Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel")
@Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man")
@Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) }
} }

View file

@ -81,6 +81,7 @@ class TitleReadFacadeTest {
every { repository.currentServer } returns null every { repository.currentServer } returns null
every { repository.currentUser } returns null every { repository.currentUser } returns null
coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null coEvery { playback.getSelectedTracks(any(), any(), any()) } returns null
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(emptyList())
val context = mockk<Context> { every { filesDir } returns directory.root } val context = mockk<Context> { every { filesDir } returns directory.root }
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context) facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
} }
@ -103,6 +104,29 @@ class TitleReadFacadeTest {
mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams))) mediaSources = listOf(source.copy(id = sourceId, name = "Original", container = "mkv", mediaStreams = streams)))
} }
@Test fun heldMovieResolvesCatalogueIdentityWhenTheDetailOmitsProviderIds() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { items.getItems(any<GetItemsRequest>()) } returns successQueryResult(
listOf(file(firstId).copy(providerIds = mapOf("Tmdb" to "42"))))
val page = facade.read(firstId, null, preferences)
assertEquals(42L, page.item.tmdbId)
assertEquals(firstId.toString(), page.watchTarget.id)
assertEquals(firstId.toString(), TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}.target?.id)
FakeConductarr().use { fake ->
ConductarrSession(org.justinvisser.wolfskin.conductarr.MemoryCacheStore()).use { session ->
session.select("server:shared", SHARED_TOKEN, fake.url)
session.authenticate()
val screen = TitleState(page, ShellPerson("shared", "Shared", "S", false)) {}
ConductarrTitlePages(session.identity.value!!).title(requireNotNull(page.item.tmdbId),
org.justinvisser.wolfskin.conductarr.MediaType.MOVIE, page).collect { screen.update(it) }
assertEquals(listOf("header", "cast", "related"), screen.rows)
assertEquals("Sam Rockwell", screen.data.cast.single().name)
assertEquals(firstId.toString(), screen.target?.id)
assertTrue(screen.data.item.held)
}
}
}
@Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest { @Test fun diskCopyPreservesFileAndNonContiguousStreamIndexesWithoutRatingsBeforeSignIn() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
facade.read(firstId, null, preferences) facade.read(firstId, null, preferences)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -16,6 +17,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import java.io.File import java.io.File
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import org.justinvisser.wolfskin.conductarr.CacheEntry
import org.justinvisser.wolfskin.conductarr.CacheStore
import org.justinvisser.wolfskin.conductarr.FileCacheStore import org.justinvisser.wolfskin.conductarr.FileCacheStore
/** Advanced-only endpoint and fixture controls contain no credentials. */ /** Advanced-only endpoint and fixture controls contain no credentials. */
@ -50,6 +54,15 @@ object ConductarrPreference {
object ConductarrModule { object ConductarrModule {
@Provides @Provides
@Singleton @Singleton
fun session(@ApplicationContext context: Context): ConductarrSession = fun session(@ApplicationContext context: Context, @IoCoroutineScope scope: CoroutineScope): ConductarrSession =
ConductarrSession(FileCacheStore(File(context.noBackupFilesDir, "conductarr"))) ConductarrSession(ConductarrDiskCache(context), scope)
}
/** Android may create noBackupFilesDir, so resolve it only during an IO cache operation. */
private class ConductarrDiskCache(context: Context) : CacheStore {
private val store by lazy { FileCacheStore(File(context.noBackupFilesDir, "conductarr")) }
override fun read(scope: String, key: String) = store.read(scope, key)
override fun write(scope: String, key: String, entry: CacheEntry, capacity: Int?) = store.write(scope, key, entry, capacity)
override fun remove(scope: String, key: String) = store.remove(scope, key)
override fun clear(scope: String) = store.clear(scope)
} }

View file

@ -41,20 +41,62 @@ internal fun DiscoverTitle.homeTitle(): HomeTitle = HomeTitle(
overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(), overview = overview, poster = poster, backdrop = backdrop, genres = genres, tmdbId = tmdb.toInt(),
) )
/** Activity is history; stage evidence must not turn a past dispatch into a live transfer. */
internal fun RequestProgress.comingIn(): ComingInRequest { internal fun RequestProgress.comingIn(): ComingInRequest {
val stagesByName = stages.orEmpty().associateBy { it.name } val stagesByName = stages.orEmpty().associateBy { it.name }
val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION] val acquisition = stagesByName[ReadinessStage.Name.ACQUISITION]
val visibility = stagesByName[ReadinessStage.Name.VISIBILITY] val visibility = stagesByName[ReadinessStage.Name.VISIBILITY]
val playback = stagesByName[ReadinessStage.Name.PLAYBACK]
val stage = when { val stage = when {
stages.orEmpty().any { it.state == ReadinessStage.State.FAILED } -> ComingInStage.Failed acquisition?.summary == "The title was deleted after it was dispatched." -> ComingInStage.Removed
acquisition?.summary == "The title is not released yet; there is nothing to acquire." -> ComingInStage.NotOutYet
playback?.state == ReadinessStage.State.FAILED || playback?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.FileHealth
visibility?.summary?.startsWith("The title is only in libraries this viewer does not see: ") == true -> ComingInStage.HeldElsewhere
visibility?.summary == "The title is not in a library this viewer sees." -> ComingInStage.NotInLibrary
visibility?.state == ReadinessStage.State.FAILED || visibility?.state == ReadinessStage.State.CONFLICTING -> ComingInStage.CheckNeeded
stagesByName[ReadinessStage.Name.REQUEST]?.state == ReadinessStage.State.FAILED || acquisition?.state == ReadinessStage.State.FAILED -> ComingInStage.Failed
visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready visibility?.state == ReadinessStage.State.DONE -> ComingInStage.Ready
awaitingApproval == true -> ComingInStage.Waiting awaitingApproval == true || acquisition?.summary == "Waiting for Seerr's approval to reach dispatch." -> ComingInStage.Waiting
visibility?.state in setOf(ReadinessStage.State.IN_PROGRESS, ReadinessStage.State.PARTIAL) || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady acquisition?.summary == "Dispatched; waiting for the Arr to import a file." -> ComingInStage.WaitingForFile
acquisition?.state == ReadinessStage.State.IN_PROGRESS -> ComingInStage.Downloading visibility?.state == ReadinessStage.State.IN_PROGRESS || visibility?.state == ReadinessStage.State.PARTIAL || acquisition?.state == ReadinessStage.State.DONE -> ComingInStage.AlmostReady
acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Searching acquisition?.state == ReadinessStage.State.IN_PROGRESS && acquisition.summary.startsWith("Downloading", ignoreCase = true) -> ComingInStage.Downloading
stages.orEmpty().any { it.state in setOf(ReadinessStage.State.UNKNOWN, ReadinessStage.State.STALE, ReadinessStage.State.CONFLICTING) } -> ComingInStage.Checking acquisition?.state == ReadinessStage.State.NONE && acquisition.summary.startsWith("Searching", ignoreCase = true) -> ComingInStage.Searching
else -> ComingInStage.Waiting acquisition?.state == ReadinessStage.State.IN_PROGRESS || acquisition?.state == ReadinessStage.State.NONE -> ComingInStage.Waiting
else -> ComingInStage.Checking
}
val detail = when (stage) {
ComingInStage.FileHealth -> playback
ComingInStage.CheckNeeded, ComingInStage.HeldElsewhere, ComingInStage.NotInLibrary, ComingInStage.Ready -> visibility
ComingInStage.Failed -> stages.orEmpty().firstOrNull { it.state == ReadinessStage.State.FAILED }
else -> acquisition
} }
return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key, return ComingInRequest(id, mediaType.name.lowercase(), tmdbId, title ?: "Title unavailable", requester.key,
requester.name, actor?.requester, actor?.name, stage) requester.name, actor?.requester, actor?.name, stage, summary = detail?.summary,
readiness = ReadinessStage.Name.entries.map { stagesByName[it].readinessWord() })
}
/** Expanded readiness describes each observed stage, not a synthetic download failure. */
private fun ReadinessStage?.readinessWord(): String = when {
this == null -> "Unknown"
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Needs attention"
name == ReadinessStage.Name.VISIBILITY && (state == ReadinessStage.State.FAILED || state == ReadinessStage.State.CONFLICTING) -> "Check needed"
name == ReadinessStage.Name.ACQUISITION && summary == "The title was deleted after it was dispatched." -> "Removed"
name == ReadinessStage.Name.ACQUISITION && summary == "The title is not released yet; there is nothing to acquire." -> "Not out yet"
name == ReadinessStage.Name.ACQUISITION && summary.startsWith("Dispatched; the Arr has represented files") -> "Files present"
name == ReadinessStage.Name.ACQUISITION && summary == "Dispatched; waiting for the Arr to import a file." -> "Waiting for a file"
name == ReadinessStage.Name.VISIBILITY && summary.startsWith("The title is only in libraries this viewer does not see: ") -> "Another library"
name == ReadinessStage.Name.VISIBILITY && summary == "The title is not in a library this viewer sees." -> "Not in library"
name == ReadinessStage.Name.ACQUISITION && state == ReadinessStage.State.IN_PROGRESS && summary.startsWith("Downloading", ignoreCase = true) -> "Downloading"
name == ReadinessStage.Name.PLAYBACK && (state == ReadinessStage.State.DONE || state == ReadinessStage.State.PARTIAL) -> "Recorded play"
name == ReadinessStage.Name.PLAYBACK && state == ReadinessStage.State.UNKNOWN -> "Not checked yet"
else -> when (state) {
ReadinessStage.State.DONE -> "Done"
ReadinessStage.State.FAILED -> "Failed"
ReadinessStage.State.IN_PROGRESS -> "In progress"
ReadinessStage.State.PARTIAL -> "Partial"
ReadinessStage.State.NONE -> "Not yet"
ReadinessStage.State.UNKNOWN -> "Unknown"
ReadinessStage.State.STALE -> "Last observed"
ReadinessStage.State.CONFLICTING -> "Check needed"
}
} }

View file

@ -57,10 +57,14 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider
val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it } val loader = catalogue?.takeIf { it.queries == queries } ?: ConductarrCatalogue(client, queries) { libraryAudiences }.also { catalogue = it }
val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE) val lanes = if (trigger == ReadTrigger.CACHE_ONLY) loader.saved() else loader.through(1, force = trigger == ReadTrigger.FORCE)
val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name } val genres = options.flatMap { it.genres.orEmpty() }.associate { it.id to it.name }
val picks = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).map { it.discoverTitle(genres) } val candidates = feed?.movies.orEmpty() + feed?.shows.orEmpty()
val seeds = (feed?.movies.orEmpty() + feed?.shows.orEmpty()).flatMap { candidate -> val picks = candidates.map { it.discoverTitle(genres) }
candidate.contributions.orEmpty().mapNotNull { seed -> seed.title?.let { it to candidate.discoverTitle(genres) } } val seeds = candidates.flatMapIndexed { index, candidate ->
}.groupBy({ it.first }, { it.second }).map { DiscoverSeed(it.key, it.value) } candidate.contributions.orEmpty().filter { !it.title.isNullOrBlank() }.map { it to picks[index] }
}.groupBy { (seed, _) -> "${seed.mediaType.name.lowercase()}:${seed.tmdbId}" }.map { (key, contributions) ->
DiscoverSeed(key, requireNotNull(contributions.first().first.title), contributions.map { it.second },
watched = contributions.any { it.first.watched }, added = contributions.any { it.first.added })
}
val hidden = hiddenJob.await()?.dismissals.orEmpty().map { val hidden = hiddenJob.await()?.dismissals.orEmpty().map {
DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath)) DiscoverTitle(it.tmdbId, it.mediaType.name.lowercase(), it.title, it.year.toInt(), poster = catalogueImage(it.posterPath))
} }

View file

@ -3,12 +3,17 @@ package org.justinvisser.wolfskin.tv
import java.io.Closeable import java.io.Closeable
import java.security.MessageDigest import java.security.MessageDigest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.justinvisser.wolfskin.conductarr.* import org.justinvisser.wolfskin.conductarr.*
import org.justinvisser.wolfskin.conductarr.model.Operator import org.justinvisser.wolfskin.conductarr.model.Operator
import timber.log.Timber
/** A selected Jellyfin login owns every provider and all in-memory observations. */ /** A selected Jellyfin login owns every provider and all in-memory observations. */
data class ConductarrIdentity( data class ConductarrIdentity(
@ -21,53 +26,85 @@ data class ConductarrIdentity(
) )
/** Tokens are borrowed from upstream, never persisted or exposed as observable state. */ /** Tokens are borrowed from upstream, never persisted or exposed as observable state. */
class ConductarrSession(private val cache: CacheStore) : Closeable { class ConductarrSession(
private val cache: CacheStore,
private val disposalScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) : Closeable {
private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null) private val mutableIdentity = MutableStateFlow<ConductarrIdentity?>(null)
val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow() val identity: StateFlow<ConductarrIdentity?> = mutableIdentity.asStateFlow()
private var selection: ByteArray? = null private var selection: ByteArray? = null
@Volatile private var generation = 0L @Volatile private var generation = 0L
@Synchronized
fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) { fun select(person: String, token: String?, baseUrl: String, retry: Boolean = false) {
val fingerprint = MessageDigest.getInstance("SHA-256") val fingerprint = MessageDigest.getInstance("SHA-256")
.digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray()) .digest("$person\n$baseUrl\n${token.orEmpty()}".toByteArray())
if (!retry && selection?.contentEquals(fingerprint) == true) return val previous: ConductarrClient?
generation++ val currentGeneration: Long
val currentGeneration = generation synchronized(this) {
mutableIdentity.value?.client?.close() if (!retry && selection?.contentEquals(fingerprint) == true) return
mutableIdentity.value = null generation++
selection = fingerprint currentGeneration = generation
if (token.isNullOrBlank()) return previous = mutableIdentity.value?.client
val client = try { mutableIdentity.value = null
ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event -> selection = fingerprint
if (event == ClientEvent.NEEDS_SIGN_IN && generation == currentGeneration) { }
mutableIdentity.update { it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) } try {
} val client = if (token.isNullOrBlank()) null else try {
}) ConductarrClient(baseUrl, token, PersonCacheStore(cache, person), timeouts = ClientTimeouts(3, 5, 8), onEvent = { event ->
} catch (_: IllegalArgumentException) { return } if (event == ClientEvent.NEEDS_SIGN_IN) {
mutableIdentity.value = ConductarrIdentity(person, client, ConductarrProvider(client)) mutableIdentity.update {
if (generation == currentGeneration) it?.copy(operator = null, signedIn = false, error = ClientError.NeedsSignIn) else it
}
}
})
} catch (_: IllegalArgumentException) { null }
synchronized(this) {
if (generation == currentGeneration) {
mutableIdentity.value = client?.let { ConductarrIdentity(person, it, ConductarrProvider(it)) }
} else dispose(client)
}
} finally {
dispose(previous)
}
} }
/** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */ /** Disk identity paints first; a 401 always removes it, even after an earlier saved emission. */
suspend fun authenticate() { suspend fun authenticate() {
val selected = mutableIdentity.value ?: return val selected = mutableIdentity.value ?: return
selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result -> selected.client.read(TvRoutes.operator(), ReadTrigger.LAUNCH).collect { result ->
if (mutableIdentity.value?.client !== selected.client) return@collect
when (result) { when (result) {
is ReadResult.Value -> mutableIdentity.update { it?.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) } is ReadResult.Value -> mutableIdentity.update {
if (it?.client === selected.client) it.copy(operator = result.saved.value, signedIn = result.saved.value != null, error = null) else it
}
is ReadResult.Failure -> mutableIdentity.update { is ReadResult.Failure -> mutableIdentity.update {
if (result.error == ClientError.NeedsSignIn) it?.copy(operator = null, signedIn = false, error = result.error) if (it?.client !== selected.client) it
else it?.copy(error = result.error) else if (result.error == ClientError.NeedsSignIn) it.copy(operator = null, signedIn = false, error = result.error)
else it.copy(error = result.error)
} }
} }
} }
} }
/** Remove read authority synchronously; socket disposal finishes independently on IO. */
@Synchronized
override fun close() { override fun close() {
generation++ generation++
mutableIdentity.value?.client?.close() val previous = mutableIdentity.value?.client
mutableIdentity.value = null mutableIdentity.value = null
selection = null selection = null
dispose(previous)
}
private fun dispose(client: ConductarrClient?) {
if (client == null) return
// The application scope outlives the screen replaced by a person switch.
disposalScope.launch(Dispatchers.IO) {
try {
client.close()
} catch (_: Exception) {
Timber.w("Conductarr client disposal failed")
}
}
} }
} }

View file

@ -95,18 +95,6 @@ internal fun rememberDiscoverState(
} }
} }
} }
LaunchedEffect(state.title?.key, state) {
val title = state.title ?: return@LaunchedEffect
if (!fixtures && identity?.signedIn == true) {
val media = if (title.type == "movie") MediaType.MOVIE else MediaType.TV
identity.provider.detail(title.tmdb, media).collect { result ->
val detail = when (result) { is ReadResult.Value -> result.saved.value; is ReadResult.Failure -> result.saved?.value }
if (detail != null && state.title?.key == title.key) state.updateTitle(title.copy(
runtimeSeconds = detail.runtime?.times(60)?.toInt(), genres = detail.genres.orEmpty(),
seasons = detail.seasons?.map { it.number.toInt() }, seasonCount = detail.seasons?.size, heldBy = identity.provider.holders(detail.title)))
}
}
}
val result = if (fixtures) fixtureState ?: state else state val result = if (fixtures) fixtureState ?: state else state
LaunchedEffect(shell.route, result) { LaunchedEffect(shell.route, result) {
result.enterRoute(shell.route) result.enterRoute(shell.route)

View file

@ -52,7 +52,9 @@ class WolfskinHomeViewModel @Inject constructor(
catalogueJob?.cancel() catalogueJob?.cancel()
withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return } withContext(Dispatchers.IO) { debugHomeSnapshot(context, person.key) }?.let { snapshot = it; return }
if (retry && conductarr.identity.value?.signedIn != true) { if (retry && conductarr.identity.value?.signedIn != true) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) withContext(Dispatchers.IO) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
}
} }
loadLibrary(server, user, person, retry) loadLibrary(server, user, person, retry)
} }

View file

@ -28,6 +28,6 @@ internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: ()
refresh() refresh()
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L) delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh() if (state.requestsVisible || state.requests.snapshot.hasUnsettledRequests) refresh()
} }
} }

View file

@ -210,7 +210,9 @@ class WolfskinTitleViewModel @Inject constructor(
internal suspend fun retrySignIn() { internal suspend fun retrySignIn() {
val user = repository.currentUser ?: return val user = repository.currentUser ?: return
val server = repository.currentServer ?: return val server = repository.currentServer ?: return
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) withContext(Dispatchers.IO) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true)
}
conductarr.authenticate() conductarr.authenticate()
} }

View file

@ -39,6 +39,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -60,6 +63,7 @@ import org.justinvisser.wolfskin.design.DiscoverScreen
class WolfskinSession @Inject constructor() { class WolfskinSession @Inject constructor() {
var state by mutableStateOf<ShellState?>(null) var state by mutableStateOf<ShellState?>(null)
var serverId: String? = null var serverId: String? = null
internal var fixtureMode: Boolean? = null
var onAction: (ShellAction) -> Unit = {} var onAction: (ShellAction) -> Unit = {}
} }
@ -85,24 +89,29 @@ class WolfskinShellViewModel @Inject constructor(
var signedInPerson by mutableStateOf("") var signedInPerson by mutableStateOf("")
private set private set
private var identityJob: kotlinx.coroutines.Job? = null private var identityJob: kotlinx.coroutines.Job? = null
private var fixtureMode: Boolean? = null private var personSwitchJob: kotlinx.coroutines.Job? = null
private val personSwitchMutex = Mutex()
suspend fun load(server: JellyfinServer, user: JellyfinUser) { suspend fun load(server: JellyfinServer, user: JellyfinUser) {
WolfskinScreenPreference.initialize(context) withContext(Dispatchers.IO) {
ConductarrPreference.initialize(context) WolfskinScreenPreference.initialize(context)
ConductarrPreference.initialize(context)
}
identityJob?.cancel() identityJob?.cancel()
libraries.audiences = null libraries.audiences = null
if (ConductarrPreference.fixtures) conductarr.close() if (ConductarrPreference.fixtures) conductarr.close()
else conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) else withContext(Dispatchers.IO) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl) }
signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase() signedInPerson = session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: user.name.orEmpty().lowercase()
if (session.state == null || session.serverId != server.id.toString() || fixtureMode != ConductarrPreference.fixtures) {
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()
fixtureMode = ConductarrPreference.fixtures
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
}
session.onAction = { action -> handleAction(action, server, user) } session.onAction = { action -> handleAction(action, server, user) }
if (session.state == null || session.serverId != server.id.toString() || session.fixtureMode != ConductarrPreference.fixtures) {
session.serverId = server.id.toString()
session.fixtureMode = ConductarrPreference.fixtures
val people = withContext(Dispatchers.IO) { readShellPeople(context, listOf(user)) }
session.state = ShellState(people, signedInPerson) { session.onAction(it) }
val saved = withContext(Dispatchers.IO) { repository.serverDao.getServer(server.id)?.users.orEmpty() }
val savedPeople = withContext(Dispatchers.IO) { readShellPeople(context, saved.ifEmpty { listOf(user) }) }
session.state?.updatePeople(savedPeople)
}
identityJob = viewModelScope.launch { identityJob = viewModelScope.launch {
if (!ConductarrPreference.fixtures) loadHousehold(user) if (!ConductarrPreference.fixtures) loadHousehold(user)
} }
@ -123,12 +132,19 @@ class WolfskinShellViewModel @Inject constructor(
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() }
?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it }
val hasAnime = try {
libraries.hasAnimeTitles(user.id)
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
session.state?.people?.firstOrNull { it.jellyfinUserId == user.id.toString() }?.hasAnime ?: false
}
if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return
val updated = session.state?.people.orEmpty().map { person -> val updated = session.state?.people.orEmpty().map { person ->
val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) }
val current = person.jellyfinUserId == user.id.toString() val current = person.jellyfinUserId == user.id.toString()
person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key,
name = if (current) identity.operator!!.name else profile?.name ?: person.name, name = if (current) identity.operator!!.name else profile?.name ?: person.name,
hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime) hasAnime = if (current) hasAnime else person.hasAnime)
} }
session.state?.updatePeople(updated) session.state?.updatePeople(updated)
signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson
@ -142,8 +158,10 @@ class WolfskinShellViewModel @Inject constructor(
} }
suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) {
conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, withContext(Dispatchers.IO) {
retry = conductarr.identity.value?.signedIn != true) conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl,
retry = conductarr.identity.value?.signedIn != true)
}
loadHousehold(user) loadHousehold(user)
} }
@ -174,10 +192,17 @@ class WolfskinShellViewModel @Inject constructor(
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) { private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
when (action) { 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)) is ShellAction.Destination -> {
val target = if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route)
when {
action.route == "settings" -> navigation.navigateTo(target)
action.preservePushed && navigation.backStack.size > 1 -> navigation.backStack[0] = target
else -> navigation.replace(target)
}
}
ShellAction.Pop -> navigation.goBack() ShellAction.Pop -> navigation.goBack()
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group)) is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user) is ShellAction.Person -> selectPerson(action.person, server)
ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) } ShellAction.RetryPeople -> viewModelScope.launch { retryConductarr(server, user) }
ShellAction.RetryRequests -> viewModelScope.launch { ShellAction.RetryRequests -> viewModelScope.launch {
if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user) if (conductarr.identity.value?.signedIn != true) retryConductarr(server, user)
@ -187,26 +212,29 @@ class WolfskinShellViewModel @Inject constructor(
} }
} }
private fun selectPerson(person: ShellPerson, server: JellyfinServer, user: JellyfinUser) { private fun selectPerson(person: ShellPerson, server: JellyfinServer) {
navigation.replace(Destination.Home()) navigation.replace(Destination.Home())
if (person.jellyfinUserId == user.id.toString() || person.key == signedInPerson) return if (person.key == signedInPerson) return
identityJob?.cancel()
personSwitchJob?.cancel()
signedInPerson = ""
conductarr.close() conductarr.close()
libraries.audiences = null libraries.audiences = null
viewModelScope.launch { personSwitchJob = viewModelScope.launch {
val saved = withContext(Dispatchers.IO) { personSwitchMutex.withLock {
repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) } val saved = withContext(Dispatchers.IO) {
} repository.serverDao.getServer(server.id)?.users?.firstOrNull { it.id.toString() == person.jellyfinUserId || it.name.equals(person.name, ignoreCase = true) }
if (saved == null || saved.isProtected) { }
signedInPerson = "" if (saved == null || saved.isProtected) return@launch
return@launch try {
} val current = repository.changeUser(server, saved)
try { coroutineContext.ensureActive()
val current = repository.changeUser(server, saved) setupNavigation.navigateTo(SetupDestination.AppContent(current))
setupNavigation.navigateTo(SetupDestination.AppContent(current)) } catch (exception: Exception) {
} catch (exception: Exception) { if (exception is kotlinx.coroutines.CancellationException) throw exception
if (exception is kotlinx.coroutines.CancellationException) throw exception coroutineContext.ensureActive()
signedInPerson = "" session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true }
session.state?.apply { peopleStatus = PeopleStatus.Unavailable; pickerVisible = true } }
} }
} }
} }
@ -230,6 +258,8 @@ fun WolfskinApp(
preferences: UserPreferences, preferences: UserPreferences,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: WolfskinShellViewModel = hiltViewModel(), viewModel: WolfskinShellViewModel = hiltViewModel(),
homeContent: @Composable (ShellState, JellyfinServer, JellyfinUser, Modifier) -> Unit =
{ shell, homeServer, homeUser, contentModifier -> HomeDestination(shell, homeServer, homeUser, contentModifier) },
) { ) {
val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle()
LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) }
@ -274,17 +304,23 @@ fun WolfskinApp(
entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()), entryDecorators = listOf(rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator()),
entryProvider = { destination -> entryProvider = { destination ->
NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") { NavEntry(destination, contentKey = "${destination}_${server.id}_${user.id}") {
// Cached entries must not retain the launch shell after sign-in completes.
val state = LocalShellState.current
val discover = LocalDiscoverState.current
val route = when (destination) { val route = when (destination) {
is Destination.Home -> "home" is Destination.Home -> "home"
is Destination.Wolfskin -> destination.route is Destination.Wolfskin -> destination.route
is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString()) is Destination.MediaItem -> if (destination.type == org.jellyfin.sdk.model.api.BaseItemKind.PERSON) personRoute(destination.itemId.toString()) else titleRoute(destination.itemId.toString())
else -> "pushed" else -> "pushed"
} }
LaunchedEffect(destination) { if (!destination.fullScreen) state.enterDestination(route) } val isCurrent = navigationManager.backStack.lastOrNull() == destination
LaunchedEffect(destination, state, isCurrent) {
if (isCurrent && !destination.fullScreen) state.enterDestination(route)
}
if (destination.fullScreen) { if (destination.fullScreen) {
DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize()) DestinationContent(destination, preferences, viewModel::clearBackdrop, Modifier.fillMaxSize())
} else { } else {
BackHandler { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) } BackHandler(enabled = isCurrent) { state.key(org.justinvisser.wolfskin.design.ShellKey.Back) }
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) { if (upstream == null && (route.startsWith("title/") || route.startsWith("person/"))) {
val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null val jellyfinIdentity = destination is Destination.MediaItem || android.net.Uri.decode(route.substringAfter('/')).toUUIDOrNull() != null
@ -297,7 +333,7 @@ fun WolfskinApp(
when { when {
(upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) (upstream != null || destination is Destination.Home || route in LIBRARY_DESTINATIONS) && 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) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) destination is Destination.Home -> homeContent(state, server, user, contentModifier)
route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") -> route in LIBRARY_DESTINATIONS || route.startsWith("seeall/") ->
WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier) WolfskinLibraryContent(androidx.compose.ui.platform.LocalContext.current, route, state, user, "${server.id}:${user.id}", viewModel.libraries, navigationManager, viewModel.seeAll, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -59,7 +59,7 @@ fun WolfskinLibraryContent(
context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") } context.assets.open("wolfskin-library.json").bufferedReader().use { LibraryFixture(it.readText(), "file:///android_asset/library/") }
} }
} }
val snapshots = remember(context) { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } val snapshots = remember(context) { lazy { LibrarySnapshotStore(FileCacheStore(File(context.filesDir, "wolfskin-library-cache"))) } }
val grid = remember(shell.person.key, route, view) { val grid = remember(shell.person.key, route, view) {
view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState( view.library ?: if (route.startsWith("seeall/")) seeAll.get(route.substringAfter("seeall/").takeIf { it.startsWith("${shell.person.key}/") } ?: "") else LibraryGridState(
title = route.replaceFirstChar { it.uppercase() }, items = emptyList(), title = route.replaceFirstChar { it.uppercase() }, items = emptyList(),
@ -81,7 +81,8 @@ fun WolfskinLibraryContent(
grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) } grid.source = withContext(Dispatchers.IO) { fixture!!.library(shell.person.key, route) }
grid.status = LibraryStatus.Ready grid.status = LibraryStatus.Ready
} else { } else {
snapshots.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot -> val store = withContext(Dispatchers.IO) { snapshots.value }
store.read(cacheScope, route, force = grid.refreshRevision > 0) { facade.readLibrary(user.id, route) }.collect { snapshot ->
val focusedId = grid.focusedTitle?.id val focusedId = grid.focusedTitle?.id
grid.source = snapshot.items grid.source = snapshot.items
focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } } focusedId?.let { id -> grid.items.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.let { grid.index = it } }

View file

@ -16,7 +16,7 @@ import org.justinvisser.wolfskin.design.HomeTitle
/** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */ /** Saved Jellyfin projections reuse the app-private atomic cache and are partitioned by server/user. */
@Singleton @Singleton
class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) { class HomeLibraryCache @Inject constructor(@param:ApplicationContext context: Context) {
private val store = FileCacheStore(File(context.filesDir, "wolfskin-home")) private val store by lazy { FileCacheStore(File(context.filesDir, "wolfskin-home")) }
fun read(scope: String): Pair<HomeLibrary, Long>? { fun read(scope: String): Pair<HomeLibrary, Long>? {
val entry = store.read(scope, KEY) ?: return null val entry = store.read(scope, KEY) ?: return null

View file

@ -48,6 +48,18 @@ class LibraryReadFacade @Inject constructor(private val api: ApiClient) {
} }
} }
/** Audience metadata names a grouping; only this user's visible titles make Anime available. */
suspend fun hasAnimeTitles(userId: UUID): Boolean {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty()
for (library in libraries) {
if (destination(library.name.orEmpty(), library.collectionType) != "anime") continue
val titles = api.itemsApi.getItems(userId = userId, parentId = library.id, recursive = true,
includeItemTypes = listOf(BaseItemKind.SERIES), limit = 1).content.items.orEmpty()
if (titles.isNotEmpty()) return true
}
return false
}
/** A destination spans every matching accessible library; containers never become titles. */ /** A destination spans every matching accessible library; containers never become titles. */
fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow { fun readLibrary(userId: UUID, destination: String): Flow<List<LibraryTitle>> = flow {
val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library -> val libraries = api.userViewsApi.getUserViews(userId = userId).content.items.orEmpty().filter { library ->

View file

@ -83,8 +83,8 @@ class TitleReadFacade @Inject constructor(
/** An episode opens its series with that exact episode as both play and watched target. */ /** An episode opens its series with that exact episode as both play and watched target. */
suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) { suspend fun read(id: UUID, season: Int?, preferences: UserPreferences): TitlePageData = withContext(Dispatchers.IO) {
val file = cacheFile(id) val file = cacheFile(id)
val opened = api.userLibraryApi.getItem(id).content val opened = readItem(id)
val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) api.userLibraryApi.getItem(opened.seriesId!!).content else opened val series = if (opened.type == BaseItemKind.EPISODE && opened.seriesId != null) readItem(opened.seriesId!!) else opened
val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList() val seasons = if (series.type == BaseItemKind.SERIES) api.itemsApi.getItems(GetItemsRequest(parentId = series.id, includeItemTypes = listOf(BaseItemKind.SEASON), recursive = false, sortBy = listOf(ItemSortBy.INDEX_NUMBER))).content.items.orEmpty() else emptyList()
val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else { val next = if (opened.type == BaseItemKind.EPISODE || opened.type == BaseItemKind.MOVIE) opened else {
api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content } api.tvShowsApi.getNextUp(GetNextUpRequest(seriesId = series.id, userId = repository.currentUser?.id, limit = 1, fields = fields, enableResumable = true)).content.items.firstOrNull()?.let { api.userLibraryApi.getItem(it.id).content }
@ -107,6 +107,20 @@ class TitleReadFacade @Inject constructor(
result.copy(trailerUrl = trailer) result.copy(trailerUrl = trailer)
} }
/** User-library detail may omit metadata fields; request catalogue identity and cast explicitly. */
private suspend fun readItem(id: UUID): BaseItemDto {
val item = api.userLibraryApi.getItem(id).content
if (item.providerIds.orEmpty().keys.any { it.equals("Tmdb", true) } && item.people != null) return item
val metadata = try {
api.itemsApi.getItems(GetItemsRequest(ids = listOf(id), userId = repository.currentUser?.id,
fields = listOf(ItemFields.PROVIDER_IDS, ItemFields.PEOPLE))).content.items.orEmpty().firstOrNull { it.id == id }
} catch (exception: Exception) {
if (exception is kotlinx.coroutines.CancellationException) throw exception
null
} ?: return item
return item.copy(providerIds = metadata.providerIds ?: item.providerIds, people = item.people ?: metadata.people)
}
/** A failed reachability read leaves navigation on the title instead of opening a dead player. */ /** A failed reachability read leaves navigation on the title instead of opening a dead player. */
suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) } suspend fun preparePlayback(id: UUID) { api.userLibraryApi.getItem(id) }

View file

@ -1,8 +1,8 @@
# Wolfskin TV container builds # Wolfskin TV container builds
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the host needs Docker, GNU Make, Bash, Git, `realpath` (GNU coreutils), and `flock`
Android SDK. (util-linux), not Java or the Android SDK.
```sh ```sh
git fetch origin --tags git fetch origin --tags
@ -27,6 +27,85 @@ Upstream enables JVM unit tests only for debug variants; there is no
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor `testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
task without changing upstream's test configuration. task without changing upstream's test configuration.
## Optimized side-by-side comparison
The `optimized` build type inherits `release`: R8 optimization/minification,
resource shrinking, non-debuggable code, dependency consumer keep rules and
dependency baseline profiles. It changes only signing (the Android debug key),
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
The launcher icon/banner and release-compatible version name are unchanged:
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
`make apk` remains the clean, release-key-signed production build. Debug and
optimized containers share the persistent debug key described below; release
signing is unchanged.
Build all four default-flavor outputs in one invocation through the container
wrapper, without a release keystore. On a host whose Gradle cache already holds
the pinned native extensions and their metadata:
```sh
bash docker/gradle.sh optimized --offline \
-PWholphinExtensionsUsername=JustinZeus \
-PWholphinExtensionsPassword=unused \
:app:assembleDefaultOptimized
```
The unused password is not a credential: offline resolution never contacts
GitHub Packages. A cold cache instead needs a read-packages credential supplied
through the existing `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` hooks inside the build container,
or securely seeded cached artifacts. No release signing inputs are needed.
Without native extension configuration, the existing dependency selection uses
the MPV stub and omits ffmpeg/AV1, just like `make apk-debug`; that is not a
playback-complete comparison. Inspect native libraries before installing.
Outputs are in `app/build/outputs/apk/default/optimized/`: universal, arm64-v8a,
armeabi-v7a and x86_64. Keep
`app/build/outputs/mapping/defaultOptimized/mapping.txt` with those APKs to
deobfuscate crashes. This variant shares release library variants and the release
`debugHomeSnapshot` implementation, not `src/debug` or its preview JSON/artwork.
`BuildConfig.DEBUG` remains false: no preview-fixture setting, debug StrictMode
or Compose diagnostic stack traces.
This is a fresh app sandbox: sign in with Jellyfin Quick Connect or the normal
server/user flow again; it does not inherit the debug/release app's logins or
preferences. **Wolfskin screens** defaults on, and the same Advanced switch can
restore upstream screens. The same Conductarr door
(`https://wolfskin-tv.justinvisser.org`) and URL override apply. The selected
Jellyfin user's token is borrowed for Conductarr; no app-specific TV credential
or embedded secret is added. All other settings retain release behavior,
including update checks; a production release APK is not an update for this
separate package/debug signer.
`androidx.profileinstaller` and library-provided profiles already exist. There
is no recorded app baseline-profile source or Macrobenchmark module.
`src/optimized/baselineProfiles/baseline-prof.txt` adds a hand-maintained seed: application and
activity classes plus their `onCreate` methods, `WolfskinAppKt`,
`WolfskinScreenPreference` and `ConductarrPreference`. AGP merges it with library
profiles, rewrites it through R8 and packages `assets/dexopt/baseline.prof` and
`baseline.profm`. It is not a measured navigation profile or evidence of a speedup.
To replace the seed, add an AndroidX Baseline Profile producer/Macrobenchmark
module, record cold launch, Quick Connect/sign-in, person selection, Home,
Libraries, Discover, title pages and playback on a suitable device, then rebuild
and compare startup/frame timings. Device-driven recording and runtime checks
remain separate from an APK build.
The optimized source set explicitly registers that profile directory; a bare
`src/optimized/baseline-prof.txt` is not consumed by this AGP configuration.
Static verification: use `aapt2 dump badging` for package/label/SDKs and absence
of `application-debuggable`; `apksigner verify --print-certs` for the debug
signer; `unzip -l` for both dexopt assets, native ABI contents and absence of
`assets/wolfskin-*`/`assets/library/` fixtures. Review R8 warnings and effective
consumer rules rather than adding blanket keeps. JVM tests do not prove
minified Hilt/Room/serialization/JNI runtime safety; exercise sign-in, real
Conductarr reads, artwork, database reopen and each player on the comparison box.
`help --task :app:testDefaultOptimizedUnitTest` confirms that this non-debug
variant has no JVM unit-test task, just like release. The existing debug JVM
`VersionCompareTests` receives the actual optimized version name from the variant
API and checks it with the same `Version.fromString` used by upgrade/update handling.
Build logic is otherwise checked through actual APK assembly and static inspection.
## Upstream v1.1.0 integration ## Upstream v1.1.0 integration
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
@ -104,12 +183,44 @@ household labels, requester capabilities and library audiences after sign-in.
The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used The locked shell projection in `app/src/debug/assets/wolfskin-shell.json` is used
only when the Advanced debug fixture switch is enabled; release excludes it. only when the Advanced debug fixture switch is enabled; release excludes it.
Person selection immediately clears the old Conductarr identity and Jellyfin
read gate before saved-login I/O. Detached clients are disposed exactly once on
the application's IO scope, after the replacement (or signed-out) identity is
published; a slow or failing socket close cannot block the picker or abort a
switch. Cleanup outlives the replaced screen. Retry and session replacement use
the same disposal owner. Superseded sign-ins are cancelled and serialized so a
late reply cannot navigate back to an earlier person.
The fixture-mode marker belongs to the singleton `WolfskinSession`, alongside its
server and shell state, not to the user-scoped ViewModel. Switching people replaces
the upstream app entry and ViewModel but retains the same-server, same-mode shell:
the picker stays closed on Home after the first OK. A cold start still opens it,
and the nav avatar explicitly reopens it. `PersonPickerSessionTest` drives the real
shell and user-entry replacement for launch/avatar Left and Right, the initial
person, gear and back, and fast Left-Left-OK; each selection emits one person action.
Preference and people-fixture reads, Conductarr client construction (including
Home/title retries), and private-cache directory initialization run on IO.
`PersonSwitchThreadingTest` replays the real picker across Justin → Mindel →
Shared → Justin while all closes are held pending, verifies retired clients
cannot start reads, contains a failing close, and checks a burst of choices
against a late sign-in reply. The thread-recording close fake rejects the main
Looper; actual TLS/StrictMode verification remains a real-device check.
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...` See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
destination is an explicit title-family integration stub exposing only Discover's destination is an explicit title-family integration stub exposing only Discover's
request and pick actions. Unheld titles have no trailer control until the pinned request and pick actions. Unheld titles have no trailer control until the pinned
Conductarr title contract supplies a trailer. Conductarr title contract supplies a trailer.
Named “Because you watched” shelves require watched contribution evidence.
Seeds are grouped by media type and TMDB identity, never by caption; repeated
contributions retain both watched/added flags. The current feed has no per-seed
watch timestamp, so eligible shelves keep wire order. Added-only contributions
remain available in the general For-you collections, but make no named shelf.
Titleless contributions do not make a shelf. Recommendations remain acquisition
suggestions, not a filter to the viewer's current library.
The debug-only fixture and local art live in The debug-only fixture and local art live in
`app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a `app/src/debug/assets/wolfskin-discover/`. The Advanced preview switch selects a
network-free fixture host; real providers are the default in debug and release. network-free fixture host; real providers are the default in debug and release.
@ -248,14 +359,24 @@ are recorded differences, not a claim of browser pixel equality.
## Requests under Settings ## Requests under Settings
The Requests row and Coming-in sheet share one immutable household snapshot. The Requests row and Coming-in sheet share one immutable household history snapshot.
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready, Settings counts returned requests, including Ready, rather than calling every
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four non-Ready row “open”. An unavailable empty snapshot has no count. Deleted rows
with readiness expanded. Right expands Request/Download/Library/Playback; Left read Removed; absent holdings read Not in any library; unreleased titles read
collapses; Up/Down collapse while moving. Opening a title preserves the request Not out yet; holdings outside the recipient's libraries read In another library.
for Back, and closing the sheet restores its opener. Selecting a root or changing Waiting for an import is not evidence of an active download. Downloading and
person clears the title-return link. Shared's authenticated actor always reads Searching require explicit acquisition signals. Visibility-proof faults read
“by Shared”, never “by you”. Check needed; playback faults read File health needs attention, even when a
library holding is present. Right expands the actual stage evidence and the
relevant summary, including episode/source names when supplied by that summary.
The activity feed currently supplies only a title-level summary for Avatar.
Attention-needed rows sort first, then active acquisition/checks, waiting and
settled history. The bounded window shows five rows, or four with readiness
expanded. Left collapses; Up/Down collapse while moving. Opening a title preserves
the request for Back, and closing the sheet restores its opener. Selecting a root
or changing person clears the title-return link. Shared's authenticated actor
always reads “by Shared”, never “by you”; absent actors have no “by” clause.
The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and The debug fixture switch loads `app/src/debug/assets/wolfskin-requests.json` and
its eight local posters. Shared and focused-rating records also feed the native its eight local posters. Shared and focused-rating records also feed the native
@ -307,12 +428,22 @@ type and TMDB identity. Back restores the exact Coming-in row and expansion;
the temporary `request-title/...` placeholder is removed. TV sign-in, release the temporary `request-title/...` placeholder is removed. TV sign-in, release
and device deployment remain separate. and device deployment remain separate.
`ActivityTruthProjectionTest` feeds recorded activity JSON through the TV adapter
for Steins;Gate, Yu Yu Hakusho, Love is Blind, Spider-Man: Homecoming, deleted
One Hour Photo, unreleased The Body, Avatar health and Partition waiting.
`ConductarrTruthCaptureTest` renders those provider snapshots and Mindel's recorded
recommendation feed on the native Compose surfaces at 1920×1080, writing
`build/wolfskin-captures/truth/`. Artwork is deliberately omitted to keep these
captures offline; missing-art rendering, tokens, focus, sheet geometry and shelf
spacing are the shipped components.
## Libraries and See all ## Libraries and See all
Movies, Shows and Anime use the shell's retained view and a continuous six-column Movies, Shows and Anime use the shell's retained view and a continuous six-column
lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves lazy grid, reduced to five columns while the letter rail is visible so scaled
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav;
exists only at 200 titles with A–Z selected. Card OK activates on key-up through Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating.
The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through
the same shell hold guard, without a long-press menu. the same shell hold guard, without a long-press menu.
Down clamps a short final title row before entering the separate Try again / Show Down clamps a short final title row before entering the separate Try again / Show
more row; an empty slot cannot activate recovery. An unavailable library hides more row; an empty slot cannot activate recovery. An unavailable library hides
@ -347,6 +478,65 @@ music keep their upstream destinations.
failure/retry, stale copies, and person/server isolation. Library captures are written failure/retry, stale copies, and person/server isolation. Library captures are written
to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`.
### Roomy card spacing
The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf
heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080.
Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px,
preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px:
chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear).
Discover and season-chip rows retain their 104 px height.
Cards and cast circles draw a single 4 px accent stroke centred on the art edge,
above the content, with no background gap. Card lift parameters are unchanged;
cast portraits and episode tiles now receive that lift too. The stroke and art
scale together at 1.08.
Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px
for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
Poster progress uses the independent 18 px title-card inset, not caption spacing:
the track remains 220 px wide on a 256 px poster. Library focus-follow restarts
when the effective column count, focused row or header geometry changes, retaining
deep focus across live snapshots that add or remove the letter rail.
`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged
focused index across the 199↔200-title boundary.
`LibraryCaptureTest.spacingShowsFirstFocused`,
`HomeCaptureTest.spacingNextUpFirstFocused` and
`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states
at native resolution. Their files are `library/spacing-shows.png`,
`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`.
These captures are recordings, not automated reference-pixel assertions; pair
measurements are a separate check. Platform Roboto metrics and fixture ordering
are not browser pixel parity.
### Shared focus rings
The same `focusRing` stroke now covers pill/icon buttons (hero, title, state,
recovery and sheet actions), the nav avatar, picker people and gear. It is always
accent, 4 px / 2 dp wide, centred on the surface edge with no background gap.
The former outside-gap renderer is removed. The stroke draws after opaque
content, so neither half can be covered by the background or portrait.
Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`;
avatar and picker tokens use the same geometry. Existing scales remain 1.05 for
buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card
and button lifts remain; avatars, people and gear gain no shadow. Existing
clearance accommodates the smaller outside reach without layout movement.
Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail
are unchanged. Selected-only chip/icon/radio borders and decorative borders
are not focus rings. These distinctions are explicit in the inventory and
reference states rather than silently adding rings to filled controls.
`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the
two outside and two inside pixels against opaque content for pill, circle and
card radii, including clearing the stroke on blur. The focused hero Details,
nav avatar, picker, Discover chip, Library toolbar chip and title Request
captures exercise the actual screens at 1920×1080. Browser and native glyph
metrics differ; their ring offsets are measured relative to each surface edge.
## Title pages and people ## Title pages and people
`TitleSurface` owns the film/series/episode header, season chips, episode tiles, `TitleSurface` owns the film/series/episode header, season chips, episode tiles,
@ -473,12 +663,38 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin
logins; that does not create selectable TV logins. Late name reads preserve the logins; that does not create selectable TV logins. Late name reads preserve the
open chooser's selected recipient and footer focus. open chooser's selected recipient and footer focus.
The launch picker publishes the current Jellyfin user and working navigation before
the saved-login database read. Saved people and Conductarr household metadata fill
that same shell later; slow activity or catalogue reads cannot hold its first usable
frame. Discover details navigate to the shared title page, including cast, performer
navigation and related titles, rather than the header-only design preview. Held
items whose detail omits ProviderIds or People get an explicit Jellyfin field read
before catalogue enrichment, without changing their playback identity. Scrolled
navigation draws an opaque band; the unscrolled hero treatment is unchanged.
Cached `NavDisplay` entries read the current shell and Discover composition locals,
not the temporary launch shell captured when the entry was first created. Otherwise
the screen can retain skeleton people, disconnected tab actions and an open picker
even after the real session is ready.
Only the current navigation entry may set the shared route or enable the outer
host's Back handler; outgoing entries can remain composed during a transition.
Nested title handlers retain their existing lifecycle-based behaviour.
Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's
authenticated fetcher. The app's network interceptor removes Authorization from authenticated fetcher. The app's network interceptor removes Authorization from
every non-Jellyfin artwork origin (scheme, host and port), including redirects; every non-Jellyfin artwork origin (scheme, host and port), including redirects;
current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps
the existing mapping or Jellyfin name rule. Returning to the foreground retries the existing mapping or Jellyfin name rule. Returning to the foreground retries
household metadata without rebuilding the selected identity. household metadata without rebuilding the selected identity.
An Anime audience grants a library grouping, not proof of owned titles. Household
refresh enables Anime only after a one-title Jellyfin read confirms visible series
in an accessible Anime library for the signed-in user. This runs after the live
picker is published; failures retain that user's last confirmed availability.
Empty Anime Home shelves remain omitted.
When availability changes, focus follows the avatar or a surviving destination;
removing Anime never leaves an out-of-range nav selection. If Anime was the root,
both the shell and navigation stack replace it with Home while retaining pushed
title/person pages. `AnimeRootRemovalTest` covers active-root Back, avatar focus,
and the real navigation stack with one or two pushed pages.
| Screen seam | Production supplier | | Screen seam | Production supplier |
| --- | --- | | --- | --- |
@ -504,6 +720,21 @@ and a same-host/different-port origin with an authenticated upstream parent,
and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers
operator success plus failed/empty audiences, name-rule retention and foreground healing. operator success plus failed/empty audiences, name-rule retention and foreground healing.
`ShellStartupTimingTest` holds saved-login and real-wire activity reads pending
while exercising the picker, early tabs and a person switch. `TitleReadFacadeTest`
covers omitted held-film provider ids with authenticated cast/related enrichment.
`DiscoverTitleTimingTest` delays the cast reply, exercises real remote Down/Back
and writes native cast/related captures to `walk-fixes/`.
`NavBandOcclusionTest` checks pixels across the entire scrolled navigation band.
`FirstSignInNavigationTest` starts the full app host with no existing shell and
immediate signed-in replies, then continues as Mindel and renders her six-film
Movies grid and 29-series Shows grid from owner-prefixed Jellyfin views. It also
checks that Continue closes the real session picker and selecting Home does not
reopen it.
`AnimeAvailabilityTest` reads the signed-in audience wire shape and checks Shared
and Mindel with empty and nonempty Jellyfin libraries: no empty Anime nav item or
Home shelf, while a real visible series enables both.
Not done here: deploying the dedicated hostname or widening server authority; Not done here: deploying the dedicated hostname or widening server authority;
hardware installation/playback verification; releases, signing or version changes. hardware installation/playback verification; releases, signing or version changes.
Unheld trailers remain absent because the server contract has no trailer field. Unheld trailers remain absent because the server contract has no trailer field.
@ -611,6 +842,41 @@ whose parent directory already exists. All callers sharing a volume must use
the same lock path on the Docker host. Do not delete the file while builds are the same lock path on the Docker host. Do not delete the file while builds are
running or waiting. The lock releases automatically when the invocation exits. running or waiting. The lock releases automatically when the invocation exits.
## Persistent debug signing
`make apk-debug` and non-release `docker/gradle.sh` invocations (including
`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout:
```text
/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore
```
Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside
the checkout when building on another host, for example:
```sh
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
```
The wrapper creates a missing key once using the build image's `keytool`: RSA
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
file mode `0600`. Existing keys are reused, never regenerated; generation errors
abort the build rather than falling back to an ephemeral container key.
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
Keep and back up this file across clean builds, disposable worktrees, container
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
are public; this key is for local debug/optimized builds, not release signing.
**One-time reinstall:** the first build with the stable key still needs one
uninstall+reinstall on the box because the signer changes once; later ones
install in place with `adb install -r`, retaining sign-ins and settings.
The initial uninstall removes app data, so sign in again once after that switch.
Deleting/replacing the persistent key or choosing a different override changes
the signer again and requires another reinstall.
## Release credentials ## Release credentials
The default escrow is The default escrow is

View file

@ -35,5 +35,36 @@ if [[ $mode == release ]]; then
exec ./gradlew "$@" exec ./gradlew "$@"
' bash "${gradle_arguments[@]}" "$@" ' bash "${gradle_arguments[@]}" "$@"
else else
debug_keystore=$(realpath -m "${WOLFSKIN_DEBUG_KEYSTORE:-/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore}")
case "$debug_keystore" in
"$root"|"$root"/*)
printf 'Debug keystore must be outside the checkout: %s\n' "$debug_keystore" >&2
exit 1
;;
esac
signing_directory=$(dirname "$debug_keystore")
mkdir -p "$signing_directory"
chmod 700 "$signing_directory"
if [[ ! -e $debug_keystore ]]; then
temporary_directory=$(mktemp -d "$signing_directory/.debug-keystore.XXXXXX")
trap 'rm -rf -- "$temporary_directory"' EXIT
if ! docker run --rm --platform linux/amd64 --user "$(id -u):$(id -g)" \
--mount "type=bind,src=$temporary_directory,dst=/run/signing" \
"$image" keytool -genkeypair -noprompt -keystore /run/signing/debug.keystore \
-storetype JKS -storepass android -keypass android -alias androiddebugkey \
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug"; then
printf 'Failed to generate persistent debug keystore: %s\n' "$debug_keystore" >&2
exit 1
fi
chmod 600 "$temporary_directory/debug.keystore"
mv "$temporary_directory/debug.keystore" "$debug_keystore"
fi
if [[ ! -s $debug_keystore ]]; then
printf 'Missing or empty persistent debug keystore: %s\n' "$debug_keystore" >&2
exit 1
fi
chmod 600 "$debug_keystore"
arguments+=(--env ANDROID_USER_HOME=/tmp/.android
--mount "type=bind,src=$debug_keystore,dst=/tmp/.android/debug.keystore,readonly")
docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@"
fi fi

View file

@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
val shape = RoundedCornerShape(WolfskinTokens.Radius.card) val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))

View file

@ -157,7 +157,8 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline), Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis) style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline), val actor = item.actor(viewer)
Text("for ${item.recipient(viewer)}" + (actor?.let { " · by $it" } ?: ""), Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
style = ComingInTypography.caption, color = secondary) style = ComingInTypography.caption, color = secondary)
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) { FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
@ -168,14 +169,17 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
} }
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin), if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) { horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
item.stage.readiness.forEachIndexed { index, word -> item.readiness.forEachIndexed { index, word ->
Row { Row {
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" } val glyph = when (word) { "Done", "Files present", "Recorded play", "Another library" -> "check"; "Failed", "Check needed", "Needs attention" -> "alert"; "Not yet", "Unknown", "Not checked yet", "Not in library", "Removed", "Not out yet" -> null; else -> "clock" }
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight }) if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary) Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
} }
} }
} }
if (expanded) item.summary?.let { summary ->
Text(summary, Modifier.padding(top = T.readinessMargin), style = ComingInTypography.readiness, color = secondary)
}
} }
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron)) if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
} }

View file

@ -6,14 +6,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
/** Household acquisition stages, ordered independently from their display wording. */ /** History states distinguish acquisition work from library evidence and playback health. */
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) { enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")), Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
FileHealth("File health needs attention", 0, "alert", listOf("Done", "Files present", "See details", "Needs attention")),
CheckNeeded("Check needed", 0, "alert", listOf("Done", "Files present", "Check needed", "Not checked yet")),
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")), Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")), AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")), Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")), Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")), Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
WaitingForFile("Waiting for a file", 3, "clock", listOf("Done", "Waiting for a file", "Not yet", "Not checked yet")),
NotOutYet("Not out yet", 3, "clock", listOf("Done", "Not out yet", "Not yet", "Not checked yet")),
HeldElsewhere("In another library", 4, "check", listOf("Done", "Files present", "Another library", "Not checked yet")),
Removed("Removed", 4, "clock", listOf("Done", "Removed", "Not in library", "Not checked yet")),
NotInLibrary("Not in any library", 4, "clock", listOf("Done", "Not observed", "Not in library", "Not checked yet")),
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")), Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
} }
@ -33,13 +40,15 @@ data class ComingInRequest(
val stage: ComingInStage, val stage: ComingInStage,
val poster: Any? = null, val poster: Any? = null,
val ratings: List<ComingInRating> = emptyList(), val ratings: List<ComingInRating> = emptyList(),
val summary: String? = null,
val readiness: List<String> = stage.readiness,
) { ) {
val ratingKey = "$mediaType:$tmdbId" val ratingKey = "$mediaType:$tmdbId"
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
fun actor(viewer: ShellPerson): String = when { fun actor(viewer: ShellPerson): String? = when {
actorKey == "shared" -> "Shared" actorKey == "shared" -> "Shared"
actorKey != null && actorKey == viewer.key -> "you" actorKey != null && actorKey == viewer.key -> "you"
else -> actorName ?: "Unknown" else -> actorName?.takeIf { it.isNotBlank() }
} }
} }
@ -54,7 +63,13 @@ data class ComingInSnapshot(
val savedAt: String? = null, val savedAt: String? = null,
) { ) {
val ordered = items.sortedBy { it.stage.rank } val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready } val requestCount: Int? get() = if (status == ComingInStatus.Ready || items.isNotEmpty()) items.size else null
val hasUnsettledRequests: Boolean get() = items.any {
when (it.stage) {
ComingInStage.Ready, ComingInStage.Removed, ComingInStage.NotInLibrary, ComingInStage.HeldElsewhere, ComingInStage.NotOutYet -> false
else -> true
}
}
} }
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */ /** A bounded window and stable request id preserve focus across refreshes and title round trips. */

View file

@ -35,7 +35,8 @@ class DiscoverFixture(json: String, private val artPath: (String) -> String = {
val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") } val items = pool.filter { genre in normalizedGenres(it.genres) && (genre !in setOf("Science Fiction", "Horror", "Romance") || it.type == "movie") }
DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items) DiscoverLane("genre-$genre", genre, items.take(20), items.size, fullTitles = items)
} }
return DiscoverSnapshot(picks, groups.values.map { DiscoverSeed(it.first, it.second.toList()) }, services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true) return DiscoverSnapshot(picks, groups.map { (key, group) -> DiscoverSeed(key, group.first, group.second.toList(), watched = true, added = false) },
services, lanes, status = DiscoverStatus.Ready, reusesFixtureLanes = true)
} }
fun service(service: DiscoverService, type: String): List<DiscoverLane> { fun service(service: DiscoverService, type: String): List<DiscoverLane> {

View file

@ -10,7 +10,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
val result = mutableListOf<DiscoverLane>() val result = mutableListOf<DiscoverLane>()
fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) } fun take(items: List<DiscoverTitle>, limit: Int) = items.filter { matches(it) && it.key !in hidden && it.key !in seen }.take(limit).filter { seen.add(it.key) }
if (snapshot.status == DiscoverStatus.Ready) { if (snapshot.status == DiscoverStatus.Ready) {
val groups = snapshot.seeds.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }.filter { it.items.isNotEmpty() }.take(3) val groups = snapshot.seeds.asSequence().filter { it.watched && it.title.isNotBlank() }
.map { seed -> seed.copy(items = seed.items.filter { matches(it) && it.key !in hidden }) }
.filter { it.items.isNotEmpty() }.take(3).toList()
for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) { for ((type, name) in listOf("movie" to "Movies", "tv" to "Series")) {
val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden } val all = snapshot.picks.filter { it.type == type && matches(it) && it.key !in hidden }
val count = all.count { it.key !in seen } val count = all.count { it.key !in seen }
@ -18,9 +20,9 @@ internal fun projectDiscoverLanes(snapshot: DiscoverSnapshot, heroes: List<Disco
if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all) if (items.isNotEmpty()) result += DiscoverLane("pk-$type", "$name for you", items, all.size, pick = true, fullTitles = all)
} }
val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt()) val size = if (groups.isEmpty()) 0 else minOf(8, ceil(snapshot.picks.count { matches(it) && it.key !in seen && it.key !in hidden }.toDouble() / groups.size).toInt())
groups.forEachIndexed { index, seed -> groups.forEach { seed ->
val items = take(seed.items, size) val items = take(seed.items, size)
if (items.size >= 3) result += DiscoverLane("pk-w$index", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items) if (items.size >= 3) result += DiscoverLane("pk-w-${seed.key}", "Because you watched ${seed.title}", items, seed.items.size, pick = true, fullTitles = seed.items)
} }
} }
val displayed = heroes.mapTo(mutableSetOf()) { it.key } val displayed = heroes.mapTo(mutableSetOf()) { it.key }

View file

@ -26,8 +26,8 @@ data class DiscoverTitle(
/** Observed source marks keep labels separate from score values. */ /** Observed source marks keep labels separate from score values. */
data class DiscoverRating(val source: String, val score: Double, val label: String? = null) data class DiscoverRating(val source: String, val score: Double, val label: String? = null)
/** A contribution group preserves feed order rather than assigning a visible rank. */ /** A typed seed identity and its contribution evidence retain wire order without inventing recency. */
data class DiscoverSeed(val title: String, val items: List<DiscoverTitle>) data class DiscoverSeed(val key: String, val title: String, val items: List<DiscoverTitle>, val watched: Boolean, val added: Boolean)
/** Services carry their catalogue id and a text wordmark, never third-party logos. */ /** Services carry their catalogue id and a text wordmark, never third-party logos. */
data class DiscoverService(val id: Long, val name: String, val hue: Float) data class DiscoverService(val id: Long, val name: String, val hue: Float)

View file

@ -197,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) {
@Composable @Composable
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))

View file

@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {

View file

@ -169,7 +169,7 @@ class DiscoverState(
fun openTitle(item: DiscoverTitle) { fun openTitle(item: DiscoverTitle) {
title = item; titleButton = 0 title = item; titleButton = 0
if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key) if (isSignedIn && item.ratings.isNotEmpty()) observedRatings.add(item.key)
onPush("discover-title/${item.key}") onPush("title/${android.net.Uri.encode(item.key)}")
} }
fun openCollection(lane: DiscoverLane) { fun openCollection(lane: DiscoverLane) {
var titles = lane.fullTitles.associateBy { it.key } var titles = lane.fullTitles.associateBy { it.key }
@ -237,7 +237,7 @@ class DiscoverState(
/** Card hold/release handling belongs to the shell, including the shared catalogue grid. */ /** Card hold/release handling belongs to the shell, including the shared catalogue grid. */
override fun key(key: ShellKey): Boolean { override fun key(key: ShellKey): Boolean {
if (overlay != null) { overlayKey(key); return true } if (overlay != null) { overlayKey(key); return true }
if (shell.route != "discover" && !shell.route.startsWith("discover-")) return false if (shell.route != "discover" && !shell.route.startsWith("discover-") && !shell.route.startsWith("title/")) return false
if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true } if (snapshot.status == DiscoverStatus.Loading) { shell.focusNav(shell.currentTab); return true }
if (title == null) collection?.let { if (title == null) collection?.let {
if (key == ShellKey.Back) { collection = null; onPop(); return true } if (key == ShellKey.Back) { collection = null; onPop(); return true }

View file

@ -15,12 +15,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */ /** Only transforms animate: focus never changes measured geometry. */
@ -34,12 +34,12 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
return graphicsLayer { scaleX = animated; scaleY = animated } return graphicsLayer { scaleX = animated; scaleY = animated }
} }
/** Content lifts are distinct from the white person/gear rings. */ /** Lift shadows remain specific to cards and buttons. */
enum class FocusLift { None, Card, Button } enum class FocusLift { None, Card, Button }
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */ /** Every focus ring straddles the surface edge above content, without a background gap. */
@Composable @Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier { fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
val shadow = if (lift == FocusLift.None) this else { val shadow = if (lift == FocusLift.None) this else {
@ -54,12 +54,15 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
} }
} }
return shadow.drawBehind { return shadow.drawWithCache {
if (ringAlpha > 0f) { val stroke = Stroke(width.toPx())
val outer = (gap + width).toPx() val corner = CornerRadius(radius.toPx())
val inner = gap.toPx() onDrawWithContent {
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer)) drawContent()
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner)) if (ringAlpha > 0f) {
drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha),
cornerRadius = corner, style = stroke)
}
} }
} }
} }

View file

@ -91,7 +91,7 @@ class LibraryGridState(
var inRail by mutableStateOf(false) var inRail by mutableStateOf(false)
var railLetter by mutableStateOf('#') var railLetter by mutableStateOf('#')
private var railReturn = 0 private var railReturn = 0
val columns = WolfskinTokens.Behaviour.gridColumns.toInt() val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0
val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" } val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" }
val hasToolbar: Boolean get() = !catalogue || mixed val hasToolbar: Boolean get() = !catalogue || mixed
val items: List<LibraryTitle> by derivedStateOf { val items: List<LibraryTitle> by derivedStateOf {

View file

@ -41,8 +41,8 @@ fun LibraryPoster(
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
val progress = title.progress val progress = title.progress
if (progress > 0f && progress < 1f) { if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) { .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
} }
} }

View file

@ -88,13 +88,13 @@ fun LibraryScreen(
} }
val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1 val headerCount = if (grid.hasToolbar && grid.status != LibraryStatus.Loading && grid.status != LibraryStatus.Unavailable) 2 else 1
val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap val rowHeight = K.posterH + K.posterCaptionBand + L.gridRowGap
LaunchedEffect(grid.index, grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) { val focusedRow = (if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index).coerceAtLeast(0) / grid.columns
val index = if (grid.inRail) items.indexOfFirst { it.letter == grid.railLetter } else grid.index LaunchedEffect(grid.index, grid.columns, focusedRow, headerCount, rowHeight, density,
val row = index.coerceAtLeast(0) / grid.columns grid.toolbar, grid.inRail, grid.railLetter, shell.navFocused, view) {
if (grid.toolbar || row == 0 && active) { if (grid.toolbar || focusedRow == 0 && active) {
animateLibraryScroll(view, headerCount, 0f, density) animateLibraryScroll(view, headerCount, 0f, density)
} else if (row > 0) { } else if (focusedRow > 0) {
val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * focusedRow - L.pinnedLine).toPx() }
animateLibraryScroll(view, headerCount, target, density) animateLibraryScroll(view, headerCount, target, density)
} }
} }
@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav
@Composable @Composable
private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) {
Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) {
val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All") val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All")
labels.forEachIndexed { index, label -> labels.forEachIndexed { index, label ->
val focused = active && grid.toolbar && grid.tool == index val focused = active && grid.toolbar && grid.tool == index
@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int,
val current = with(density) { val current = with(density) {
(if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead + (if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead +
if (first < headerCount) WolfskinTokens.Shell.zero else if (first < headerCount) WolfskinTokens.Shell.zero else
(if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx()
} + view.listState.firstVisibleItemScrollOffset } + view.listState.firstVisibleItemScrollOffset
animateFrameScroll(view.listState, target - current) animateFrameScroll(view.listState, target - current)
} }

View file

@ -71,13 +71,12 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
val height by animateFloatAsState((box.height + pad * 2).value, spring(damping, stiffness), label = "blob-height") 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 color by animateColorAsState(if (state.navFocused) C.fg else C.navBlobRest, spring(damping, stiffness), label = "blob-color")
val density = LocalDensity.current.density val density = LocalDensity.current.density
val band = remember(scrolled) { val band = remember {
if (scrolled) Brush.verticalGradient(0f to C.bg, S.navSolidStop to C.bg, 1f to C.bg.copy(alpha = 0f)) Brush.verticalGradient(listOf(C.bg.copy(alpha = S.navBandOpacity), 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)) { Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) { Canvas(Modifier.matchParentSize()) {
drawRect(band) if (scrolled) drawRect(C.bg) else drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2)) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
} }
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop)) Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
@ -112,7 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
val avatar = boxes.last() val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top) Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
.focusProperties { canFocus = false } .focusProperties { canFocus = false }
.clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) })
} }

View file

@ -52,9 +52,9 @@ val SETTINGS_GROUPS = listOf(
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"), SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
) )
/** The household request count shares the sheet's snapshot and excludes Ready. */ /** The count describes returned request history, not an inferred queue of active downloads. */
@Composable @Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) { fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
val list = state.view.listState val list = state.view.listState
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) { LaunchedEffect(state.settingsRow, state.navFocused) {
@ -78,7 +78,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group -> itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) { Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow, SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") openRequests else null, if (group.key == "requests") requestCount else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) { Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok) state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
} }
@ -89,7 +89,7 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
/** The inverse focus fill has no focus ring and never changes row layout. */ /** The inverse focus fill has no focus ring and never changes row layout. */
@Composable @Composable
fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) { fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modifier: Modifier = Modifier, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg val foreground = if (focused) C.onFocus else C.fg
Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow) Row(modifier.size(L.settingsRowWidth, L.settingsRowHeight).focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
@ -97,7 +97,7 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon)) Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
val count = if (openRequests != null && openRequests > 0) " · $openRequests open" else "" val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1) Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }

View file

@ -11,7 +11,7 @@ data class ShellPerson(val key: String, val name: String, val initial: String, v
/** The shell owns keys; the integration owns navigation and authenticated user changes. */ /** The shell owns keys; the integration owns navigation and authenticated user changes. */
sealed interface ShellAction { sealed interface ShellAction {
data class Destination(val route: String) : ShellAction data class Destination(val route: String, val preservePushed: Boolean = false) : ShellAction
data class Person(val person: ShellPerson) : ShellAction data class Person(val person: ShellPerson) : ShellAction
data class HandOff(val group: String) : ShellAction data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction data object Pop : ShellAction
@ -50,14 +50,23 @@ class ShellState(
var people by mutableStateOf(people) var people by mutableStateOf(people)
private set private set
/** Refresh household labels without discarding this person's navigation memory. */ /** Refresh household data without losing valid nav focus or this person's view memory. */
fun updatePeople(value: List<ShellPerson>) { fun updatePeople(value: List<ShellPerson>) {
val focusedTab = tabs.getOrNull(navIndex)
val avatarFocused = navIndex == tabs.size
val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId } val selected = value.firstOrNull { it.jellyfinUserId != null && it.jellyfinUserId == person.jellyfinUserId }
?: value.firstOrNull { it.key == person.key } ?: value.firstOrNull { it.key == person.key }
people = value people = value
if (selected != null) person = selected if (selected != null) person = selected
pickerIndex = people.indexOf(person).coerceAtLeast(0) pickerIndex = people.indexOf(person).coerceAtLeast(0)
val removedRoot = rootRoute !in tabs
if (removedRoot) {
if (route == rootRoute) route = "home"
rootRoute = "home"
}
preparePersonMemory() preparePersonMemory()
navIndex = if (avatarFocused) tabs.size else tabs.indexOf(focusedTab).takeIf { it >= 0 } ?: currentTab
if (removedRoot) onAction(ShellAction.Destination("home", preservePushed = true))
} }
var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first()) var person by mutableStateOf(people.firstOrNull { it.key == initialPerson } ?: people.first())
private set private set

View file

@ -55,7 +55,7 @@ fun ShellSurface(
state: ShellState, state: ShellState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true, handlesContentKeys: Boolean = true,
settingsOpenRequests: Int? = null, settingsRequestCount: Int? = null,
overlay: @Composable () -> Unit = {}, overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) }, content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) { ) {
@ -91,7 +91,7 @@ fun ShellSurface(
if (state.pickerVisible) { if (state.pickerVisible) {
WhosWatching(state) WhosWatching(state)
} else { } else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup()) if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2) NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state) if (state.requestsVisible) ComingInSheet(state)
overlay() overlay()

View file

@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs
height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card) val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) { .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) {
Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content)
} }
} }

View file

@ -37,7 +37,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.background(background, shape) .background(background, shape)
.then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions), .clickable { focusOwner?.invoke(); onClick() }.then(dimensions),

View file

@ -51,9 +51,10 @@ fun TitleCast(state: TitleState, active: Boolean) {
val focusOwner = LocalTitleFocusOwner.current val focusOwner = LocalTitleFocusOwner.current
val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) }
Column(Modifier.height(D.castHeight)) { Box(Modifier.height(D.castHeight)) {
Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) {
if (state.data.castStatus == TitleReadStatus.Loading) { if (state.data.castStatus == TitleReadStatus.Loading) {
items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) Box(Modifier.size(L.castCircle).background(C.surface, CircleShape))
@ -63,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape)) PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardRing, FocusLift.Card).clip(CircleShape))
Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) {
Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center)
@ -85,9 +86,11 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots
val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() }
LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) }
Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) {
Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand))
LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
.height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2),
contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) {
items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) {
Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
@ -112,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleTileArtwork(item) TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))

View file

@ -46,7 +46,7 @@ private fun PeoplePicker(state: ShellState) {
state.focusPerson(index); state.key(ShellKey.Ok) state.focusPerson(index); state.key(ShellKey.Ok)
}, horizontalAlignment = Alignment.CenterHorizontally) { }, horizontalAlignment = Alignment.CenterHorizontally) {
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg)) Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
Spacer(Modifier.height(S.pickerNameGap)) Spacer(Modifier.height(S.pickerNameGap))
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName) Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
} }
@ -55,7 +55,7 @@ private fun PeoplePicker(state: ShellState) {
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) 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 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) 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) .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing)
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { .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)) Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
if (state.gearFocused) { if (state.gearFocused) {
@ -106,7 +106,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {

View file

@ -112,9 +112,9 @@ object WolfskinTokens {
val navSolidAfter = 20.0.dp val navSolidAfter = 20.0.dp
val pinnedLine = 84.0.dp val pinnedLine = 84.0.dp
val windowAhead = 350.0.dp val windowAhead = 350.0.dp
val rowHeadingBand = 30.0.dp val rowHeadingBand = 42.0.dp
val rowGapAfter = 22.0.dp val rowGapAfter = 34.0.dp
val gridRowGap = 14.0.dp val gridRowGap = 22.0.dp
val row_hero = 410.0.dp val row_hero = 410.0.dp
val row_phero = 370.0.dp val row_phero = 370.0.dp
val row_section = 75.0.dp val row_section = 75.0.dp
@ -146,7 +146,7 @@ object WolfskinTokens {
val settingsRowWidth = 650.0.dp val settingsRowWidth = 650.0.dp
val settingsRowHeight = 52.0.dp val settingsRowHeight = 52.0.dp
val stateWidth = 550.0.dp val stateWidth = 550.0.dp
val captionOffset = 9.0.dp val captionOffset = 19.0.dp
val badge = 24.0.dp val badge = 24.0.dp
val badgeInset = 7.0.dp val badgeInset = 7.0.dp
val progressBar = 4.0.dp val progressBar = 4.0.dp
@ -159,36 +159,37 @@ object WolfskinTokens {
val personPortrait = 120.0.dp val personPortrait = 120.0.dp
val thumbWidth = 48.0.dp val thumbWidth = 48.0.dp
val thumbHeight = 72.0.dp val thumbHeight = 72.0.dp
val row_chipsGrid = 68.0.dp
} }
object Card { object Card {
val posterW = 128.0.dp val posterW = 128.0.dp
val posterH = 192.0.dp val posterH = 192.0.dp
val posterGap = 12.0.dp val posterGap = 20.0.dp
val posterPitch = 140.0.dp val posterPitch = 148.0.dp
val posterCaptionBand = 38.0.dp val posterCaptionBand = 48.0.dp
const val posterSlots = 6 const val posterSlots = 6
val tileW = 208.0.dp val tileW = 200.0.dp
val tileH = 117.0.dp val tileH = 112.5.dp
val tileGap = 12.0.dp val tileGap = 20.0.dp
val tilePitch = 220.0.dp val tilePitch = 220.0.dp
val tileCaptionBand = 0.0.dp val tileCaptionBand = 0.0.dp
const val tileSlots = 4 const val tileSlots = 4
val wideW = 280.0.dp val wideW = 272.0.dp
val wideH = 157.5.dp val wideH = 153.0.dp
val wideGap = 12.0.dp val wideGap = 20.0.dp
val widePitch = 292.0.dp val widePitch = 292.0.dp
val wideCaptionBand = 0.0.dp val wideCaptionBand = 0.0.dp
const val wideSlots = 3 const val wideSlots = 3
val entryW = 128.0.dp val entryW = 128.0.dp
val entryH = 72.0.dp val entryH = 72.0.dp
val entryGap = 12.0.dp val entryGap = 20.0.dp
val entryPitch = 140.0.dp val entryPitch = 148.0.dp
val entryCaptionBand = 0.0.dp val entryCaptionBand = 0.0.dp
const val entrySlots = 6 const val entrySlots = 6
val personW = 100.0.dp val personW = 100.0.dp
val personH = 145.0.dp val personH = 155.0.dp
val personGap = 14.0.dp val personGap = 22.0.dp
val personPitch = 114.0.dp val personPitch = 122.0.dp
val personCaptionBand = 0.0.dp val personCaptionBand = 0.0.dp
const val personSlots = 7 const val personSlots = 7
} }
@ -196,14 +197,14 @@ object WolfskinTokens {
const val scaleCard = 1.08f const val scaleCard = 1.08f
const val scaleControl = 1.05f const val scaleControl = 1.05f
const val scaleRow = 1.02f const val scaleRow = 1.02f
val ringCardGap = 2.0.dp val ringCardGap = 0.0.dp
val ringCardRing = 2.0.dp val ringCardRing = 2.0.dp
val ringButtonGap = 2.0.dp val ringControlGap = 0.0.dp
val ringButtonRing = 2.0.dp val ringControlRing = 2.0.dp
val ringAvatarGap = 2.0.dp val ringAvatarGap = 0.0.dp
val ringAvatarRing = 2.0.dp val ringAvatarRing = 2.0.dp
val ringPickerGap = 3.0.dp val ringPickerGap = 0.0.dp
val ringPickerRing = 3.0.dp val ringPickerRing = 2.0.dp
const val fillInverse = "background color.fg, text color.onFocus" const val fillInverse = "background color.fg, text color.onFocus"
} }
object Motion { object Motion {
@ -429,8 +430,8 @@ object WolfskinTokens {
val progressBottom = 15.0.dp val progressBottom = 15.0.dp
val progressGap = 9.0.dp val progressGap = 9.0.dp
val progressLift = -7.0.dp val progressLift = -7.0.dp
val castHeight = 197.0.dp val castHeight = 231.0.dp
val castNameTop = 108.0.dp val castNameTop = 118.0.dp
val castNameSide = 6.0.dp val castNameSide = 6.0.dp
val personTop = 85.0.dp val personTop = 85.0.dp
val personGap = 24.0.dp val personGap = 24.0.dp
@ -456,7 +457,7 @@ object WolfskinTokens {
val infoRowGap = 3.0.dp val infoRowGap = 3.0.dp
val infoColumnGap = 10.0.dp val infoColumnGap = 10.0.dp
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val gridHeight = 244.0.dp val gridHeight = 262.0.dp
val minimumEndPadding = 40.0.dp val minimumEndPadding = 40.0.dp
val ratingGap = 10.0.dp val ratingGap = 10.0.dp
val ratingMarkGap = 4.0.dp val ratingMarkGap = 4.0.dp

View file

@ -90,6 +90,7 @@ class DiscoverCaptureTest {
} }
@Test fun st029_discover() = capture("st-029") @Test fun st029_discover() = capture("st-029")
@Test fun focusFilterChip() = capture("st-120") { it.focus(1, 0) }
@Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) }
@Test fun st031_hidden() = capture("st-031") { hidden(it) } @Test fun st031_hidden() = capture("st-031") { hidden(it) }
@Test fun st032_restore() { @Test fun st032_restore() {

View file

@ -36,7 +36,7 @@ class DiscoverKeysTest {
rule.setContent { rule.setContent {
WolfskinTheme { WolfskinTheme {
ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier -> ShellSurface(shell, overlay = { DiscoverOverlay(state) }) { modifier ->
if (shell.route == "discover" || shell.route.startsWith("discover-")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier) if (shell.route == "discover" || shell.route.startsWith("discover-") || shell.route.startsWith("title/")) DiscoverScreen(state, modifier) else ShellPlaceholder(shell.route, modifier)
} }
} }
} }

View file

@ -8,7 +8,7 @@ import org.junit.Test
class DiscoverLaneProjectionTest { class DiscoverLaneProjectionTest {
@Test fun discardedSeedGroupRemainsAvailableInCatalogue() { @Test fun discardedSeedGroupRemainsAvailableInCatalogue() {
val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") } val titles = (1L..6L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("Seed", titles.subList(3, 5))), val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(DiscoverSeed("movie:10", "Seed", titles.subList(3, 5), watched = true, added = false)),
lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready) lanes = listOf(DiscoverLane("trending", "Trending", titles)), status = DiscoverStatus.Ready)
val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap()) val rows = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "trending"), rows.map { it.id }) assertEquals(listOf("pk-movie", "trending"), rows.map { it.id })
@ -30,4 +30,25 @@ class DiscoverLaneProjectionTest {
val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden)) val filtered = projectDiscoverLanes(snapshot, emptyList(), "tv", mapOf(hidden.key to hidden))
assertEquals(listOf(listOf(series)), filtered.map { it.titles }) assertEquals(listOf(listOf(series)), filtered.map { it.titles })
} }
@Test fun addedOnlySeedsKeepTheirPicksWithoutClaimingTheyWereWatched() {
val titles = (1L..9L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
DiscoverSeed("movie:10", "Added seed", titles, watched = false, added = true)), status = DiscoverStatus.Ready)
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("Movies for you"), lanes.map { it.title })
assertEquals(titles, lanes.single().fullTitles)
}
@Test fun watchedLanesKeepWireOrderAndStableIdentityWhenTitlesCollide() {
val titles = (1L..24L).map { DiscoverTitle(it, "movie", "Film $it") }
val snapshot = DiscoverSnapshot(picks = titles, seeds = listOf(
DiscoverSeed("movie:50", "Added seed", titles.take(8), watched = false, added = true),
DiscoverSeed("tv:70", "Same title", titles.subList(8, 16), watched = true, added = true),
DiscoverSeed("movie:70", "Same title", titles.subList(16, 24), watched = true, added = false)),
status = DiscoverStatus.Ready)
val lanes = projectDiscoverLanes(snapshot, emptyList(), "all", emptyMap())
assertEquals(listOf("pk-movie", "pk-w-tv:70", "pk-w-movie:70"), lanes.map { it.id })
assertEquals(listOf(titles.subList(8, 16), titles.subList(16, 24)), lanes.drop(1).map { it.titles })
}
} }

View file

@ -0,0 +1,84 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertTrue
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
/** Opaque content must not cover either half of the shared accent focus stroke. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class FocusRingTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus() {
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
val focused = mutableStateOf(true)
val radius = mutableStateOf(WolfskinTokens.Radius.button)
rule.setContent {
Box(Modifier.requiredSize(120.dp).background(WolfskinTokens.Color.bg)) {
Box(Modifier.offset(40.dp, 40.dp).size(60.dp)
.focusRing(focused.value, radius.value, WolfskinTokens.Focus.ringControlRing)
.background(WolfskinTokens.Color.surface, RoundedCornerShape(radius.value)))
}
}
val file = File.createTempFile("focus-ring-", ".png")
try {
for (corner in listOf(WolfskinTokens.Radius.button, 30.dp, WolfskinTokens.Radius.card)) {
rule.runOnIdle { radius.value = corner }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
for ((x, expected) in listOf(77 to WolfskinTokens.Color.bg, 78 to WolfskinTokens.Color.accent,
79 to WolfskinTokens.Color.accent, 80 to WolfskinTokens.Color.accent,
81 to WolfskinTokens.Color.accent, 82 to WolfskinTokens.Color.surface)) {
assertPixel("Left edge x=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(x, 140))
assertPixel("Top edge y=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(140, x))
}
bitmap.recycle()
}
rule.runOnIdle { focused.value = false }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
assertEquals(WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(79, 140))
assertEquals(WolfskinTokens.Color.surface.toArgb(), bitmap.getPixel(80, 140))
bitmap.recycle()
} finally { file.delete() }
}
private fun assertPixel(label: String, expected: Int, actual: Int) {
// Circular edge coverage can differ by a few channel values at pixel centres.
for (shift in listOf(0, 8, 16, 24)) {
val difference = kotlin.math.abs((expected ushr shift and 255) - (actual ushr shift and 255))
assertTrue("$label: channel $shift differs by $difference", difference <= 8)
}
}
}

View file

@ -115,5 +115,7 @@ class HomeCaptureTest {
@Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2)
@Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4)
@Test fun st103_heroRatings() = capture("st-103") @Test fun st103_heroRatings() = capture("st-103")
@Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu")
@Test fun focusHeroDetails() = capture("focus-hero-details", column = 1)
@Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m")
} }

View file

@ -82,6 +82,15 @@ class LibraryCaptureTest {
@Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true) @Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true)
@Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false)
@Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false)
@Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows")
@Test fun focusToolbarChip() = capture("focus-toolbar-chip") { it.toolbar = true; it.tool = 0 }
@Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid ->
val original = grid.source
grid.source = List(200) { index ->
original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index")
}
grid.index = 4
}
} }
fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/") fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/")

View file

@ -112,18 +112,25 @@ class LibraryKeysTest {
val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") } val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") }
val grid = LibraryGridState("Movies", titles) val grid = LibraryGridState("Movies", titles)
assertTrue(grid.railVisible) assertTrue(grid.railVisible)
grid.index = 5 grid.index = 4
grid.key(ShellKey.Right) grid.key(ShellKey.Right)
assertTrue(grid.inRail) assertTrue(grid.inRail)
grid.key(ShellKey.Down) grid.key(ShellKey.Down)
assertEquals('B', grid.railLetter) assertEquals('B', grid.railLetter)
grid.key(ShellKey.Back) grid.key(ShellKey.Back)
assertEquals(5, grid.index) assertEquals(4, grid.index)
assertFalse(grid.inRail) assertFalse(grid.inRail)
grid.key(ShellKey.Down)
assertEquals(9, grid.index)
grid.key(ShellKey.Up)
assertEquals(4, grid.index)
grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok) grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok)
assertEquals('B', grid.focusedTitle!!.letter) assertEquals('B', grid.focusedTitle!!.letter)
grid.source = titles.take(199) grid.source = titles.take(199)
assertFalse(grid.railVisible) assertFalse(grid.railVisible)
grid.index = 4
grid.key(ShellKey.Right)
assertEquals(5, grid.index)
grid.source = titles; grid.sort = 1 grid.source = titles; grid.sort = 1
assertFalse(grid.railVisible) assertFalse(grid.railVisible)
} }

View file

@ -0,0 +1,53 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
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
/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class LibrarySpacingRegressionTest {
@get:Rule val rule = createComposeRule()
@Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() {
val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f)
rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } }
val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot
val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot
assertEquals(256f, poster.width, .1f)
assertEquals(220f, track.width, .1f)
assertEquals(18f, track.left - poster.left, .1f)
assertEquals(18f, poster.right - track.right, .1f)
}
@Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() {
val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") }
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") }
val grid = LibraryGridState("Shows", titles.take(199)).apply { index = 198 }
rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it) } } }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val poster = rule.onNodeWithTag("poster-198")
poster.assertIsDisplayed()
val original = poster.fetchSemanticsNode().boundsInRoot
for (count in listOf(200, 199)) {
rule.runOnIdle { grid.source = titles.take(count) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
poster.assertIsDisplayed()
val current = poster.fetchSemanticsNode().boundsInRoot
assertEquals(198, grid.index)
assertEquals("198", grid.focusedTitle!!.id)
assertEquals(original.top, current.top, 1f)
assertEquals(original.height, current.height, 1f)
}
}
}

View file

@ -53,7 +53,6 @@ class RequestsKeysTest {
assertEquals(path.getInt("presses"), presses.length()) assertEquals(path.getInt("presses"), presses.length())
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index))) for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
rule.onNodeWithTag("coming-in").assertExists() rule.onNodeWithTag("coming-in").assertExists()
rule.onNodeWithText("Requests · 5 open").assertExists()
assertEquals("settings", state.route) assertEquals("settings", state.route)
assertEquals("x5", state.requests.focusedId) assertEquals("x5", state.requests.focusedId)
assertFalse(state.navFocused) assertFalse(state.navFocused)
@ -144,7 +143,5 @@ class RequestsKeysTest {
val viewer = fixturePeople().single { it.key == "shared" } val viewer = fixturePeople().single { it.key == "shared" }
assertEquals("Shared", shared.actor(viewer)) assertEquals("Shared", shared.actor(viewer))
assertEquals("Justin", shared.recipient(viewer)) assertEquals("Justin", shared.recipient(viewer))
assertEquals(5, snapshot.openCount)
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
} }
} }

View file

@ -38,12 +38,12 @@ class ShellCaptureTest {
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY 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 = {}) { private fun capture(name: String, requestCount: Int? = null, configure: (ShellState) -> Unit = {}) {
val state = ShellState(fixturePeople(), "justin") val state = ShellState(fixturePeople(), "justin")
configure(state) configure(state)
rule.setContent { rule.setContent {
WolfskinTheme { WolfskinTheme {
ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsOpenRequests = openRequests) ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight), settingsRequestCount = requestCount)
} }
} }
rule.mainClock.advanceTimeBy(1000) rule.mainClock.advanceTimeBy(1000)
@ -54,6 +54,7 @@ class ShellCaptureTest {
} }
@Test fun st001_launch() = capture("st-001") @Test fun st001_launch() = capture("st-001")
@Test fun focusNavAvatar() = capture("focus-nav-avatar") { it.pickerVisible = false; it.focusNav(it.tabs.size) }
@Test fun st002_gear() { @Test fun st002_gear() {
capture("st-002") { it.pickerIndex = 2; it.gearFocused = true } capture("st-002") { it.pickerIndex = 2; it.gearFocused = true }
val layouts = mutableListOf<TextLayoutResult>() val layouts = mutableListOf<TextLayoutResult>()
@ -66,7 +67,7 @@ class ShellCaptureTest {
@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 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 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 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 st092_requestsCountFixture() = capture("st-092", requestCount = 5) { it.pickerVisible = false; it.enterDestination("settings") }
@Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 } @Test fun st093_profileFocus() = capture("st-093") { it.pickerVisible = false; it.enterDestination("settings"); it.settingsRow = 4 }
} }

View file

@ -1,6 +1,6 @@
{ {
"id": "library", "id": "library",
"title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)", "title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)",
"specSection": "6.4", "specSection": "6.4",
"source": "index.html:1403-1426, 1563-1611", "source": "index.html:1403-1426, 1563-1611",
"layout": [ "layout": [
@ -22,7 +22,7 @@
"token": null, "token": null,
"title": "", "title": "",
"yPx": 236, "yPx": 236,
"heightPx": 104, "heightPx": 136,
"cards": null, "cards": null,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -33,8 +33,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 340, "yPx": 372,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -45,8 +45,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 828, "yPx": 896,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -57,8 +57,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 1316, "yPx": 1420,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -69,8 +69,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 1804, "yPx": 1944,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -81,8 +81,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 2292, "yPx": 2468,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -93,8 +93,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 2780, "yPx": 2992,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -105,8 +105,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 3268, "yPx": 3516,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -117,8 +117,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 3756, "yPx": 4040,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -129,8 +129,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 4244, "yPx": 4564,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -141,8 +141,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 4732, "yPx": 5088,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -153,8 +153,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 5220, "yPx": 5612,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -165,8 +165,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 5708, "yPx": 6136,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -177,8 +177,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 6196, "yPx": 6660,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,
@ -189,8 +189,8 @@
"type": "grid", "type": "grid",
"token": "poster", "token": "poster",
"title": "", "title": "",
"yPx": 6684, "yPx": 7184,
"heightPx": 488, "heightPx": 524,
"cards": 6, "cards": 6,
"focusable": true, "focusable": true,
"endsInSeeAll": false, "endsInSeeAll": false,

View file

@ -14,8 +14,8 @@
"progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"}, "progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"},
"progressGap": {"dp": 9, "px": 18, "source": "index.html:217"}, "progressGap": {"dp": 9, "px": 18, "source": "index.html:217"},
"progressLift": {"dp": -7, "px": -14, "source": "index.html:217"}, "progressLift": {"dp": -7, "px": -14, "source": "index.html:217"},
"castHeight": {"dp": 197, "px": 394, "source": "index.html:1018"}, "castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"},
"castNameTop": {"dp": 108, "px": 216, "source": "index.html:289"}, "castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"},
"castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"}, "castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"},
"personTop": {"dp": 85, "px": 170, "source": "index.html:293"}, "personTop": {"dp": 85, "px": 170, "source": "index.html:293"},
"personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, "personGap": {"dp": 24, "px": 48, "source": "index.html:293"},
@ -41,7 +41,7 @@
"infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"}, "infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"},
"infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"}, "infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"},
"infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"}, "infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"},
"gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"}, "gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"},
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"}, "minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"}, "ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"}, "ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},

View file

@ -81,9 +81,9 @@
--layout-navSolidAfter: 40.0px; --layout-navSolidAfter: 40.0px;
--layout-pinnedLine: 168.0px; --layout-pinnedLine: 168.0px;
--layout-windowAhead: 700.0px; --layout-windowAhead: 700.0px;
--layout-rowHeadingBand: 60.0px; --layout-rowHeadingBand: 84.0px;
--layout-rowGapAfter: 44.0px; --layout-rowGapAfter: 68.0px;
--layout-gridRowGap: 28.0px; --layout-gridRowGap: 44.0px;
--layout-row_hero: 820.0px; --layout-row_hero: 820.0px;
--layout-row_phero: 740.0px; --layout-row_phero: 740.0px;
--layout-row_section: 150.0px; --layout-row_section: 150.0px;
@ -111,7 +111,7 @@
--layout-sheetWidth: 800.0px; --layout-sheetWidth: 800.0px;
--layout-sheetRow: 100.0px; --layout-sheetRow: 100.0px;
--layout-stateWidth: 1100.0px; --layout-stateWidth: 1100.0px;
--layout-captionOffset: 18.0px; --layout-captionOffset: 38.0px;
--layout-badge: 48.0px; --layout-badge: 48.0px;
--layout-badgeInset: 14.0px; --layout-badgeInset: 14.0px;
--layout-progressBar: 8.0px; --layout-progressBar: 8.0px;
@ -122,6 +122,7 @@
--layout-navAvatar: 64.0px; --layout-navAvatar: 64.0px;
--layout-castCircle: 200.0px; --layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px; --layout-personPortrait: 240.0px;
--layout-row_chipsGrid: 136.0px;
--motion-fast: 140ms; --motion-fast: 140ms;
--motion-base: 160ms; --motion-base: 160ms;
--motion-scroll: 180ms; --motion-scroll: 180ms;
@ -273,8 +274,8 @@
--title-progressBottom: 30.0px; --title-progressBottom: 30.0px;
--title-progressGap: 18.0px; --title-progressGap: 18.0px;
--title-progressLift: -14.0px; --title-progressLift: -14.0px;
--title-castHeight: 394.0px; --title-castHeight: 462.0px;
--title-castNameTop: 216.0px; --title-castNameTop: 236.0px;
--title-castNameSide: 12.0px; --title-castNameSide: 12.0px;
--title-personTop: 170.0px; --title-personTop: 170.0px;
--title-personGap: 48.0px; --title-personGap: 48.0px;
@ -300,7 +301,7 @@
--title-infoRowGap: 6.0px; --title-infoRowGap: 6.0px;
--title-infoColumnGap: 20.0px; --title-infoColumnGap: 20.0px;
--title-infoKey: 24.0px; --title-infoKey: 24.0px;
--title-gridHeight: 488.0px; --title-gridHeight: 524.0px;
--title-minimumEndPadding: 80.0px; --title-minimumEndPadding: 80.0px;
--title-ratingGap: 20.0px; --title-ratingGap: 20.0px;
--title-ratingMarkGap: 8.0px; --title-ratingMarkGap: 8.0px;

View file

@ -1,5 +1,5 @@
{ {
"$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it).", "$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.",
"meta": { "meta": {
"density": 2.0, "density": 2.0,
"stagePx": [ "stagePx": [
@ -10,8 +10,8 @@
960, 960,
540 540
], ],
"source": "data/tv-v2/opus-final-2026-11-05/index.html", "source": "data/tv-v2/focus-2026-11-06/index.html",
"sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d", "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py"
}, },
"color": { "color": {
@ -20,7 +20,7 @@
"$value": "#07070a", "$value": "#07070a",
"css": "#07070a", "css": "#07070a",
"source": "index.html:10", "source": "index.html:10",
"usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid" "usedBy": "app background; stage; hero/detail scrim end; nav band when solid"
}, },
"surface": { "surface": {
"$type": "color", "$type": "color",
@ -773,27 +773,27 @@
}, },
"rowHeadingBand": { "rowHeadingBand": {
"$type": "dimension", "$type": "dimension",
"$value": "30dp", "$value": "42dp",
"px": 60, "px": 84,
"dp": 30.0, "dp": 42.0,
"source": "index.html:1018", "source": "index.html:1018",
"usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60" "usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)"
}, },
"rowGapAfter": { "rowGapAfter": {
"$type": "dimension",
"$value": "34dp",
"px": 68,
"dp": 34.0,
"source": "index.html:1018",
"usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68"
},
"gridRowGap": {
"$type": "dimension", "$type": "dimension",
"$value": "22dp", "$value": "22dp",
"px": 44, "px": 44,
"dp": 22.0, "dp": 22.0,
"source": "index.html:1018",
"usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 60 + card h + caption + 44"
},
"gridRowGap": {
"$type": "dimension",
"$value": "14dp",
"px": 28,
"dp": 14.0,
"source": "index.html:1019", "source": "index.html:1019",
"usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)" "usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)"
}, },
"row_hero": { "row_hero": {
"$type": "dimension", "$type": "dimension",
@ -825,7 +825,7 @@
"px": 104, "px": 104,
"dp": 52.0, "dp": 52.0,
"source": "index.html:1021", "source": "index.html:1021",
"usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)" "usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px"
}, },
"row_note": { "row_note": {
"$type": "dimension", "$type": "dimension",
@ -1041,10 +1041,10 @@
}, },
"captionOffset": { "captionOffset": {
"$type": "dimension", "$type": "dimension",
"px": 18, "px": 38,
"dp": 9, "dp": 19,
"source": "index.html:1242", "source": "index.html:1242",
"usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640" "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640"
}, },
"plateInset": { "plateInset": {
"$type": "dimension", "$type": "dimension",
@ -1171,6 +1171,14 @@
}, },
"source": "index.html:204", "source": "index.html:204",
"usedBy": "letter rail (only for destinations with >= 200 titles)" "usedBy": "letter rail (only for destinations with >= 200 titles)"
},
"row_chipsGrid": {
"$type": "dimension",
"$value": "68dp",
"px": 136,
"dp": 68.0,
"source": "index.html:rowH",
"usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)"
} }
}, },
"card": { "card": {
@ -1179,16 +1187,16 @@
"px": { "px": {
"w": 256, "w": 256,
"h": 384, "h": 384,
"gap": 24, "gap": 40,
"pitch": 280, "pitch": 296,
"captionBand": 76 "captionBand": 96
}, },
"dp": { "dp": {
"w": 128.0, "w": 128.0,
"h": 192.0, "h": 192.0,
"gap": 12.0, "gap": 20.0,
"pitch": 140.0, "pitch": 148.0,
"captionBand": 38.0 "captionBand": 48.0
}, },
"ratio": "2:3", "ratio": "2:3",
"fullSlotsPerRow": 6, "fullSlotsPerRow": 6,
@ -1198,16 +1206,16 @@
"tile": { "tile": {
"$type": "card", "$type": "card",
"px": { "px": {
"w": 416, "w": 400,
"h": 234, "h": 225,
"gap": 24, "gap": 40,
"pitch": 440, "pitch": 440,
"captionBand": 0 "captionBand": 0
}, },
"dp": { "dp": {
"w": 208.0, "w": 200.0,
"h": 117.0, "h": 112.5,
"gap": 12.0, "gap": 20.0,
"pitch": 220.0, "pitch": 220.0,
"captionBand": 0.0 "captionBand": 0.0
}, },
@ -1219,16 +1227,16 @@
"wide": { "wide": {
"$type": "card", "$type": "card",
"px": { "px": {
"w": 560, "w": 544,
"h": 315, "h": 306,
"gap": 24, "gap": 40,
"pitch": 584, "pitch": 584,
"captionBand": 0 "captionBand": 0
}, },
"dp": { "dp": {
"w": 280.0, "w": 272.0,
"h": 157.5, "h": 153.0,
"gap": 12.0, "gap": 20.0,
"pitch": 292.0, "pitch": 292.0,
"captionBand": 0.0 "captionBand": 0.0
}, },
@ -1242,15 +1250,15 @@
"px": { "px": {
"w": 256, "w": 256,
"h": 144, "h": 144,
"gap": 24, "gap": 40,
"pitch": 280, "pitch": 296,
"captionBand": 0 "captionBand": 0
}, },
"dp": { "dp": {
"w": 128.0, "w": 128.0,
"h": 72.0, "h": 72.0,
"gap": 12.0, "gap": 20.0,
"pitch": 140.0, "pitch": 148.0,
"captionBand": 0.0 "captionBand": 0.0
}, },
"ratio": "16:9", "ratio": "16:9",
@ -1262,22 +1270,22 @@
"$type": "card", "$type": "card",
"px": { "px": {
"w": 200, "w": 200,
"h": 290, "h": 310,
"gap": 28, "gap": 44,
"pitch": 228, "pitch": 244,
"captionBand": 0 "captionBand": 0
}, },
"dp": { "dp": {
"w": 100.0, "w": 100.0,
"h": 145.0, "h": 155.0,
"gap": 14.0, "gap": 22.0,
"pitch": 114.0, "pitch": 122.0,
"captionBand": 0.0 "captionBand": 0.0
}, },
"ratio": "1:1 circle", "ratio": "1:1 circle",
"fullSlotsPerRow": 7, "fullSlotsPerRow": 7,
"source": "index.html:660", "source": "index.html:660",
"usedBy": "Cast row: 200 px circle + name + character, 290 px tall" "usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)"
} }
}, },
"focus": { "focus": {
@ -1318,44 +1326,60 @@
}, },
"ringCard": { "ringCard": {
"$type": "shadow", "$type": "shadow",
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)", "$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)",
"gapPx": 4, "gapPx": 0,
"ringPx": 4, "ringPx": 4,
"gapDp": 2, "gapDp": 0,
"ringDp": 2, "ringDp": 2,
"gapColor": "color.bg",
"ringColor": "color.accent", "ringColor": "color.accent",
"liftShadow": "0 18px 50px rgb(0 0 0 / .65)", "liftShadow": "0 18px 50px rgb(0 0 0 / .65)",
"source": "index.html:134", "source": "index.html:134",
"usedBy": "focused card: a 4 px bg-coloured gap then a 4 px lilac ring OUTSIDE the art (never covers it), plus a lift shadow; drawn on the circle for cast cards" "usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")",
"outsidePx": 2,
"insidePx": 2
}, },
"ringButton": { "ringControl": {
"$type": "shadow", "$type": "shadow",
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 12px 40px rgb(0 0 0 / .5)", "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)",
"gapPx": 4, "gapPx": 0,
"ringPx": 4, "ringPx": 4,
"gapDp": 0,
"ringDp": 2,
"outsidePx": 2,
"insidePx": 2,
"alignment": "straddle",
"ringColor": "color.accent", "ringColor": "color.accent",
"liftShadow": "0 12px 40px rgb(0 0 0 / .5)", "liftShadow": "0 12px 40px rgb(0 0 0 / .5)",
"source": "index.html:110", "source": "index.html:111",
"usedBy": "focused pill and icon buttons (primary stays white, secondary lightens to buttonSecondaryFocused)" "usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged"
}, },
"ringAvatar": { "ringAvatar": {
"$type": "shadow", "$type": "shadow",
"$value": "0 0 0 4px var(--bg),0 0 0 8px var(--fg)", "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)",
"gapPx": 4, "gapPx": 0,
"ringPx": 4, "ringPx": 4,
"ringColor": "color.fg", "gapDp": 0,
"source": "index.html:92", "ringDp": 2,
"usedBy": "focused nav avatar and focused gear (white ring, not lilac)" "outsidePx": 2,
"insidePx": 2,
"alignment": "straddle",
"ringColor": "color.accent",
"source": "index.html:92,312",
"usedBy": "focused nav avatar and gear; existing scale unchanged, no lift"
}, },
"ringPicker": { "ringPicker": {
"$type": "shadow", "$type": "shadow",
"$value": "0 0 0 6px var(--bg),0 0 0 12px var(--fg)", "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)",
"gapPx": 6, "gapPx": 0,
"ringPx": 6, "ringPx": 4,
"ringColor": "color.fg", "gapDp": 0,
"source": "index.html:263", "ringDp": 2,
"usedBy": "focused person in Who's watching (white 6 + 6 ring; name turns fg)" "outsidePx": 2,
"insidePx": 2,
"alignment": "straddle",
"ringColor": "color.accent",
"source": "index.html:265",
"usedBy": "focused person in Who's watching; scale 1.08, name turns fg, no lift"
}, },
"fillInverse": { "fillInverse": {
"$type": "rule", "$type": "rule",
@ -1589,4 +1613,4 @@
"usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus" "usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus"
} }
} }
} }

View file

@ -2,7 +2,7 @@
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json 8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json
3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json