Compare commits
8 commits
fm/tv-opt-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5162c299db | |||
| 278335f009 | |||
| deb24e3c9f | |||
| 556d633e88 | |||
| 34087d8eb5 | |||
| 33dc65bf18 | |||
| ef3ae3f21b | |||
| d0e38306b3 |
38 changed files with 11336 additions and 107 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
2568
app/src/test/resources/conductarr/activity-history.json
Normal file
File diff suppressed because it is too large
Load diff
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
8143
app/src/test/resources/conductarr/mindel-feed.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
docker/README.md
115
docker/README.md
|
|
@ -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
|
||||||
|
|
@ -35,8 +35,9 @@ dependency baseline profiles. It changes only signing (the Android debug key),
|
||||||
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
package (`org.justinvisser.wolfskin.tv.opt`) and launcher label (`Wolfskin (Opt)`).
|
||||||
The launcher icon/banner and release-compatible version name are unchanged:
|
The launcher icon/banner and release-compatible version name are unchanged:
|
||||||
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
the shared upgrade/updater parser rejects extra suffixes such as `-opt`.
|
||||||
`make apk` remains the clean, release-key-signed production build; neither its
|
`make apk` remains the clean, release-key-signed production build. Debug and
|
||||||
signing flow nor the existing debug build changes.
|
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
|
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
|
wrapper, without a release keystore. On a host whose Gradle cache already holds
|
||||||
|
|
@ -212,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.
|
||||||
|
|
@ -350,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
|
||||||
|
|
@ -409,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
|
||||||
|
|
@ -462,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.
|
||||||
|
|
@ -483,6 +511,32 @@ 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.
|
||||||
|
|
||||||
## 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,
|
||||||
|
|
@ -788,6 +842,41 @@ whose parent directory already exists. All callers sharing a volume must use
|
||||||
the same lock path on the Docker host. Do not delete the file while builds are
|
the same lock path on the Docker host. Do not delete the file while builds are
|
||||||
running or waiting. The lock releases automatically when the invocation exits.
|
running or waiting. The lock releases automatically when the invocation exits.
|
||||||
|
|
||||||
|
## Persistent debug signing
|
||||||
|
|
||||||
|
`make apk-debug` and non-release `docker/gradle.sh` invocations (including
|
||||||
|
`:app:assembleDefaultOptimized`) use one host keystore, outside the checkout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/root/.treehouse/firstmate-7bab20/2/firstmate/data/signing/debug.keystore
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `WOLFSKIN_DEBUG_KEYSTORE` to another file in a dedicated directory outside
|
||||||
|
the checkout when building on another host, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
WOLFSKIN_DEBUG_KEYSTORE="$HOME/.local/share/wolfskin-signing/debug.keystore" make apk-debug
|
||||||
|
```
|
||||||
|
|
||||||
|
The wrapper creates a missing key once using the build image's `keytool`: RSA
|
||||||
|
2048, 10,000-day validity, alias `androiddebugkey`, store/key passwords `android`,
|
||||||
|
and subject `CN=Android Debug`. The containing directory is mode `0700`, the
|
||||||
|
file mode `0600`. Existing keys are reused, never regenerated; generation errors
|
||||||
|
abort the build rather than falling back to an ephemeral container key.
|
||||||
|
The file is bind-mounted read-only at `/tmp/.android/debug.keystore`, with
|
||||||
|
`ANDROID_USER_HOME=/tmp/.android`, for every non-release Gradle container.
|
||||||
|
Keep and back up this file across clean builds, disposable worktrees, container
|
||||||
|
rebuilds and Gradle cache resets. Never commit it. The standard debug passwords
|
||||||
|
are public; this key is for local debug/optimized builds, not release signing.
|
||||||
|
|
||||||
|
**One-time reinstall:** the first build with the stable key still needs one
|
||||||
|
uninstall+reinstall on the box because the signer changes once; later ones
|
||||||
|
install in place with `adb install -r`, retaining sign-ins and settings.
|
||||||
|
The initial uninstall removes app data, so sign in again once after that switch.
|
||||||
|
Deleting/replacing the persistent key or choosing a different override changes
|
||||||
|
the signer again and requires another reinstall.
|
||||||
|
|
||||||
|
|
||||||
## Release credentials
|
## Release credentials
|
||||||
|
|
||||||
The default escrow is
|
The default escrow is
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
|
||||||
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
|
||||||
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
|
||||||
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, 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) {
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) {
|
||||||
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f)
|
||||||
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
.onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) }
|
||||||
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
.titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) {
|
||||||
PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, 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)
|
||||||
|
|
@ -115,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier
|
||||||
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
|
||||||
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt)
|
||||||
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
} else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, 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))))
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
state.focusPerson(index); state.key(ShellKey.Ok)
|
state.focusPerson(index); state.key(ShellKey.Ok)
|
||||||
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
}, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker,
|
||||||
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg))
|
Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing))
|
||||||
Spacer(Modifier.height(S.pickerNameGap))
|
Spacer(Modifier.height(S.pickerNameGap))
|
||||||
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
|
Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName)
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ private fun PeoplePicker(state: ShellState) {
|
||||||
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
|
val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1)
|
||||||
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
|
val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2
|
||||||
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg)
|
.focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing)
|
||||||
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
.clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) {
|
||||||
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon))
|
||||||
if (state.gearFocused) {
|
if (state.gearFocused) {
|
||||||
|
|
@ -106,7 +106,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String
|
||||||
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) {
|
||||||
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
|
||||||
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button)
|
||||||
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
|
||||||
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
|
||||||
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.",
|
||||||
"meta": {
|
"meta": {
|
||||||
"density": 2.0,
|
"density": 2.0,
|
||||||
"stagePx": [
|
"stagePx": [
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
960,
|
960,
|
||||||
540
|
540
|
||||||
],
|
],
|
||||||
"source": "data/tv-v2/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"
|
||||||
},
|
},
|
||||||
"color": {
|
"color": {
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
"$value": "#07070a",
|
"$value": "#07070a",
|
||||||
"css": "#07070a",
|
"css": "#07070a",
|
||||||
"source": "index.html:10",
|
"source": "index.html:10",
|
||||||
"usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid"
|
"usedBy": "app background; stage; hero/detail scrim end; nav band when solid"
|
||||||
},
|
},
|
||||||
"surface": {
|
"surface": {
|
||||||
"$type": "color",
|
"$type": "color",
|
||||||
|
|
@ -1338,33 +1338,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",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py
|
||||||
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
|
||||||
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
|
||||||
4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json
|
8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json
|
||||||
b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css
|
b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css
|
||||||
6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json
|
3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue