Compare commits

..

16 commits

Author SHA1 Message Date
54c23e076a Merge pull request 'Fix TV artwork, episode layout and focus visibility' (#47) from fm/tv-fb1-visual into main 2026-11-06 22:04:17 +01:00
12fd12f754 Merge remote-tracking branch 'origin/main' into fm/tv-fb1-visual
All checks were successful
review/code-gpt Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner
review/design-claude Firstmate read the diff and evidence (native before/after frames, hero decode proof, episode art chain, gear centered per owner answer); apk-debug green; full serial suite NOT green (4 PersonPickerSessionTest timeouts, cause unisolated, disclosed in the PR body), accepted by firstmate with real-box person-switch verification before anything ships to the owner
2026-11-06 20:39:32 +01:00
7b88f9b54b Remove obsolete episode header position assertion 2026-11-06 20:39:32 +01:00
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
9d2471c5b1 Fix TV artwork, episode layout and focus visibility 2026-11-06 20:25:50 +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
63 changed files with 11909 additions and 223 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

@ -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

@ -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

@ -29,6 +29,8 @@ internal class FakeConductarr : Closeable {
@Volatile var localArt = false @Volatile var localArt = false
@Volatile var beforeResponse: ((String) -> Unit)? = null @Volatile var beforeResponse: ((String) -> Unit)? = null
@Volatile var audiencesBody: String? = 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=")
@ -69,6 +71,8 @@ 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 == "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

View file

@ -312,11 +312,6 @@ class TitleKeysTest {
assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f) assertEquals(553f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
} }
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Test fun episodeProgressReclaimsItsNegativeMarginBeforeTheActionRow() {
render("Andor")
assertEquals(603f, rule.onNodeWithTag("title-actions").fetchSemanticsNode().boundsInRoot.top, 1f)
}
@Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() { @Test fun everyLongTrackChoiceAndDoneRemainInsideTheVisibleSheet() {
render(transform = { data -> render(transform = { data ->

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

@ -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

@ -35,6 +35,7 @@ import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitlePerson import org.justinvisser.wolfskin.design.TitlePerson
import org.justinvisser.wolfskin.design.TitleTrack import org.justinvisser.wolfskin.design.TitleTrack
import org.justinvisser.wolfskin.design.TitleVersion import org.justinvisser.wolfskin.design.TitleVersion
import org.justinvisser.wolfskin.design.WolfskinTokens
import java.io.File import java.io.File
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
@ -189,27 +190,44 @@ class TitleReadFacade @Inject constructor(
streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }, streams.filter { it.type == MediaStreamType.AUDIO }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) },
streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) }) streams.filter { it.type == MediaStreamType.SUBTITLE }.map { TitleTrack(it.index, it.displayTitle ?: it.language ?: it.codec.orEmpty(), it.isDefault) })
} }
return TitlePageData(mapItem(item).copy(seasons = seasonNumbers.size), target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it) }, val pageItem = mapItem(item).copy(seasons = seasonNumbers.size)
rows.first { it.id.toString() == watchId }.let { mapItem(it) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index, return TitlePageData(pageItem, target?.takeIf { it.type == BaseItemKind.EPISODE || (it.userData?.playbackPositionTicks ?: 0) > 0 }?.let { mapItem(it, pageItem.backdrop) },
rows.first { it.id.toString() == watchId }.let { mapItem(it, pageItem.backdrop) }, versions, chosen?.source?.id, chosen?.audioStream?.stream?.index,
if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1, if (chosen?.subtitlesDisabled == true) -1 else chosen?.subtitleStream?.stream?.index ?: -1,
item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url }, item.remoteTrailers.orEmpty().firstNotNullOfOrNull { it.url },
cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person -> cast = item.people.orEmpty().filter { it.type.toString().equals("Actor", true) }.map { person ->
TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) }) TitlePerson(person.id.toString(), person.name.orEmpty(), person.role.orEmpty(), person.primaryImageTag?.let { images.getItemImageUrl(person.id, ImageType.PRIMARY, maxWidth = 200, maxHeight = 200, tag = it) })
}, },
episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it) }, episodes = rows.filter { it.type == BaseItemKind.EPISODE && it.parentIndexNumber == selectedSeason }.map { mapItem(it, pageItem.backdrop) },
connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason) connection = TitleConnection.SignedOut, seasonNumbers = seasonNumbers, selectedSeason = selectedSeason)
} }
private fun mapItem(item: BaseItemDto): TitleItem = TitleItem( private fun mapItem(item: BaseItemDto, seriesBackdrop: String? = null): TitleItem = TitleItem(
item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" }, item.id.toString(), item.name.orEmpty(), when (item.type) { BaseItemKind.MOVIE -> "movie"; BaseItemKind.EPISODE -> "episode"; else -> "tv" },
item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(), item.productionYear, (item.runTimeTicks ?: 0) / 10_000_000, overview = item.overview.orEmpty(), genres = item.genres.orEmpty(),
poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) }, poster = item.imageTags?.get(ImageType.PRIMARY)?.let { images.getItemImageUrl(item.id, ImageType.PRIMARY, maxWidth = 256, maxHeight = 384, tag = it) },
backdrop = item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = 1280, maxHeight = 720, tag = it) }, backdrop = if (item.type == BaseItemKind.EPISODE) episodeArtwork(item, seriesBackdrop) else item.backdropImageTags?.firstOrNull()?.let { images.getItemImageUrl(item.id, ImageType.BACKDROP, maxWidth = WolfskinTokens.Image.heroRequestWidth, maxHeight = WolfskinTokens.Image.heroRequestHeight, tag = it) },
held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000, held = true, watched = item.userData?.played == true, resumeSeconds = (item.userData?.playbackPositionTicks ?: 0) / 10_000_000,
seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0, seriesId = item.seriesId?.toString(), seriesName = item.seriesName, season = item.parentIndexNumber ?: 0, episode = item.indexNumber ?: 0,
tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(), tmdbId = item.providerIds?.entries?.firstOrNull { it.key.equals("Tmdb", true) }?.value?.toLongOrNull(),
) )
private fun episodeArtwork(item: BaseItemDto, seriesBackdrop: String?): String? {
val type = when {
item.imageTags?.get(ImageType.PRIMARY) != null -> ImageType.PRIMARY
item.imageTags?.get(ImageType.THUMB) != null -> ImageType.THUMB
else -> null
}
if (type != null) return images.getItemImageUrl(item.id, type,
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = item.imageTags?.get(type))
return seriesBackdrop ?: item.parentBackdropItemId?.let { parent ->
item.parentBackdropImageTags?.firstOrNull()?.let { tag ->
images.getItemImageUrl(parent, ImageType.BACKDROP,
maxWidth = WolfskinTokens.Image.episodeRequestWidth, maxHeight = WolfskinTokens.Image.episodeRequestHeight, tag = tag)
}
}
}
private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json") private fun cacheFile(id: UUID): File = File(context.filesDir, "wolfskin-title/${repository.currentServer?.id}/${repository.currentUser?.id}/$id.json")
private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json") private fun personCacheFile(id: UUID): File = File(cacheFile(id).parentFile, "person-$id.json")
private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified())) private fun savedTime(file: File): String = java.text.SimpleDateFormat("HH:mm", Locale.getDefault()).format(java.util.Date(file.lastModified()))

61
data/tv-spec/spec.md Normal file
View file

@ -0,0 +1,61 @@
# Wolfskin TV specification — revision 5
2026-11-06. This feedback amendment applies to the approved revision-4 specification and frozen frames at `data/tv-v2/focus-2026-11-06/` (prototype SHA-256 `1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d`). Unchanged geometry, navigation, typography, colours and motion remain governed by those frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The amendment's prototype delta is `data/tv-v2/feedback-2026-11-06/`.
## Who’s watching
The Settings gear is horizontally centred beneath the complete people row, independent of person count or selection. Justin confirmed this placement. It uses the same 4 px accent straddle ring as the people, with the existing 1.08 scale and focus-only Settings caption. Down still reaches it only from the final person; Up returns to that person.
## Focus visibility
“Centered” means consistent visibility, not geometric centering. Keep the approved shelf rule: one-card lookahead, shifting after the second-last visible slot, with first/last positions clamped to the actual contents. Short rows do not manufacture empty scroll space. Poster, tile, wide, service, episode, cast and related shelves follow the same rule.
The focused card and its ring remain fully inside the viewport, with at least the Level 2 gap (40 px) of horizontal clearance at each screen edge. Focused rows clear the nav band and the bottom edge. Preserve the approved 168 px heading pin and initial fully-visible-row exceptions; hero/header anchors remain zero. First entry, row changes, row ends and Back from a title must reconcile retained logical selection with the actual scroll position. A frame is recorded as positioned only after its scroll finishes. Discover uses the approved six poster slots, not a rounded-down five-slot approximation.
Library and Person grids retain six columns and the existing Level 2 row spacing. The final Person column keeps the full poster width; horizontal padding must not shrink it.
## Episode artwork and dimensions
Episode cards use **400×280 px (200×140 dp)** art: 55 px / 24.4% taller than the previous 400×225 px tile. Other tile and wide-card sizes are unchanged. The episode shelf is `84 + 280 + 68 = 432 px`; its heading band and 68 px clearance before Cast remain Level 2. Cast moves down by the additional card height rather than overlapping it.
Choose available art in this order:
1. Episode `Primary` image — Jellyfin stores episode stills under this type.
2. Episode `Thumb` image.
3. Series `Backdrop`, centre-cropped to the episode slot.
4. The existing missing-art treatment when none is available.
A series-page read supplies its already-read backdrop to the episode projection; it does not make one series request per episode. Saved pages rebuild the same projection from cached metadata. Thumbnail reads and prefetch use the 400×280 px episode request, not the unrelated poster dimensions.
Read-only Jellyfin evidence: all ten The Bear season-three episodes have Primary tags and no episode Backdrop tags. Tomorrow’s Primary source is 2100×1182; the series Backdrop is 3840×2160. These are existing aired files, not missing/unaired episode placeholders.
## Episode summaries and series header spacing
In episode context, the summary belongs to the episode: focused episode first, explicitly opened episode next, otherwise the Up-next episode. Use the show overview only when no episode context is selected. An absent episode overview stays absent instead of substituting the show's description. The focused episode card can show two ellipsised lines of its own overview below its episode label.
A held series header measures its actual copy, actions and action-caption band, then leaves **40 px** before the Seasons row. Remove the unconditional 900 px series-header spacer. Films, non-held titles and loading/error layouts retain their approved fixed header geometry. Measured series geometry also drives the frame offsets and focus-follow calculation, preventing season/episode rows from scrolling to stale offsets. Long titles or summaries may grow the header; controls must never overlap the seasons.
## Hero image quality and memory
| Surface | Requested/decode bound | Format policy |
|---|---|---|
| Home and Discover hero; title backdrop | 1920×1080 | ARGB, 7.91 MiB at the bound |
| Upright hero poster | 420×630 | RGB565 where supported, 0.50 MiB |
| Episode card | 400×280 | ARGB, 0.43 MiB |
| Library poster, ordinary tile/wide, cast | Existing slot dimensions | Existing RGB565/ARGB policy |
Jellyfin image URLs discard conflicting width/height/max/fill caps and request the consuming slot while preserving image tags, quality and other query parameters. Jellyfin quality remains **96**. TMDB heroes use the original source rather than the previous 780 px source; Coil constrains decoding to the panel. Upright TMDB sheets use a suitable poster tier. Intentionally blurred poster-only washes remain bounded to the upright source, not an oversized portrait bitmap. Decode fitting prevents allocation of an oversized off-ratio source merely to fill a crop.
The shared memory cache remains **64 MiB**, disk cache **128 MiB**, image concurrency **4**. Three 1080p hero bitmaps consume 23.73 MiB, leaving 40.27 MiB of the cache for other art. These are allocation bounds, not measured TV resident-memory claims; do not request or decode 4K hero bitmaps on the 2 GB armv7 box.
Read-only source measurement: Desperate Housewives has a 2732×1536 Backdrop. With quality 96, Jellyfin returned a 1281×720 JPEG of 438,329 bytes for the old fill request and a 1921×1080 JPEG of 982,435 bytes for the new request; the extra source pixel is Jellyfin aspect-ratio rounding. The display decode stays bounded to 1920×1080.
## Discover lane spacing
Every Discover lane, including Movies for you, Series for you, service entries, service-specific lanes and Hidden picks, uses a **104 px heading band**: heading line plus **60 px heading-to-card gap**, up from 40 px. This is a consistent Level 2 extension, not a Series-only exception. Poster shelves become 652 px high and service-entry shelves 316 px. Captions and frame scroll offsets use the same heading band. Home headings remain unchanged.
## Sources and verification
Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`.
The feedback prototype delta links the native 1920×1080 before/after frames. `HomeViewportTest` exercises short and long poster/tile/wide rows, first focus, both row directions and native Back from the shipped title surface. The real-Bear capture harness reads recorded Jellyfin metadata through `TitleReadFacade`; it is a throwaway proof and is not shipped. The final gates are `make test` and `make apk-debug`. No TV access or optimized-APK build is part of this worker's validation.

View file

@ -0,0 +1,50 @@
{
"specRevision": 5,
"base": "data/tv-v2/focus-2026-11-06/index.html",
"baseSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
"stagePx": [1920, 1080],
"density": 2,
"picker": {
"gearAlignment": "center of the complete people row",
"gearRing": "same accent straddle ring as picker people",
"gearFocusScale": 1.08,
"navigation": "unchanged: Down from the final person only"
},
"focus": {
"horizontalRule": "second-last-slot lookahead; clamp first/last to real contents",
"minimumScreenSideMarginPx": 40,
"headingPinPx": 168,
"geometricCentering": false,
"discoverPosterSlots": 6
},
"episode": {
"artPx": [400, 280],
"shelfHeightPx": 432,
"gapBeforeCastPx": 68,
"artFallback": ["episode Primary", "episode Thumb", "series Backdrop crop", "missing art"],
"summaryContext": ["focused episode", "explicitly opened episode", "up-next episode", "show when no episode context"],
"focusedCardSummaryLines": 2
},
"seriesHeader": {
"height": "measured copy, actions and caption band plus gap",
"gapBeforeSeasonsPx": 40,
"otherTitleHeaders": "unchanged"
},
"discover": {
"allLaneHeadingBandPx": 104,
"allLaneHeadingToCardGapPx": 60,
"posterShelfHeightPx": 652,
"serviceShelfHeightPx": 316,
"homeHeadingBand": "unchanged"
},
"images": {
"heroDecodeBoundPx": [1920, 1080],
"heroPosterDecodeBoundPx": [420, 630],
"jellyfinQuality": 96,
"memoryCacheMiB": 64,
"diskCacheMiB": 128,
"concurrentReads": 4
},
"nativeFrames": "https://pages.justinvisser.org/wolfskin/tv-fb1-visual/",
"specification": "../../tv-spec/spec.md"
}

View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wolfskin TV · feedback delta · revision 5</title>
<style>
:root { color-scheme: dark; font: 16px/1.5 system-ui, sans-serif; }
body { max-width: 1440px; margin: 32px auto; padding: 0 24px; background: #07070a; color: #f4f4f6; }
a { color: #cfb0f6; } nav { display: flex; flex-wrap: wrap; gap: 24px; }
section { margin: 48px 0; } h2 { margin-bottom: 8px; }
img { display: block; width: 100%; height: auto; margin: 16px 0; border: 1px solid #33333b; }
summary { cursor: pointer; } .note { color: #b9b9c1; }
</style>
<h1>Wolfskin TV · feedback delta</h1>
<p>Specification revision 5. The approved frames remain the baseline; these native 1920×1080 frames show only the requested feedback changes. This is a frame prototype, not a simulated Jellyfin client.</p>
<nav><a href="#picker">Who’s watching</a><a href="#home">Home</a><a href="#discover">Discover</a><a href="#series">Series</a><a href="#episodes">Episodes</a><a href="#person">Person</a><a href="delta.json">Exact delta values</a></nav>
<section id="picker"><h2>Who’s watching</h2><p>Gear centred beneath everyone, with the person-style focus ring.</p>
<img width="1920" height="1080" alt="Native picker after: Settings centred below the complete people row" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/picker.png">
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native picker before: gear below the final person" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/picker.png"></details></section>
<section id="home"><h2>Home</h2><p>Approved edge scrolling retained. Short/long poster, tile and wide rows, row changes and native title Back exercise the same visibility contract.</p>
<img loading="lazy" width="1920" height="1080" alt="Native Home after: focused card safely inside a long shelf" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/home.png">
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Home baseline" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/home.png"></details>
<details><summary>Real-source hero request comparison</summary><p class="note">Same native renderer and source artwork: old 720p response, then panel-sized response. Intentionally blurred poster washes are unchanged.</p><img loading="lazy" width="1920" height="1080" alt="Native hero using the old 720p source response" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/home-hero.png"><img loading="lazy" width="1920" height="1080" alt="Native hero using the panel-resolution source response" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/home-hero.png"></details></section>
<section id="discover"><h2>Discover</h2><p>60 px from lane title to cards; all lanes use the same 104 px heading band.</p>
<img loading="lazy" width="1920" height="1080" alt="Native Discover after: more space above every lane's cards" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/discover.png">
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Discover before: smaller heading gap" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/discover.png"></details></section>
<section id="series"><h2>Series header</h2><p>Episode-context summary and a measured header instead of the empty fixed-height spacer. Season chips and thumbnail artwork enter the initial view.</p>
<img loading="lazy" width="1920" height="1080" alt="Native Bear series header after: episode overview and visible thumbnail row" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/series.png">
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Bear series header before: show overview and empty space before seasons" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/series.png"></details></section>
<section id="episodes"><h2>Episode shelf</h2><p>Real episode Primary artwork, 400×280 px cards and the focused episode's own overview. Cast retains its Level 2 clearance.</p>
<img loading="lazy" width="1920" height="1080" alt="Native Bear episodes after: taller thumbnail cards, focused overview and Cast below" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/episodes.png">
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Bear episodes before: monitor placeholders" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/episodes.png"></details></section>
<section id="person"><h2>Person</h2><p>Six full-width poster columns; no shrinking of the final column.</p>
<img loading="lazy" width="1920" height="1080" alt="Native Person after" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/after/person.png">
<details><summary>Before</summary><img loading="lazy" width="1920" height="1080" alt="Native Person before" src="https://pages.justinvisser.org/wolfskin/tv-fb1-visual/before/person.png"></details></section>
</html>

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
@ -90,7 +169,7 @@ The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%. ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
The small gear under the final person opens Settings with Requests focused. The small gear centred beneath the complete people row opens Settings with Requests focused.
Settings groups hand off to existing upstream pages; Requests opens the household Settings groups hand off to existing upstream pages; Requests opens the household
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations with rollout switch: turning it off renders upstream Home and library destinations with
@ -134,6 +213,14 @@ destination is an explicit title-family integration stub exposing only Discover'
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.
@ -272,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
@ -331,6 +428,15 @@ 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
@ -384,7 +490,7 @@ 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, 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; 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 cast portraits and episode tiles now receive that lift too. The stroke and art
scale together at 1.08; buttons, avatars, chips and picker focus are unchanged. scale together at 1.08.
Title shelves reserve vertical drawing room for the scaled ring. Title shelves reserve vertical drawing room for the scaled ring.
Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px 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. for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px.
@ -405,6 +511,62 @@ These captures are recordings, not automated reference-pixel assertions; pair
measurements are a separate check. Platform Roboto metrics and fixture ordering measurements are a separate check. Platform Roboto metrics and fixture ordering
are not browser pixel parity. 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.
### TV feedback corrections (spec revision 5)
The [revision-5 amendment](../data/tv-spec/spec.md) and
[native frame prototype delta](../data/tv-v2/feedback-2026-11-06/index.html)
retain the approved frames except for the requested feedback changes:
- The picker gear is centred under everyone and uses the picker-person ring.
- Shelf tracks share second-last-slot lookahead, count clamping and settled
offsets. Home records completed frame positioning rather than an animation
that may still be cancelled; first mount restores the intended shelf item.
Discover uses all six approved poster slots. Person keeps the final column
at full poster width.
- Episode images choose episode Primary, then Thumb, then the already-read
series Backdrop. Episode cards are 400×280 px; cast retains its 68 px clearance.
Episode context uses the episode overview, with two summary lines on the
focused card. Held-series headers measure their copy/actions and leave 40 px
before seasons instead of reserving a fixed empty 900 px header.
- Every Discover heading uses a 104 px band, including a 60 px title-to-card
gap. Home heading geometry is unchanged.
- Hero/backdrop decoding is bounded to 1920×1080; upright hero sheets request
420×630. The shared request builder removes conflicting Jellyfin size caps,
preserves tags and quality 96, and selects a suitable TMDB tier. The 64 MiB
memory cache, 128 MiB disk cache and four image-read limit remain unchanged.
`HomeViewportTest` covers short/long poster, tile and wide rows, both row
directions, first focus and native Back from the actual title surface. Native
proof records real Bear season-three metadata through the title read facade;
the proof-only harness is not part of the shipped tests or APK.
## 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,
@ -710,6 +872,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

@ -1,5 +1,6 @@
{ {
"discover": { "discover": {
"shelfHeadingBand": {"dp": 52, "px": 104, "source": "feedback-2026-11-06: heading-to-card gap 60 px on every Discover lane"},
"sheetPaddingX": { "sheetPaddingX": {
"$type": "dimension", "$type": "dimension",
"px": 64, "px": 64,

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, straddle = true) .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

@ -50,8 +50,11 @@ fun DiscoverArtwork(path: String?, modifier: Modifier, poster: Boolean = false,
this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) } this *= ColorMatrix().apply { setToScale(D.ambientBrightness, D.ambientBrightness, D.ambientBrightness, 1f) }
}) else null } }) else null }
val context = LocalContext.current val context = LocalContext.current
val request = remember(context, path, poster) { val request = remember(context, path, poster, dim) {
WolfskinImages.request(context, path, if (poster) 256 else 1280, if (poster) 384 else 720, poster) val upright = poster || dim
WolfskinImages.request(context, path,
if (upright) WolfskinTokens.Image.heroPosterRequestWidth else WolfskinTokens.Image.heroRequestWidth,
if (upright) WolfskinTokens.Image.heroPosterRequestHeight else WolfskinTokens.Image.heroRequestHeight, upright)
} }
if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter) if (painter != null) Image(painter, null, modifier, contentScale = ContentScale.Crop, colorFilter = filter)
else AsyncImage( else AsyncImage(

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

@ -86,7 +86,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero) add(if (state.hasCatalogue) L.row_chips else WolfskinTokens.Shell.zero)
if (!state.hasCatalogue) add(L.row_state) else { if (!state.hasCatalogue) add(L.row_state) else {
if (note) add(L.row_note) if (note) add(L.row_note)
state.lanes.forEach { lane -> add(L.rowHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) } state.lanes.forEach { lane -> add(D.shelfHeadingBand + (if (lane.id == "svc") WolfskinTokens.Card.entryH else WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter) }
} }
} }
} }
@ -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

@ -1,9 +1,6 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -22,15 +19,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.tv.material3.Text import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
@ -47,30 +42,23 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1 val count = if (services) state.snapshot.services.size else lane.titles.size + if (lane.tail == DiscoverTail.None) 0 else 1
val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0)) val column = (state.shell.view.cards[lane.id] ?: 0).coerceIn(0, (count - 1).coerceAtLeast(0))
val active = state.contentFocused && state.focusedRow == row val active = state.contentFocused && state.focusedRow == row
val slots = ((L.contentWidth + K.posterGap) / K.posterPitch).toInt() val slots = K.posterSlots
val first = state.shell.view.cards["first:${lane.id}"] ?: 0 val first = state.shell.view.cards["first:${lane.id}"] ?: 0
var initialized by remember { mutableStateOf(false) } val density = LocalDensity.current
LaunchedEffect(column, count) { LaunchedEffect(column, count, active) {
var target = first val target = shelfFirst(column, first, slots, count)
if (column > target + slots - 2) target = column - (slots - 2)
if (column < target + 1) target = (column - 1).coerceAtLeast(0)
target = target.coerceIn(0, (count - slots).coerceAtLeast(0))
state.shell.view.cards["first:${lane.id}"] = target state.shell.view.cards["first:${lane.id}"] = target
val offset = cards.layoutInfo.visibleItemsInfo.firstOrNull { it.index == target }?.offset cards.scrollShelfTo(target, with(density) { K.posterPitch.toPx() })
if (initialized && offset != null) {
if (offset != 0) cards.animateScrollBy(offset.toFloat(), tween(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
} else cards.scrollToItem(target)
initialized = true
} }
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) { Box(Modifier.fillMaxWidth().height(D.shelfHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1) Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
LazyRow(state = cards, userScrollEnabled = false, LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { modifier = Modifier.offset(y = D.shelfHeadingBand - 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, straddle = true) .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) {
@ -97,7 +85,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
Caption( Caption(
item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more",
if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles",
true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), L.rowHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth),
accentFirstFact = lane.hidden, accentFirstFact = lane.hidden,
accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()),
) )

View file

@ -15,11 +15,9 @@ 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.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.graphics.drawscope.Stroke
@ -36,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 }
/** Card rings straddle the art edge above content; control rings retain their outside gap. */ /** 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, straddle: Boolean = false): 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 {
@ -56,25 +54,17 @@ 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
} }
} }
if (straddle) return shadow.drawWithCache { return shadow.drawWithCache {
val stroke = Stroke(width.toPx()) val stroke = Stroke(width.toPx())
val corner = CornerRadius(radius.toPx()) val corner = CornerRadius(radius.toPx())
onDrawWithContent { onDrawWithContent {
drawContent() drawContent()
if (ringAlpha > 0f) { if (ringAlpha > 0f) {
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha),
cornerRadius = corner, style = stroke) cornerRadius = corner, style = stroke)
} }
} }
} }
return shadow.drawBehind {
if (ringAlpha > 0f) {
val outer = (gap + width).toPx()
val inner = gap.toPx()
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
}
}
} }
/** Pointing changes the same logical focus as the D-pad without activating an action. */ /** Pointing changes the same logical focus as the D-pad without activating an action. */

View file

@ -107,9 +107,5 @@ internal val HomeRow.focusSlots: Int get() = when (cardKind) {
} }
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */ /** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int { internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int =
var first = previous shelfFirst(column, previous, focusSlots, count)
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
}

View file

@ -61,25 +61,17 @@ fun HomeScreen(
LaunchedEffect(view.row, snapshot.rows) { LaunchedEffect(view.row, snapshot.rows) {
snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) } snapshot.rows.drop(view.row + 1).firstOrNull { it.kind == HomeRowKind.Shelf && !it.skeleton }?.let { WolfskinImages.prefetch(context, it) }
} }
LaunchedEffect(view) { LaunchedEffect(view, view.row, view.home.resetScroll, heights) {
if (view.home.positionedKey != null) {
view.home.focusedKey?.let { view.home.tracks[it] }?.let { track ->
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset)
}
view.listState.scrollToItem(view.listState.firstVisibleItemIndex, view.listState.firstVisibleItemScrollOffset)
}
}
LaunchedEffect(view, view.row, view.home.resetScroll, snapshot.rows) {
val key = snapshot.rows.getOrNull(view.row)?.id val key = snapshot.rows.getOrNull(view.row)?.id
if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect if (view.home.positionedKey == key && view.home.positionedReset == view.home.resetScroll) return@LaunchedEffect
val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll val topRequested = shell.navFocused && view.home.resetScroll > 0 && view.home.positionedReset != view.home.resetScroll
val target = if (topRequested) 0f else with(density) { val target = if (topRequested) 0f else with(density) {
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f) }.coerceAtLeast(0f)
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f))) view.listState.animateScrollBy(target - current, tween(WolfskinTokens.Motion.scroll, easing = CubicBezierEasing(.33f, 1f, .68f, 1f)))
view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll
} }
val cache = view.home.ratings val cache = view.home.ratings
val ratingsReader = LocalScreenRatings.current val ratingsReader = LocalScreenRatings.current

View file

@ -2,8 +2,6 @@ package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
@ -16,9 +14,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue
import kotlinx.coroutines.flow.collectLatest
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@ -43,18 +40,12 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0)
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first }
val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } }
val targetFirst by rememberUpdatedState(first)
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(track) { var positioned by remember(track) { mutableStateOf(false) }
track.scrollToItem(track.firstVisibleItemIndex, track.firstVisibleItemScrollOffset) LaunchedEffect(track, first, focused, width, density) {
var positionedFirst = targetFirst if (!positioned) track.scrollToItem(first)
snapshotFlow { targetFirst }.collectLatest { target -> else track.scrollShelfTo(first, with(density) { (width + K.posterGap).toPx() })
if (target != positionedFirst) { positioned = true
positionedFirst = target
val pitch = with(density) { (width + K.posterGap).toPx() }
track.animateScrollBy((target - track.firstVisibleItemIndex) * pitch - track.firstVisibleItemScrollOffset, tween(WolfskinTokens.Motion.base))
}
}
} }
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) {
if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide))

View file

@ -111,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

@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop))
} }
itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row -> itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row ->
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item -> row.forEachIndexed { column, item ->
val index = rowIndex * columns + column val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }

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

@ -0,0 +1,22 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.lazy.LazyListState
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int {
var first = previous
if (column > first + slots - 2) first = column - (slots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - slots).coerceAtLeast(0))
}
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
if (distance != 0f) animateScrollBy(distance, tween(WolfskinTokens.Motion.base,
easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
}

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

@ -98,14 +98,14 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat() for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
ColorFilter.colorMatrix(matrix) ColorFilter.colorMatrix(matrix)
} }
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, TitleArtwork(item.poster, WolfskinTokens.Image.heroPosterRequestWidth, WolfskinTokens.Image.heroPosterRequestHeight,
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale } Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) .blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) } val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape)) .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape), poster = true)
} }
Box(Modifier.fillMaxSize().background(horizontal)) Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical)) Box(Modifier.fillMaxSize().background(vertical))
@ -131,13 +131,13 @@ fun TitleTileArtwork(item: TitleItem) {
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
}) } }) }
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) TitleArtwork(item.poster, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2 val height = D.episodeCardHeight - D.tileSheetInset * 2
val shape = remember { RoundedCornerShape(D.tileSheetRadius) } val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize()) } else TitleArtwork(item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight, Modifier.fillMaxSize())
} }
} }

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, straddle = true)) { .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

@ -36,6 +36,11 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) { fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifier) {
if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return } if (state.data.status == TitleReadStatus.Loading) { TitleHeaderSkeleton(modifier); return }
val item = state.data.item val item = state.data.item
val episode = when {
state.row == "episodes" -> state.data.episodes.getOrNull(state.column)
state.data.watchTarget.kind == "episode" -> state.data.watchTarget
else -> state.data.playTarget?.takeIf { it.kind == "episode" }
}
val type = if (item.kind == "movie") "Movie" else "Series" val type = if (item.kind == "movie") "Movie" else "Series"
CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { CssColumn(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.exactLineBox(TitleTextStyles.dLine, bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
@ -63,7 +68,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine) Text("for ${state.data.requestFor ?: "you"}" + state.data.requestActor?.let { " · by $it" }.orEmpty(), color = C.muted, style = TitleTextStyles.dLine)
} }
} }
Text(item.overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview")) Text((episode ?: item).overview, color = C.muted, style = TitleTextStyles.body, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap).testTag("title-overview"))
if (!item.held && state.data.connection != TitleConnection.SignedIn) { if (!item.held && state.data.connection != TitleConnection.SignedIn) {
Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.", Text(if (state.data.connection == TitleConnection.SignedOut) "Requests need this TV to be signed in to Conductarr." else "Requests need Conductarr, and it can’t be reached right now.",
color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap)) color = C.warn, style = TitleTextStyles.body, modifier = Modifier.exactLineBox(TitleTextStyles.body, bottom = D.overviewGap))

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.design
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -90,6 +91,10 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val list = shell.view.listState val list = shell.view.listState
var wheelPending by remember { mutableStateOf(false) } var wheelPending by remember { mutableStateOf(false) }
val focus = remember { FocusRequester() } val focus = remember { FocusRequester() }
var measuredHeaderHeight by remember(state.data.item.id) { mutableStateOf(L.row_dhead) }
val compactSeries = state.data.status == TitleReadStatus.Ready && state.data.item.held &&
state.data.item.kind == "tv" && state.data.item.seasons > 0
val headerHeight = if (compactSeries) measuredHeaderHeight else L.row_dhead
LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) { LaunchedEffect(state.sheet, shell.navFocused, shell.pickerVisible) {
if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus() if (state.sheet == null && !shell.navFocused && !shell.pickerVisible) focus.requestFocus()
} }
@ -105,19 +110,19 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
if (state.data.related.isNotEmpty()) add("related") if (state.data.related.isNotEmpty()) add("related")
} }
} }
val rowOffsets = remember(displayedRows, density) { val rowOffsets = remember(displayedRows, density, headerHeight) {
FloatArray(displayedRows.size + 1).apply { FloatArray(displayedRows.size + 1).apply {
displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row).toPx() } } displayedRows.forEachIndexed { index, row -> this[index + 1] = this[index] + with(density) { titleRowHeight(row, headerHeight).toPx() } }
} }
} }
val position by remember(list, rowOffsets) { derivedStateOf { val position by remember(list, rowOffsets) { derivedStateOf {
rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset rowOffsets[list.firstVisibleItemIndex.coerceAtMost(rowOffsets.lastIndex)] + list.firstVisibleItemScrollOffset
} } } }
LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows) { LaunchedEffect(state.row, shell.navFocused, state.data.status, displayedRows, rowOffsets) {
if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab) if (state.data.status == TitleReadStatus.Loading) shell.focusNav(shell.currentTab)
val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0) val index = if (shell.navFocused) 0 else displayedRows.indexOf(state.row).coerceAtLeast(0)
val y = rowOffsets[index] val y = rowOffsets[index]
val height = with(density) { titleRowHeight(state.row).toPx() } val height = with(density) { titleRowHeight(state.row, headerHeight).toPx() }
val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f val target = if (index == 0 || (y < with(density) { L.windowAhead.toPx() } && y + height <= with(density) { (WolfskinTokens.Shell.stageHeight - L.safeTopPicker).toPx() })) 0f
else y - with(density) { L.pinnedLine.toPx() } else y - with(density) { L.pinnedLine.toPx() }
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
@ -148,7 +153,11 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
val active = state.row == id && !shell.navFocused && state.sheet == null val active = state.row == id && !shell.navFocused && state.sheet == null
when (id) { when (id) {
"header" -> Box(Modifier.height(L.row_dhead)) { TitleHeader(state, active) } "header" -> Box(Modifier.height(headerHeight)) {
TitleHeader(state, active, if (compactSeries) Modifier.onSizeChanged { size ->
measuredHeaderHeight = L.detailCopyTop + D.lineLift + with(density) { size.height.toDp() } + D.seriesHeaderGap
} else Modifier)
}
"seasons" -> TitleSeasons(state, active) "seasons" -> TitleSeasons(state, active)
"episodes" -> TitleMediaShelf(state, true, active) "episodes" -> TitleMediaShelf(state, true, active)
"cast" -> TitleCast(state, active) "cast" -> TitleCast(state, active)
@ -156,16 +165,16 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
} }
} } } }
item(key = "end-padding", contentType = "spacer") { item(key = "end-padding", contentType = "spacer") {
Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last())).coerceAtLeast(D.minimumEndPadding))) Spacer(Modifier.height((WolfskinTokens.Shell.stageHeight - L.pinnedLine - titleRowHeight(displayedRows.last(), headerHeight)).coerceAtLeast(D.minimumEndPadding)))
} }
} }
} }
} }
private fun titleRowHeight(row: String): Dp = when (row) { private fun titleRowHeight(row: String, headerHeight: Dp): Dp = when (row) {
"header" -> L.row_dhead "header" -> headerHeight
"seasons" -> L.row_chips "seasons" -> L.row_chips
"episodes" -> L.rowHeadingBand + WolfskinTokens.Card.tileH + L.rowGapAfter "episodes" -> L.rowHeadingBand + D.episodeCardHeight + L.rowGapAfter
"cast" -> D.castHeight "cast" -> D.castHeight
else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter else -> L.rowHeadingBand + WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + L.rowGapAfter
} }

View file

@ -22,7 +22,7 @@ internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight) queue(data.cast[index].portrait, WolfskinTokens.Image.castRequestWidth, WolfskinTokens.Image.castRequestHeight)
"episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) { "episodes" -> for (index in 0 until minOf(data.episodes.size, WolfskinTokens.Card.tileSlots)) {
val item = data.episodes[index] val item = data.episodes[index]
queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight) queue(if (item.isPosterBackdrop) item.poster else item.backdrop, WolfskinTokens.Image.episodeRequestWidth, WolfskinTokens.Image.episodeRequestHeight)
} }
"related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots)) "related" -> for (index in 0 until minOf(data.related.size, WolfskinTokens.Card.posterSlots))
queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true) queue(data.related[index].poster, WolfskinTokens.Image.posterRequestWidth, WolfskinTokens.Image.posterRequestHeight, true)

View file

@ -3,8 +3,6 @@ package org.justinvisser.wolfskin.design
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -40,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.tv.material3.Text import androidx.tv.material3.Text
import kotlin.math.roundToInt
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -50,7 +49,9 @@ fun TitleCast(state: TitleState, active: Boolean) {
val track = rememberLazyListState() val track = rememberLazyListState()
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, state.data.cast.size) {
if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch, state.data.cast.size)
}
Box(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, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2),
@ -64,7 +65,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, FocusLift.Card, straddle = true).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)
@ -81,11 +82,13 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
val titles = if (episodes) state.data.episodes else state.data.related val titles = if (episodes) state.data.episodes else state.data.related
val id = if (episodes) "episodes" else "related" val id = if (episodes) "episodes" else "related"
val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW val width = if (episodes) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (episodes) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH val height = if (episodes) D.episodeCardHeight else WolfskinTokens.Card.posterH
val track = rememberLazyListState() val track = rememberLazyListState()
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, titles.size) {
if (active) track.keepColumnInWindow(state.column, visible, pitch, titles.size)
}
Box(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, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter)
@ -114,14 +117,15 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
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, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) .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))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
} }
val progress = item.progress val progress = item.progress
if (progress > 0f && progress < 1f) { if (progress > 0f && progress < 1f) {
@ -155,15 +159,8 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
} }
} }
/** Keep a full lookahead card, but do not recenter a row when reversing direction. */ /** Apply the same lookahead and settled-offset rule to cast, episodes and related shelves. */
private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float) { private suspend fun LazyListState.keepColumnInWindow(column: Int, slots: Int, pitch: Float, count: Int) {
val first = firstVisibleItemIndex val previous = firstVisibleItemIndex + (firstVisibleItemScrollOffset / pitch).roundToInt()
val next = when { scrollShelfTo(shelfFirst(column, previous, slots, count), pitch)
column < first -> column
column >= first + slots - 1 -> (column - slots + 2).coerceAtLeast(0)
else -> first
}
if (next != first) animateScrollBy((next - first) * pitch - firstVisibleItemScrollOffset, titleTrackScrollSpec)
} }
private val titleTrackScrollSpec = tween<Float>(WolfskinTokens.Motion.base, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))

View file

@ -48,12 +48,12 @@ fun TitleArtwork(title: String, type: String, poster: String?, width: Dp, height
.requiredSize(width + inset * 2, height + inset * 2) .requiredSize(width + inset * 2, height + inset * 2)
.blur(if (hero) H.heroBlur else H.cardBlur) .blur(if (hero) H.heroBlur else H.cardBlur)
.graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } } .graphicsLayer { if (hero) { scaleX = 1.1f; scaleY = 1.1f } }
SlotImage(poster, width, height, washModifier, colorFilter = wash, onError = { failed.value = true }) SlotImage(poster, if (hero) WolfskinTokens.Layout.heroSheetWidth else width, if (hero) WolfskinTokens.Layout.heroSheetHeight else height, washModifier, colorFilter = wash, onError = { failed.value = true })
val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset val sheetHeight = if (hero) WolfskinTokens.Layout.heroSheetHeight else height - H.cardSheetInset
val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f) val sheetWidth = if (hero) WolfskinTokens.Layout.heroSheetWidth else sheetHeight * (2f / 3f)
val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop) val sheetModifier = if (hero) Modifier.align(Alignment.TopEnd).offset(-H.heroSheetRight, H.heroSheetTop) else Modifier.offset(H.cardSheetLeft, H.cardSheetTop)
SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true }) SlotImage(poster, sheetWidth, sheetHeight, sheetModifier.size(sheetWidth, sheetHeight).clip(RoundedCornerShape(if (hero) WolfskinTokens.Radius.heroSheet else WolfskinTokens.Radius.cardSheet)), poster = true, onError = { failed.value = true })
} else SlotImage(source, if (hero) WolfskinTokens.Card.posterW * 5 else width, if (hero) WolfskinTokens.Card.posterW * 5 * (9f / 16f) else height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true }) } else SlotImage(source, width, height, Modifier.fillMaxSize(), poster = !landscape, onError = { failed.value = true })
} else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape) } else if (!hero) MediaArtworkFallback(title, type == "movie", showTitle = !landscape)
else HomeAmbient(Modifier.fillMaxSize()) else HomeAmbient(Modifier.fillMaxSize())
} }

View file

@ -46,16 +46,15 @@ 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)
} }
} }
} }
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) val left = (S.stageWidth - 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.ringPickerRing)
.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 +105,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

@ -1,6 +1,7 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import android.content.Context import android.content.Context
import android.net.Uri
import coil3.ComponentRegistry import coil3.ComponentRegistry
import coil3.ImageLoader import coil3.ImageLoader
import coil3.disk.DiskCache import coil3.disk.DiskCache
@ -10,6 +11,7 @@ import coil3.request.crossfade
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.size.Scale
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@ -61,7 +63,7 @@ object WolfskinImages {
} }
fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest = fun request(context: Context, source: Any, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(source).size(width, height).allowRgb565(poster).build() ImageRequest.Builder(context).data(sourceForSize(source, width, height)).size(width, height).scale(Scale.FIT).allowRgb565(poster).build()
fun prefetch(context: Context, row: HomeRow) { fun prefetch(context: Context, row: HomeRow) {
val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 } val count = when (row.cardKind) { HomeCardKind.Poster -> 7; HomeCardKind.Tile -> 5; HomeCardKind.Wide -> 4 }
@ -73,4 +75,35 @@ object WolfskinImages {
if (source != null) loader(context).enqueue(request(context, source, width, height, poster)) if (source != null) loader(context).enqueue(request(context, source, width, height, poster))
} }
} }
private val jellyfinImagePath = Regex("/Items/[^/]+/Images/[^/]+(?:/\\d+)?", RegexOption.IGNORE_CASE)
private val tmdbSizePath = Regex("/t/p/(?:w\\d+|original)/")
private val dimensionParameters = setOf("width", "height", "maxwidth", "maxheight", "fillwidth", "fillheight")
private fun sourceForSize(source: Any, width: Int, height: Int): Any {
if (source !is String) return source
val uri = Uri.parse(source)
if (uri.scheme != "https" && uri.scheme != "http") return source
if (uri.host == "image.tmdb.org") {
val size = if (height > width) when {
width <= 500 -> "w500"
width <= 780 -> "w780"
else -> "original"
} else when {
width <= 780 -> "w780"
width <= 1280 -> "w1280"
else -> "original"
}
return uri.buildUpon().path(uri.path?.replace(tmdbSizePath, "/t/p/$size/")).build().toString()
}
if (!jellyfinImagePath.matches(uri.path.orEmpty())) return source
val builder = uri.buildUpon().clearQuery()
for (name in uri.queryParameterNames) {
if (name.lowercase() !in dimensionParameters) {
uri.getQueryParameters(name).forEach { value -> builder.appendQueryParameter(name, value) }
}
}
return builder.appendQueryParameter("fillWidth", width.toString())
.appendQueryParameter("fillHeight", height.toString()).build().toString()
}
} }

View file

@ -199,12 +199,12 @@ object WolfskinTokens {
const val scaleRow = 1.02f const val scaleRow = 1.02f
val ringCardGap = 0.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 {
@ -224,8 +224,12 @@ object WolfskinTokens {
const val tileRequestHeight = 234 const val tileRequestHeight = 234
const val wideRequestWidth = 560 const val wideRequestWidth = 560
const val wideRequestHeight = 315 const val wideRequestHeight = 315
const val heroRequestWidth = 1280 const val heroRequestWidth = 1920
const val heroRequestHeight = 720 const val heroRequestHeight = 1080
const val heroPosterRequestWidth = 420
const val heroPosterRequestHeight = 630
const val episodeRequestWidth = 400
const val episodeRequestHeight = 280
const val castRequestWidth = 200 const val castRequestWidth = 200
const val castRequestHeight = 200 const val castRequestHeight = 200
const val memoryCacheMB = 64.0f const val memoryCacheMB = 64.0f
@ -416,6 +420,8 @@ object WolfskinTokens {
const val sheetOpacity = 0.5f const val sheetOpacity = 0.5f
} }
object Title { object Title {
val episodeCardHeight = 140.0.dp
val seriesHeaderGap = 20.0.dp
val lineGap = 11.0.dp val lineGap = 11.0.dp
val lineLift = -2.0.dp val lineLift = -2.0.dp
val overviewGap = 17.0.dp val overviewGap = 17.0.dp
@ -609,6 +615,7 @@ object WolfskinTokens {
val posterProgressBottom = 6.0.dp val posterProgressBottom = 6.0.dp
} }
object Discover { object Discover {
val shelfHeadingBand = 52.0.dp
val sheetPaddingX = 32.0.dp val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp val sheetPaddingY = 40.0.dp
val sheetGap = 9.0.dp val sheetGap = 9.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

@ -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

@ -116,5 +116,6 @@ class HomeCaptureTest {
@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 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

@ -0,0 +1,95 @@
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.assertTrue
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
/** Row ends, mixed card widths and real title Back navigation keep the selected art inside the view. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class HomeViewportTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun rowEndsRowChangesAndTitleBackPreserveTheFocusedCardMargin() {
val cards = HomeFixture(rule.activity).snapshot("justin").rows.flatMap { it.cards }.distinctBy { it.id }.take(12)
val rows = HomeCardKind.entries.flatMap { kind ->
val slots = when (kind) { HomeCardKind.Poster -> 6; HomeCardKind.Tile -> 4; HomeCardKind.Wide -> 3 }
listOf(slots, cards.size).map { count -> HomeRow("${kind.name}-$count", HomeRowKind.Shelf,
"${kind.name} shelf", cards.take(count), kind) }
}
val snapshot = HomeSnapshot(rows)
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false }
var title by mutableStateOf<TitleState?>(null)
rule.setContent { WolfskinTheme {
val page = title
if (page != null) TitleSurface(page, shell)
else ShellSurface(shell) { modifier -> HomeScreen(shell, snapshot, modifier) { action ->
if (action is HomeAction.Details) {
shell.enterDestination("title/viewport")
title = TitleState(TitlePageData(TitleItem(action.title.id, action.title.title, "movie", held = true)), shell.person) { effect ->
if (effect == TitleAction.Pop) { shell.enterDestination("home"); title = null }
}
}
} }
} }
for ((index, row) in rows.withIndex()) {
if (index > 0) press(Key.DirectionDown)
assertVisible(shell, rows)
repeat(row.count) { press(Key.DirectionLeft) }
assertVisible(shell, rows)
repeat(row.count - 1) { press(Key.DirectionRight) }
assertVisible(shell, rows)
capture("viewport-${row.id}")
press(Key.DirectionCenter)
rule.onNodeWithTag("title-page").assertIsDisplayed()
press(Key.Back)
assertVisible(shell, rows)
}
repeat(rows.lastIndex) { press(Key.DirectionUp); assertVisible(shell, rows) }
}
private fun press(key: Key) {
rule.onRoot().performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
}
private fun assertVisible(shell: ShellState, rows: List<HomeRow>) {
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val row = rows[shell.view.row]
val column = shell.view.cards[row.id] ?: 0
val bounds = rule.onNodeWithTag("home-card:${row.id}:$column").fetchSemanticsNode().boundsInRoot
val density = rule.density.density
val margin = WolfskinTokens.Card.posterGap.value * density
val stage = WolfskinTokens.Shell.stageWidth.value * density
assertTrue("Focused card is clipped: ${row.id}:$column $bounds", bounds.width >= row.cardWidth.value * density)
assertTrue("Focused card needs side margins: ${row.id}:$column $bounds", bounds.left >= margin && bounds.right <= stage - margin)
assertTrue("Focused row must clear the nav: ${row.id}:$column $bounds", bounds.top >= WolfskinTokens.Layout.navBand.value * density)
assertTrue("Focused card must clear the bottom: ${row.id}:$column $bounds", bounds.bottom <= WolfskinTokens.Shell.stageHeight.value * density - margin)
}
private fun capture(name: String) {
val directory = File(System.getProperty("wolfskin.captureDirectory"), "home").apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
}

View file

@ -83,6 +83,7 @@ class LibraryCaptureTest {
@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 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 -> @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid ->
val original = grid.source val original = grid.source
grid.source = List(200) { index -> grid.source = List(200) { index ->

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,5 +1,7 @@
{ {
"title": { "title": {
"episodeCardHeight": {"dp": 140, "px": 280, "source": "feedback-2026-11-06: episode art height +24.4%"},
"seriesHeaderGap": {"dp": 20, "px": 40, "source": "feedback-2026-11-06: measured series header to seasons"},
"lineGap": {"dp": 11, "px": 22, "source": "index.html:214"}, "lineGap": {"dp": 11, "px": 22, "source": "index.html:214"},
"lineLift": {"dp": -2, "px": -4, "source": "index.html:214"}, "lineLift": {"dp": -2, "px": -4, "source": "index.html:214"},
"overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"}, "overviewGap": {"dp": 17, "px": 34, "source": "index.html:216"},

View file

@ -260,6 +260,8 @@
--lift-heroY: 30.0px; --lift-heroY: 30.0px;
--lift-sheetBlur: 80.0px; --lift-sheetBlur: 80.0px;
--lift-sheetX: -30.0px; --lift-sheetX: -30.0px;
--title-episodeCardHeight: 280.0px;
--title-seriesHeaderGap: 40.0px;
--title-lineGap: 22.0px; --title-lineGap: 22.0px;
--title-lineLift: -4.0px; --title-lineLift: -4.0px;
--title-overviewGap: 34.0px; --title-overviewGap: 34.0px;
@ -436,6 +438,7 @@
--home-savedIcon: 28.0px; --home-savedIcon: 28.0px;
--home-savedRadius: 28.0px; --home-savedRadius: 28.0px;
--home-posterProgressBottom: 12.0px; --home-posterProgressBottom: 12.0px;
--discover-shelfHeadingBand: 104.0px;
--discover-sheetPaddingX: 64.0px; --discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px; --discover-sheetPaddingY: 80.0px;
--discover-sheetGap: 18.0px; --discover-sheetGap: 18.0px;

View file

@ -1,5 +1,5 @@
{ {
"$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (originally data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...; now amended in data/tv-v2/spacing-2026-11-06/index.html, sha256 fcc5d879...). 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). Amended 2026-11-06 (spec revision 3, Level 2 spacing + card ring; data/tv-card-spacing/answers.md): card, layout.row*/gridRowGap and focus.ringCard changed; every other token is as extracted. The prototype source line numbers in `source` fields predate the amendment and drift by a few lines after index.html:8.", "$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.",
"meta": { "meta": {
"density": 2.0, "density": 2.0,
"stagePx": [ "stagePx": [
@ -10,9 +10,10 @@
960, 960,
540 540
], ],
"source": "data/tv-v2/spacing-2026-11-06/index.html", "source": "data/tv-v2/focus-2026-11-06/index.html",
"sourceSha256": "fcc5d879c34da868b817f8d9d306193743d3933db3096da605bda659d8e633b2", "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d",
"generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py",
"feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json"
}, },
"color": { "color": {
"bg": { "bg": {
@ -20,7 +21,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",
@ -1338,33 +1339,48 @@
"outsidePx": 2, "outsidePx": 2,
"insidePx": 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",
@ -1494,13 +1510,23 @@
}, },
"heroRequest": { "heroRequest": {
"px": [ "px": [
1280, 1920,
720 1080
], ],
"decode": "ARGB_8888", "decode": "ARGB_8888",
"bytes": 3686400, "bytes": 8294400,
"source": "index.html:549", "source": "feedback-2026-11-06: panel-resolution artwork",
"usedBy": "Home hero, Discover hero, title backdrop: the 1280x720 source, never upscaled; released when scrolled past" "usedBy": "Home hero, Discover hero and title backdrop; bounded 1920x1080 decode"
},
"heroPosterRequest": {
"px": [420, 630],
"decode": "RGB565",
"source": "feedback-2026-11-06: largest upright hero poster slot"
},
"episodeRequest": {
"px": [400, 280],
"decode": "ARGB_8888",
"source": "feedback-2026-11-06: taller episode cards"
}, },
"castRequest": { "castRequest": {
"px": [ "px": [

View file

@ -1,8 +1,8 @@
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt bb64e45654fce2cd3ba595b505fb8b5a540526cef696dfa974be25f028d3e119 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css 36d5b2646dff2e4055d2683e4c284111a6886c311e9c924b73061fa785bc863d tokens.css
6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json 3226c6fb9c7e35c5bde90aaf01630b238cfcb9a768ff60c49461e19a200c9125 tokens.json