Reuse shared artwork and correct title focus edges

This commit is contained in:
justinzeus 2026-11-05 19:58:36 +01:00
parent 3bfb8cd779
commit a8951827ba
41 changed files with 581 additions and 438 deletions

View file

@ -134,7 +134,11 @@ class TitleCaptureTest {
@Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") }
@Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") }
@Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") }
@Test fun st052_filmCast() = capture(52) { state, _ -> state.focus("cast", 0) }
@Test fun st052_filmCast() = capture(52) { state, shell ->
state.focus("cast", 0)
state.key(ShellKey.Right, true, 0, shell)
state.key(ShellKey.Right, false, 0, shell)
}
@Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) }
@Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) }
@Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) })
@ -162,7 +166,9 @@ class TitleCaptureTest {
@Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man")
@Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") })
@Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.Offline) })
@Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = {
it.copy(connection = TitleConnection.Offline, savedAt = "20:42", item = it.item.copy(ratings = emptyList()))
})
@Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) }
@Test fun st106_heldRatings() = capture(106, "American Gangster", "shared")
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
@ -9,6 +10,10 @@ import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.justinvisser.wolfskin.design.WolfskinTokens
import org.json.JSONArray
import org.json.JSONObject
@ -50,7 +55,10 @@ class TitleKeysTest {
private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) {
shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) }
state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.setContent { WolfskinTheme {
BackHandler { state.back(shell) }
TitleSurface(state, shell)
} }
}
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
@ -251,4 +259,34 @@ class TitleKeysTest {
press(Key.DirectionDown)
assertEquals(expected, state.row)
}
@Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() {
render("Andor")
val layouts = mutableListOf<TextLayoutResult>()
rule.onNodeWithText("Add for someone", useUnmergedTree = true)
.performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) }
val layout = layouts.single()
assertFalse("The final pill must not shrink to its icon or a clipped label", layout.didOverflowWidth)
assertEquals(1, layout.lineCount)
}
@Test fun anInitialLoadNeverExposesStaleCastBelowItsSkeleton() {
render(transform = { it.copy(status = TitleReadStatus.Loading) })
rule.onNodeWithText("Cast").assertDoesNotExist()
press(Key.DirectionDown)
assertTrue(shell.navFocused)
assertEquals("header", state.row)
rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() }
assertEquals(listOf(TitleAction.Pop), effects)
}
@Test fun unratedFactsDoNotReserveAnEmptyRatingsSlot() {
render("The Amazing Spider-Man", transform = {
it.copy(item = it.item.copy(ratings = emptyList(), genres = listOf("Drama"), runtimeSeconds = 0),
cast = emptyList(), related = emptyList())
})
val year = rule.onNodeWithText("2012", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f)
}
}

View file

@ -18,7 +18,7 @@ import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitleRating
import org.justinvisser.wolfskin.design.LibraryRating
import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.design.TitleSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
@ -39,14 +39,14 @@ class TitleLocaleTest {
try {
Locale.setDefault(locale)
val person = ShellPerson("justin", "Justin", "J", false)
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(TitleRating("tmdb", 7.8, "tmdb")))
val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(LibraryRating("tmdb", 7.8)))
val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") }
val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {}
state.focus("related", 0)
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
rule.mainClock.advanceTimeBy(1000)
val layouts = mutableListOf<TextLayoutResult>()
rule.onAllNodesWithText(expected).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
rule.onAllNodesWithText(expected, useUnmergedTree = true).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) }
assertEquals(1, layouts.single().lineCount)
assertFalse(layouts.single().didOverflowWidth)
} finally { Locale.setDefault(previous) }

View file

@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
import android.app.Application
import android.content.Context
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinUser
@ -12,8 +13,11 @@ import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.ui.successQueryResult
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
@ -29,6 +33,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.junit.Assert.*
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -40,6 +45,7 @@ import org.justinvisser.wolfskin.design.TitleState
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.RuntimeEnvironment
import java.util.UUID
/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */
@ -59,9 +65,15 @@ class TitleReadFacadeTest {
private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002")
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004")
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString()
private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString().replace("-", "")
@Before fun setup() {
// BaseItem formats runtime using application resources; no Hilt/network application is started.
val app = mockk<WholphinApplication> {
every { resources } returns RuntimeEnvironment.getApplication().resources
}
mockkObject(WholphinApplication.Companion)
every { WholphinApplication.instance } returns app
every { api.userLibraryApi } returns library
every { api.itemsApi } returns items
every { api.tvShowsApi } returns shows
@ -72,6 +84,10 @@ class TitleReadFacadeTest {
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
}
@After fun teardown() {
unmockkObject(WholphinApplication.Companion)
}
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto {
val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo()
val streams = source.mediaStreams.orEmpty().map { stream ->
@ -142,4 +158,12 @@ class TitleReadFacadeTest {
assertEquals(listOf(2), data.seasonNumbers)
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
}
@Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest {
coEvery { library.getItem(firstId) } returns successResponse(file(firstId))
coEvery { playback.savePlayVersion(any(), any()) } returns null
val data = facade.read(firstId, null, preferences)
facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single()))
coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) }
}
}

View file

@ -235,8 +235,10 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen
LaunchedEffect(title?.data?.status) {
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
}
BackHandler { title?.back() }
val person = viewModel.person
BackHandler(enabled = title != null) {
title?.back(shell)
}
if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier)
else if (title != null) TitleSurface(title, shell, modifier)
}

View file

@ -14,7 +14,7 @@ import org.justinvisser.wolfskin.design.TitleConnection
import org.justinvisser.wolfskin.design.TitleItem
import org.justinvisser.wolfskin.design.TitlePageData
import org.justinvisser.wolfskin.design.TitlePerson
import org.justinvisser.wolfskin.design.TitleRating
import org.justinvisser.wolfskin.design.LibraryRating
import org.justinvisser.wolfskin.design.TitleTrack
import org.justinvisser.wolfskin.design.TitleVersion
import java.util.Locale
@ -80,23 +80,23 @@ class TitleFixture(private val fixture: JSONObject) {
private fun art(path: String?): String? = path?.takeIf { it in availableArt }?.let { "file:///android_asset/wolfskin-titles/$it" }
private fun ratings(item: JSONObject): List<TitleRating> {
private fun ratings(item: JSONObject): List<LibraryRating> {
val episode = item.optString("type") == "episode"
if (episode) {
val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") }
val base = series?.let { ratings(it) }.orEmpty()
val score = item.optDouble("communityRating", Double.NaN)
return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + TitleRating("tmdb", score, "tmdb", true) else base
return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + LibraryRating("tmdb", score, episode = true) else base
}
val ratings = item.optJSONObject("ratings") ?: JSONObject().apply {
if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating"))
if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating"))
}
return buildList {
if (!ratings.isNull("rtCritics")) add(TitleRating("rtCritics", ratings.getDouble("rtCritics"), when (ratings.optString("rtCriticsLabel")) { "Rotten" -> "rotten"; "Fresh", "Certified Fresh" -> "fresh"; else -> "tomato" }))
if (!ratings.isNull("rtAudience")) add(TitleRating("rtAudience", ratings.getDouble("rtAudience"), if (ratings.optString("rtAudienceLabel") == "Spilled") "spilled" else "upright"))
if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(TitleRating("imdb", ratings.getDouble("imdb"), "imdb"))
if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(TitleRating("tmdb", ratings.getDouble("tmdb"), "tmdb"))
if (!ratings.isNull("rtCritics")) add(LibraryRating("rtCritics", ratings.getDouble("rtCritics"), ratings.optString("rtCriticsLabel").takeIf { it.isNotEmpty() }))
if (!ratings.isNull("rtAudience")) add(LibraryRating("rtAudience", ratings.getDouble("rtAudience"), ratings.optString("rtAudienceLabel").takeIf { it.isNotEmpty() }))
if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(LibraryRating("imdb", ratings.getDouble("imdb")))
if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(LibraryRating("tmdb", ratings.getDouble("tmdb")))
}.filter { it.score in 0.0..if (it.source.startsWith("rt")) 100.0 else 10.0 }
}

View file

@ -1,31 +0,0 @@
package org.justinvisser.wolfskin.tv
import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.disk.directory
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import dagger.hilt.android.qualifiers.ApplicationContext
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import org.justinvisser.wolfskin.design.WolfskinTokens
import javax.inject.Inject
import javax.inject.Singleton
/** All owned screens share byte-bounded caches and four image reads rather than a heap percentage. */
@Singleton
class WolfskinImageLoader @Inject constructor(@param:ApplicationContext context: Context) {
private val client = OkHttpClient.Builder().dispatcher(Dispatcher().apply {
maxRequests = WolfskinTokens.Image.concurrentReadsImage
maxRequestsPerHost = maxRequests
}).build()
val loader: ImageLoader = ImageLoader.Builder(context)
.memoryCache(MemoryCache.Builder().maxSizeBytes(WolfskinTokens.Image.memoryCacheMB.toLong() * 1024 * 1024).build())
.diskCache(DiskCache.Builder().directory(context.cacheDir.resolve("wolfskin-images"))
.maxSizeBytes(WolfskinTokens.Image.diskCacheMB.toLong() * 1024 * 1024).build())
.crossfade(false)
.components { add(OkHttpNetworkFetcherFactory(callFactory = { client })) }
.build()
}

View file

@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import org.json.JSONObject
import org.justinvisser.wolfskin.design.TitleAction
import org.justinvisser.wolfskin.design.TitleConnection
@ -97,7 +98,10 @@ class TitleReadFacade @Inject constructor(
/** Track selection uses the same repository consumed by the upstream player. */
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
when (action) {
is TitleAction.Version -> playback.savePlayVersion(UUID.fromString(action.item.id), UUID.fromString(action.version.id))
is TitleAction.Version -> playback.savePlayVersion(
UUID.fromString(action.item.id),
requireNotNull(action.version.id.toUUIDOrNull()) { "Invalid Jellyfin media source identifier" },
)
is TitleAction.Track -> {
val id = UUID.fromString(action.item.id)
val item = BaseItem(api.userLibraryApi.getItem(id).content)

View file

@ -59,8 +59,9 @@ cover the Wolfskin nav.
The independent `:wolfskin-design` module owns the shell components, fixed person
colours, focus policy, per-person root-view memory, palette and motion. Its
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
SHA and provenance. `shell-tokens.json` adds shell and grid measurements from that
same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
SHA and provenance. `shell-tokens.json` adds shell and grid measurements;
`title-tokens.json` adds title, performer and playback-sheet measurements from
that same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
```sh
python3 wolfskin-design/generate_tokens.py
@ -149,8 +150,10 @@ Home/Discover shelves open a full list through
The optional suspend page provider returns `LibraryPage(items, hasMore)` using the
generated Conductarr client. The grid reads within ten cards of its end, deduplicates
title identities, and pauses on Try again or Show more rather than looping.
Title activation pushes the explicitly named `library-title/{id}` destination for
the title-screen implementation; this slice does not implement title pages.
Title activation opens `title/{id}` through `Destination.Wolfskin` for prototype
identities, or the existing typed media destination for real Jellyfin identities.
Movies, series, episodes and people use the native title family; collections and
music keep their upstream destinations.
`LibraryCaptureTest` renders the sixteen approved library/See all states offline.
`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly
@ -158,6 +161,57 @@ the title-screen implementation; this slice does not implement title pages.
failure/retry, stale copies, and person/server isolation. Captures are written to
the same `build/wolfskin-captures/` directory as the shell captures.
## Title pages and people
`TitleSurface` owns the film/series/episode header, season chips, episode tiles,
cast and related shelves. `PersonSurface` owns the real biography and continuous
six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`,
the adaptive `LibraryRatingPlate`, shared captions and the single bounded
`LibraryImages` loader. Image requests use exact slot sizes, RGB565 for opaque
posters, no crossfade, four concurrent reads and next-row prefetch. The fixed
title backdrop remains visible behind deep rows; only off-screen row images
leave composition.
The options row preserves complete labels beyond the copy-column measure.
Version choices close after selection; Audio and subtitles stays open, including
an explicit Off choice. Real source IDs use the Jellyfin SDK UUID parser, and
stream indexes remain the server's indexes rather than positions in a list.
Selections persist through the same playback repository as the upstream player.
Marking a whole series asks first; an explicit episode remains the exact play and
watched target even while another season is selected. Nested person/title visits
retain their entry's focus and selection state.
Hardware and remote Back share the sheet/picker/request-overlay precedence, then
pop a pushed title/person from content **or** nav. Initial loading has no content
target: Down stays in nav, while Back can still leave the title. A missing rating
set emits no layout node, so it cannot reserve an invisible extra facts gap.
`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies,
person credits and playback mutations to one boundary. Cached content appears
before refresh. Production ratings stay hidden until Conductarr sign-in exists.
Debug-only `wolfskin-titles.json` and local `wolfskin-titles/assets/` project the
locked household fixture, including portraits; release never packages them.
Those synthetic IDs are not invented Jellyfin playback IDs. Real held-title
playback uses the existing player; debug catalogue identities cannot play a file.
Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an
installed YouTube handler such as SmartTube, never a browser. No handler leaves
the title visible with a calm message. Titles nobody holds have no Trailer
button until Conductarr's trailer field exists. Conductarr sign-in and request
submission/receipt workflows belong to their separate slices; title pages do not
pretend those operations succeeded or fall back to Seerr.
`TitleCaptureTest` writes the 34 approved title/person/options frames and native
heading layout measurements at 1920×1080, density 2.0. The approved typeface ruling
keeps platform Roboto rather than bundling the browser's DejaVu Sans: there is no
per-title compensation and no wrapping-parity claim. `TitleKeysTest` replays
real Android key events from title entry, including modal Back, held OK, nearest
row entry, remembered columns, pending seasons and mouse-wheel re-entry.
`title-press-paths.json` also retains the complete launch paths and their entry
boundaries; their Home prefixes require the Home surface before end-to-end replay.
SDK cache, exact-episode, track-index, identity, locale-layout and trailer-handler
tests run entirely offline with in-process service replies and local art.
## Toolchain and disk use
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools

View file

@ -325,6 +325,9 @@
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
"captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"},
"badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"},
"badgeBorder": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"},
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
"plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"},
"plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"},

View file

@ -11,38 +11,60 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.constrainHeight
/** Captions fade without moving the reserved band under the focused poster. */
@Composable
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false) {
fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) {
val alpha = remember(title, facts) { Animatable(0f) }
LaunchedEffect(title, facts, visible) {
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
}
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
val styledFacts = remember(facts, accentLastFact) {
buildAnnotatedString {
val separator = " · "
append(facts)
if (accentLastFact) {
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
}
var start = facts.indexOf(separator)
if (start >= 0) {
val muted = WolfskinTokens.Color.muted
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
val dotOffset = separator.indexOf('·')
while (start >= 0) {
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
start = facts.indexOf(separator, start + separator.length)
}
}
}
}
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
CaptionFacts(facts, accentFirstFact, accentLastFact)
}
}
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
@Composable
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean) {
val parts = remember(facts) { facts.split(" · ") }
val muted = WolfskinTokens.Color.muted
val colour = WolfskinTokens.Color.accentText
val style = WolfskinTokens.Type.caption
Layout(
content = {
parts.forEachIndexed { index, part ->
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
Text(part, color = if (accented) colour else muted, style = style, maxLines = 1, overflow = TextOverflow.Clip)
if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style)
}
},
modifier = Modifier.fillMaxWidth().clipToBounds(),
) { children, constraints ->
val gap = WolfskinTokens.Library.captionFactsGap.roundToPx()
val basis = IntArray(children.size)
var fixed = gap * (children.size - 1).coerceAtLeast(0)
var flexible = 0
fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast)
children.forEachIndexed { index, child ->
val width = child.maxIntrinsicWidth(constraints.maxHeight)
basis[index] = width
if (shrinks(index)) flexible += width else fixed += width
}
val available = (constraints.maxWidth - fixed).coerceAtLeast(0)
val ratio = if (flexible > available) available.toFloat() / flexible else 1f
val measured = children.mapIndexed { index, child ->
val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index]
child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0))
}
layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) {
var x = 0
measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap }
}
}
}

View file

@ -6,23 +6,36 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.tv.material3.Icon
/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */
/** Shared glyph geometry comes from the locked prototype, never a platform icon font. */
@Composable
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"gear" -> R.drawable.wolfskin_gear
"requests" -> R.drawable.wolfskin_tray
"interface" -> R.drawable.wolfskin_grid
"playback" -> R.drawable.wolfskin_play
"nextup" -> R.drawable.wolfskin_tv
"profile" -> R.drawable.wolfskin_users
"play", "playback" -> R.drawable.wolfskin_play
"tv", "nextup" -> R.drawable.wolfskin_tv
"users", "profile" -> R.drawable.wolfskin_users
"about" -> R.drawable.wolfskin_info
"seerr" -> R.drawable.wolfskin_sparkle
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
"down" -> R.drawable.wolfskin_down
"restart" -> R.drawable.title_restart
"version" -> R.drawable.title_version
"tracks" -> R.drawable.title_tracks
"eyeoff" -> R.drawable.title_eyeoff
"trailer" -> R.drawable.title_trailer
"more" -> R.drawable.title_more
"plus" -> R.drawable.title_plus
"clock" -> R.drawable.title_clock
"download" -> R.drawable.title_download
"wifi" -> R.drawable.title_wifi
"check" -> R.drawable.title_check
"undo" -> R.drawable.title_undo
"left" -> R.drawable.title_left
else -> R.drawable.wolfskin_right
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)

View file

@ -5,26 +5,41 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.derivedStateOf
import java.util.Locale
import androidx.compose.runtime.Immutable
private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE)
/** Art-only card data shared without per-card adapter allocations. */
@Immutable
interface PosterContent {
val id: String
val posterTitle: String
val posterIsMovie: Boolean
val poster: String?
val ratings: List<LibraryRating>
val progress: Float
val badge: String?
}
/** Immutable title facts cross the provider boundary without SDK objects. */
data class LibraryTitle(
val id: String,
override val id: String,
val title: String,
val type: String,
val year: Int? = null,
val runtimeSeconds: Long? = null,
val seasons: Int? = null,
val poster: String? = null,
override val poster: String? = null,
val played: Boolean = false,
val progress: Float = 0f,
override val progress: Float = 0f,
val addedAt: String = "",
val ratings: List<LibraryRating> = emptyList(),
override val ratings: List<LibraryRating> = emptyList(),
val availability: String = "",
val badge: String? = null,
override val badge: String? = null,
val tmdbId: String? = null,
) {
) : PosterContent {
override val posterTitle: String get() = title
override val posterIsMovie: Boolean get() = type == "movie"
val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
val catalogueKey: String = "$type:${tmdbId ?: id}"
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
@ -40,7 +55,8 @@ data class LibraryTitle(
}
/** Ordered rating entries are formatted after the active TV locale is known. */
data class LibraryRating(val source: String, val score: Double, val label: String? = null) {
@Immutable
data class LibraryRating(val source: String, val score: Double, val label: String? = null, val episode: Boolean = false) {
fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score)
}

View file

@ -26,7 +26,7 @@ object LibraryImages {
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
.crossfade(false).build().also { loader = it }
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
.allowRgb565(true).crossfade(false).build()
fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth,
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
}

View file

@ -1,9 +1,8 @@
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -11,20 +10,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
@ -32,92 +21,46 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
@Composable
fun LibraryPoster(
title: LibraryTitle,
title: PosterContent,
focused: Boolean,
ratingsVisible: Boolean,
catalogue: Boolean,
modifier: Modifier = Modifier,
offlineArt: ((String) -> ImageBitmap?)? = null,
) {
val poster = title.poster
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.testTag("poster-${title.id}")) {
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (title.poster != null && offlineArt == null) {
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
else if (poster != null && offlineArt == null) {
var ready by remember(poster) { mutableStateOf(false) }
if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
val context = LocalContext.current
val request = remember(title.poster, context) { LibraryImages.request(context, title.poster) }
AsyncImage(request, title.title, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
} else MissingLibraryArt(title)
val request = remember(poster, context) { LibraryImages.request(context, poster) }
AsyncImage(request, title.posterTitle, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
onSuccess = { ready = true }, onError = { ready = false })
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
if (title.progress > 0f && title.progress < 1f) {
val progress = title.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape))
Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape))
}
}
if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset)
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape), contentAlignment = Alignment.Center) {
Text(if (title.badge == "held") "✓" else if (title.badge == "failed") "!" else "◷", color = if (title.badge == "held") C.onFocus else C.accent, style = WolfskinTokens.Type.button)
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape)
.then(if (title.badge != "held") Modifier.border(L.badgeBorder, if (title.badge == "failed") C.fail else C.accent, CircleShape) else Modifier),
contentAlignment = Alignment.Center) {
Glyph(if (title.badge == "held") "check" else if (title.badge == "failed") "alert" else "clock",
if (title.badge == "held") C.onFocus else if (title.badge == "failed") C.fail else C.accent, Modifier.size(L.badgeIcon))
}
}
}
}
@Composable
private fun MissingLibraryArt(title: LibraryTitle) {
val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() }
Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf(
Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop),
Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom))))
.padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) {
Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon))
Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4)
}
}
/** A measured prefix stays on one line, shrinking only to the approved type floor. */
@Composable
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val available = with(density) { (WolfskinTokens.Card.posterW - L.plateSide * 2).toPx() }
val formatted = ratings.map { it.formatted() }
fun width(count: Int, size: TextUnit): Float = (0 until count).sumOf { index ->
val labelWidth = measurer.measure(formatted[index], WolfskinTokens.Type.ratingPlate.copy(fontSize = size)).size.width
val pixels = with(density) { size.toPx() }
(labelWidth + pixels * (if (ratings[index].source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f) + pixels * L.markGapEm + if (index > 0) pixels * L.plateGapEm else 0f).toDouble()
}.toFloat()
var size = WolfskinTokens.Type.ratingPlate.fontSize
var count = ratings.size
while (size > L.plateFloor.fontSize && width(count, size) > available) size = (size.value - L.plateStep).coerceAtLeast(L.plateFloor.fontSize.value).sp
while (count > 1 && width(count, size) > available) count--
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "library-plate")
Row(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }.background(Brush.verticalGradient(
0f to C.bg.copy(alpha = L.plateOpacity), L.plateMidStop to C.bg.copy(alpha = L.plateMidOpacity), 1f to Color.Transparent))
.padding(start = L.plateSide, end = L.plateSide, top = L.plateTop, bottom = L.plateBottom),
horizontalArrangement = Arrangement.spacedBy(with(density) { (size.toPx() * L.plateGapEm).toDp() }), verticalAlignment = Alignment.CenterVertically) {
repeat(count) { index ->
val rating = ratings[index]
val mark = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
val markHeight = with(density) { size.toDp() }
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
Row(horizontalArrangement = Arrangement.spacedBy(markHeight * L.markGapEm), verticalAlignment = Alignment.CenterVertically) {
Image(androidx.compose.ui.res.painterResource(mark), rating.source,
Modifier.size(markHeight * if (rating.source in listOf("imdb", "tmdb")) L.wordmarkRatio else 1f, markHeight)
.graphicsLayer { this.alpha = if (unknownCritic) L.unknownCriticOpacity else 1f },
colorFilter = if (unknownCritic) desaturated else null)
Text(formatted[index], color = C.fg, style = WolfskinTokens.Type.ratingPlate.copy(fontSize = size), maxLines = 1, softWrap = false)
}
}
}
}

View file

@ -105,7 +105,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier,
.offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2)
.height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button))
.padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("wifi", C.warn, Modifier.size(V.icon))
Glyph("wifi", C.warn, Modifier.size(V.icon))
Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption)
}
}

View file

@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier
Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
row.forEachIndexed { column, item ->
val index = rowIndex * columns + column
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), onFocus = { state.index = index }) { state.click(index) }
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) }
}
}
}

View file

@ -0,0 +1,105 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as P
/** A full facts-line set never shrinks or waits for focused-card dwell. */
@Composable
fun TitleRatings(ratings: List<LibraryRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.ratingPlate, plate: Boolean = false) {
if (ratings.isEmpty()) return
val density = LocalDensity.current
val markHeight = with(density) { style.fontSize.toDp() }
val lineHeight = style.fontSize * (WolfskinTokens.Type.ratingPlate.lineHeight.value / WolfskinTokens.Type.ratingPlate.fontSize.value)
val textStyle = if (style.lineHeight == lineHeight) style else style.copy(lineHeight = lineHeight)
val numberStyle = remember(textStyle) { textStyle.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold) }
val gap = if (plate) with(density) { (style.fontSize * P.plateGapEm).toDp() } else D.ratingGap
val markGap = if (plate) with(density) { (style.fontSize * P.markGapEm).toDp() } else D.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = textStyle.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
val resource = when (rating.source) {
"rtCritics" -> if (rating.label == "Rotten") R.drawable.ic_rotten_tomatoes_rotten else R.drawable.ic_rotten_tomatoes_fresh
"rtAudience" -> if (rating.label == "Spilled") R.drawable.ic_rt_spilled else R.drawable.ic_rt_upright
"imdb" -> R.drawable.ic_rating_imdb
else -> R.drawable.ic_rating_tmdb
}
val unknownCritic = rating.source == "rtCritics" && rating.label != "Fresh" && rating.label != "Certified Fresh" && rating.label != "Rotten"
val desaturated = if (unknownCritic) remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) } else null
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f, markHeight),
alpha = if (unknownCritic) P.unknownCriticOpacity else 1f, colorFilter = desaturated)
Text(rating.formatted(), color = C.fg, style = numberStyle, maxLines = 1)
}
}
}
}
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
@Composable
fun LibraryRatingPlate(ratings: List<LibraryRating>, modifier: Modifier = Modifier, visible: Boolean = true) {
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val alpha by animateFloatAsState(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.base), label = "rating-plate")
BoxWithConstraints(modifier.fillMaxWidth().graphicsLayer { this.alpha = alpha }
.background(Brush.verticalGradient(0f to C.bg.copy(alpha = P.plateOpacity), P.plateMidStop to C.bg.copy(alpha = P.plateMidOpacity), 1f to C.bg.copy(alpha = 0f)))
.padding(start = P.plateSide, end = P.plateSide, top = P.plateTop, bottom = P.plateBottom)) {
val width = with(density) { maxWidth.toPx() }
val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
var count = ratings.size
val numbers = ratings.map { it.formatted() }
fun measured(): Float {
val fontSize = TextUnit(size, TextUnitType.Sp)
val style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize, fontFeatureSettings = "tnum")
val markGap = with(density) { (fontSize * P.markGapEm).toPx() }
val entryGap = with(density) { (fontSize * P.plateGapEm).toPx() }
var total = 0f
for (index in 0 until count) {
val rating = ratings[index]
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") P.wordmarkRatio else 1f
total += markGap
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
if (index > 0) total += entryGap
}
return total
}
while (size > P.plateFloor.fontSize.value && measured() > width) size = (size - P.plateStep).coerceAtLeast(P.plateFloor.fontSize.value)
while (count > 1 && measured() > width) count--
count to size
}
val fontSize = TextUnit(fit.second, TextUnitType.Sp)
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.ratingPlate.copy(fontSize = fontSize,
lineHeight = WolfskinTokens.Type.ratingPlate.lineHeight * (fit.second / WolfskinTokens.Type.ratingPlate.fontSize.value)), plate = true)
}
}

View file

@ -15,6 +15,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.ShaderBrush
@ -28,8 +33,8 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import coil3.SingletonImageLoader
import coil3.compose.AsyncImage
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -46,9 +51,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
else if (path != null) {
val context = LocalContext.current
val request = remember(context, path, width, height, poster) {
titleImageRequest(context, path, width, height, poster)
LibraryImages.request(context, path, width, height, poster)
}
AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context),
AsyncImage(model = request, imageLoader = LibraryImages.loader(context),
contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter)
}
}
@ -93,40 +98,57 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) {
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
ColorFilter.colorMatrix(matrix)
}
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize().blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight,
Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale }
.blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter)
val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) }
val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) }
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
.size(L.heroSheetWidth, L.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)))
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
}
Box(Modifier.fillMaxSize().background(horizontal))
Box(Modifier.fillMaxSize().background(vertical))
}
}
/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */
/** Library, related and person posters share the exact locked missing-art treatment. */
@Composable
fun TitleCardArtwork(item: TitleItem, tile: Boolean) {
Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) {
TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4,
internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) {
Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) {
Glyph(if (movie) "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset))
if (tile && item.isPosterBackdrop && item.poster != null) {
}
}
/** Episode tiles use their backdrop, or the prototype's blurred poster and small one-sheet. */
@Composable
fun TitleTileArtwork(item: TitleItem) {
Box(Modifier.fillMaxSize()) {
MediaArtworkFallback(item.seriesName ?: item.name, item.kind == "movie", showTitle = false)
if (item.isPosterBackdrop && item.poster != null) {
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
}) }
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter)
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter)
val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2
val shape = remember { RoundedCornerShape(D.tileSheetRadius) }
val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) }
TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(),
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true)
} else {
TitleArtwork(if (tile) item.backdrop else item.poster,
if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth,
if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight,
Modifier.fillMaxSize(), poster = !tile)
}
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
}
}
private fun Modifier.artOverscan(inset: Dp): Modifier = layout { measurable, constraints ->
val pixels = inset.roundToPx()
val width = constraints.maxWidth + pixels * 2
val height = constraints.maxHeight + pixels * 2
val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width, minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(-pixels, -pixels) }
}
private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache {
var hue = 0
name.forEach { hue = (hue * 31 + it.code) % 360 }

View file

@ -14,9 +14,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.testTag
import androidx.tv.material3.Icon
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
@ -33,42 +36,17 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
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)
.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)
.background(background, shape)
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
if (option.glyph.isNotEmpty()) TitleGlyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon))
if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1)
}
}
/** Title glyphs are the locked prototype paths, independent of platform icon substitutions. */
@Composable
fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier) {
val resource = when (name) {
"play" -> R.drawable.title_play
"restart" -> R.drawable.title_restart
"version" -> R.drawable.title_version
"tracks" -> R.drawable.title_tracks
"eyeoff" -> R.drawable.title_eyeoff
"eye" -> R.drawable.title_eye
"trailer" -> R.drawable.title_trailer
"more" -> R.drawable.title_more
"plus" -> R.drawable.title_plus
"clock" -> R.drawable.title_clock
"users" -> R.drawable.title_users
"tv" -> R.drawable.title_tv
"film" -> R.drawable.title_film
"alert" -> R.drawable.title_alert
"wifi" -> R.drawable.title_wifi
"check" -> R.drawable.title_check
"sparkle" -> R.drawable.title_sparkle
"undo" -> R.drawable.title_undo
else -> R.drawable.title_left
}
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
}
/** A radio remains visibly selected independently of the inverse focused row. */
@Composable

View file

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -38,7 +39,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
val type = if (item.kind == "movie") "Movie" else "Series"
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon))
Text(buildString {
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
if (item.held && state.watched) append(" · Watched")
@ -57,7 +58,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
state.data.requestedStage?.let { stage ->
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph(if (stage == "Ready") "check" else "clock", color, Modifier.size(D.lineIcon))
Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon))
Text(stage, color = color, style = WolfskinTokens.Type.label)
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine)
}
@ -79,7 +80,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie
if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
onFocus = { state.focus("header", index) }) { state.click("header", index) } }

View file

@ -5,7 +5,7 @@ import androidx.compose.runtime.Immutable
/** Screen data is independent of Jellyfin and Conductarr wire objects. */
@Immutable
data class TitleItem(
val id: String,
override val id: String,
val name: String,
val kind: String,
val year: Int? = null,
@ -13,7 +13,7 @@ data class TitleItem(
val seasons: Int = 0,
val overview: String = "",
val genres: List<String> = emptyList(),
val poster: String? = null,
override val poster: String? = null,
val backdrop: String? = null,
val held: Boolean = false,
val watched: Boolean = false,
@ -22,11 +22,15 @@ data class TitleItem(
val seriesName: String? = null,
val season: Int = 0,
val episode: Int = 0,
val ratings: List<TitleRating> = emptyList(),
override val ratings: List<LibraryRating> = emptyList(),
val character: String? = null,
val otherOwners: List<String> = emptyList(),
val isPosterBackdrop: Boolean = false,
) {
) : PosterContent {
override val posterTitle: String get() = name
override val posterIsMovie: Boolean get() = kind == "movie"
override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f
override val badge: String? get() = if (held) "held" else null
val episodeLabel: String get() = "S$season E$episode"
val runtime: String get() {
val minutes = (runtimeSeconds + 30) / 60
@ -34,10 +38,6 @@ data class TitleItem(
}
}
/** A rating is supplied only after sign-in and only when its source observed a valid score. */
@Immutable
data class TitleRating(val source: String, val score: Double, val mark: String, val episode: Boolean = false)
/** A cast identity can open a performer page without borrowing a title's identity. */
@Immutable
data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "")

View file

@ -31,6 +31,12 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.tv.material3.Text
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.layout.layout
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
@ -40,13 +46,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
val focus = remember { FocusRequester() }
LaunchedEffect(state.sheet) { focus.requestFocus() }
val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) }
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
.focusRequester(focus).onPreviewKeyEvent { event ->
val key = readShellKey(event)
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().background(C.surface)
Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface)
.verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop),
verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) {
when (state.sheet) {
@ -76,11 +83,11 @@ private fun TrackChoices(state: TitleState) {
val version = state.version ?: return
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}")
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label)
Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
version.audio.forEachIndexed { index, track ->
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
}
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label)
Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
val start = version.audio.size
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
version.subtitles.forEachIndexed { index, track ->
@ -121,10 +128,10 @@ private fun MediaInformation(state: TitleState) {
@Composable
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
Text(label, color = C.dim, style = WolfskinTokens.Type.label)
Column { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth()) {
Text(name, color = C.dim, style = WolfskinTokens.Type.caption, modifier = Modifier.width(D.infoLabelWidth))
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
}
} }
@ -143,7 +150,11 @@ private fun WatchedConfirmation(state: TitleState) {
@Composable
private fun SheetSubtitle(text: String) {
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.sheetSubBottom))
Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints ->
val child = measurable.measure(constraints)
val lift = D.sheetSubLift.roundToPx()
layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) }
}.padding(bottom = D.sheetSubBottom))
}
@Composable
@ -151,7 +162,7 @@ private fun HiddenReceipt(state: TitleState) {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop),
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("check", C.fg, Modifier.size(D.receiptIcon))
Glyph("check", C.fg, Modifier.size(D.receiptIcon))
Text("Hidden from your picks", color = C.fg, style = D.receiptTitle)
}
Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body)

View file

@ -52,7 +52,18 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
@Composable
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize()) {
Box(modifier.fillMaxSize().onPreviewKeyEvent { event ->
// No content target exists during an initial load; nav Down must not enter a skeleton.
if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false
else when (readShellKey(event)) {
ShellKey.Down -> true
ShellKey.Back -> {
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) state.back(shell)
true
}
else -> false
}
}) {
val focusOwner = remember(shell) { { shell.navFocused = false } }
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
@ -72,7 +83,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism
Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth)
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon))
Glyph("alert", C.warn, Modifier.size(L.buttonIcon))
Text(message, color = C.fg, style = WolfskinTokens.Type.body)
}
}
@ -91,6 +102,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
val displayedRows = remember(state.data) {
buildList {
add("header")
if (state.data.status != TitleReadStatus.Ready) return@buildList
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
add("seasons")
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
@ -117,7 +129,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
}
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } }
val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } }
val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } }
TitleRowPrefetch(state.data, nextArtRow)
@ -135,10 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie
}
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
}.focusable()) {
if (heroVisible) {
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
} else Box(Modifier.fillMaxSize().background(C.bg))
// This remains visible behind the scrolled rows; it is not an off-screen header image.
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") {
@ -193,7 +203,7 @@ private fun TitleReadError(state: TitleState) {
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon))
Glyph("alert", C.warn, Modifier.size(D.receiptIcon))
Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle)
}
Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom))

View file

@ -1,33 +1,21 @@
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.compositionLocalOf
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
import coil3.request.ImageRequest
import coil3.request.Disposable
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.ImageLoader
/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */
val LocalWolfskinImageLoader = compositionLocalOf<ImageLoader?> { null }
/** Visible and prefetched images use exactly the same size and decode policy. */
internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest =
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
@Composable
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
val loader = LocalWolfskinImageLoader.current ?: return
val context = LocalContext.current
if (row == null || data.status != TitleReadStatus.Ready) return
if (LocalTitleBitmaps.current.isNotEmpty()) return
val loader = LibraryImages.loader(context)
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
val requests = ArrayList<Disposable>()
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
if (path != null) requests.add(loader.enqueue(titleImageRequest(context, path, width, height, poster)))
if (path != null) requests.add(loader.enqueue(LibraryImages.request(context, path, width, height, poster)))
}
when (row) {
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))

View file

@ -1,96 +0,0 @@
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
/** Rating formatting follows the TV locale; Rotten Tomatoes remains an integer percentage. */
fun formatTitleRating(rating: TitleRating, locale: Locale = Locale.getDefault()): String =
if (rating.source.startsWith("rt")) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** A full facts-line set never shrinks or waits for focused-card dwell. */
@Composable
fun TitleRatings(ratings: List<TitleRating>, modifier: Modifier = Modifier, style: TextStyle = WolfskinTokens.Type.button, plate: Boolean = false) {
val density = LocalDensity.current
val markHeight = with(density) { style.fontSize.toDp() }
val gap = if (plate) with(density) { (style.fontSize * D.ratingPlateEntryEm).toDp() } else D.ratingGap
val markGap = if (plate) with(density) { (style.fontSize * D.ratingPlateMarkEm).toDp() } else D.ratingMarkGap
Row(modifier, horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) {
ratings.forEach { rating ->
Row(horizontalArrangement = Arrangement.spacedBy(markGap), verticalAlignment = Alignment.CenterVertically) {
if (rating.episode) Text("Ep", color = C.muted, style = style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
val resource = when (rating.mark) {
"rotten" -> R.drawable.ic_rotten_tomatoes_rotten
"upright" -> R.drawable.ic_rt_upright
"spilled" -> R.drawable.ic_rt_spilled
"imdb" -> R.drawable.ic_rating_imdb
"tmdb" -> R.drawable.ic_rating_tmdb
else -> R.drawable.ic_rotten_tomatoes_fresh
}
Image(painterResource(resource), rating.source, Modifier.size(markHeight * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f, markHeight),
alpha = if (rating.mark == "tomato") D.mutedTomatoOpacity else 1f,
colorFilter = if (rating.mark == "tomato") ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null)
Text(formatTitleRating(rating), color = C.fg, style = style.copy(fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold), maxLines = 1)
}
}
}
}
/** The plate keeps a prefix of the title set, shrinking to the caption floor before dropping its tail. */
@Composable
fun TitleRatingPlate(ratings: List<TitleRating>, modifier: Modifier = Modifier) {
if (ratings.isEmpty()) return
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier.background(Brush.verticalGradient(listOf(C.bg.copy(alpha = .9f), C.bg.copy(alpha = .72f), C.bg.copy(alpha = 0f))))
.padding(start = D.plateSide, end = D.plateSide, top = D.plateTop, bottom = D.plateFade)) {
val width = with(density) { maxWidth.toPx() }
val fit = remember(ratings, width, density, Locale.getDefault()) {
var size = WolfskinTokens.Type.ratingPlate.fontSize.value
var count = ratings.size
val numbers = ratings.map { formatTitleRating(it) }
fun measured(): Float {
val fontSize = TextUnit(size, TextUnitType.Sp)
val style = WolfskinTokens.Type.button.copy(fontSize = fontSize, fontFeatureSettings = "tnum", fontWeight = FontWeight.Bold)
val markGap = with(density) { (fontSize * D.ratingPlateMarkEm).toPx() }
val entryGap = with(density) { (fontSize * D.ratingPlateEntryEm).toPx() }
var total = 0f
for (index in 0 until count) {
val rating = ratings[index]
total += measurer.measure(numbers[index], style, maxLines = 1).size.width
total += with(density) { fontSize.toPx() } * if (rating.source == "imdb" || rating.source == "tmdb") D.ratingWordmarkAspect else 1f
total += markGap
if (rating.episode) total += measurer.measure("Ep", style.copy(fontWeight = WolfskinTokens.Type.label.fontWeight), maxLines = 1).size.width + markGap
if (index > 0) total += entryGap
}
return total
}
while (size > WolfskinTokens.Type.caption.fontSize.value && measured() > width) size = (size - D.ratingPlateStepSp).coerceAtLeast(WolfskinTokens.Type.caption.fontSize.value)
while (count > 1 && measured() > width) count--
count to size
}
TitleRatings(ratings.take(fit.first), style = WolfskinTokens.Type.button.copy(fontSize = TextUnit(fit.second, TextUnitType.Sp)), plate = true)
}
}

View file

@ -103,40 +103,50 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) {
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
@Composable
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val focusOwner = LocalTitleFocusOwner.current
val width = if (tile) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (tile) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
Column(modifier.zIndex(if (focused) 1f else 0f)) {
Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard)
if (!tile) {
val bitmaps = LocalTitleBitmaps.current
val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } }
LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue,
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)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
TitleCardArtwork(item, tile)
if (tile) {
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
TitleTileArtwork(item)
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
val progress = item.progress
if (progress > 0f && progress < 1f) {
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom)
Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom)
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
}
}
if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) {
TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon))
}
androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) {
TitleRatingPlate(item.ratings, Modifier.fillMaxWidth())
LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth())
}
}
if (!tile) {
val availability = androidx.compose.runtime.remember(item, catalogue) {
if (catalogue) {
if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }
} else if (item.watched) "Watched"
else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left"
else null
}
val facts = androidx.compose.runtime.remember(item, availability) {
listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(),
if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series",
availability).joinToString(" · ")
}
Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) {
Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused,
Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth))
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank())
}
}
}

View file

@ -125,7 +125,7 @@ class TitleState(
}
if (sheet != null) { sheetKey(key); return true }
when (key) {
ShellKey.Back -> onAction(TitleAction.Pop)
ShellKey.Back -> back(shell)
ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1)
ShellKey.Down -> moveRow(1)
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
@ -135,8 +135,14 @@ class TitleState(
return true
}
/** Platform Back shares the sheet-first ladder with remote Back. */
fun back() { if (sheet != null) sheet = null else onAction(TitleAction.Pop) }
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
fun back(shell: ShellState) {
when {
sheet != null -> sheet = null
shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back)
else -> onAction(TitleAction.Pop)
}
}
/** Pointer selection stays inside the modal's row order. */
fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) }
@ -227,7 +233,6 @@ class TitleState(
}
else -> sheet = null
}
else -> Unit
}
}
@ -249,8 +254,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
val target = state.target
if (data.status != TitleReadStatus.Ready) {
if (data.status == TitleReadStatus.Unavailable) {
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
add(TitleOption("back", "Back", "left", icon = false))
add(TitleOption("retry", "Try again", "", primary = true, icon = false))
add(TitleOption("back", "Back", "", icon = false))
}
return@buildList
}
@ -271,9 +276,16 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
} else if (data.connection != TitleConnection.SignedIn) {
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
} else {
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", if (data.requestedStage == null) "plus" else "clock", primary = true, icon = false))
add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false))
add(TitleOption("other", "For someone else", "users", icon = false))
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false))
else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false))
}
}
internal fun titleStageGlyph(stage: String): String = when (stage) {
"Ready" -> "check"
"Failed" -> "alert"
"Downloading", "Almost ready" -> "download"
else -> "clock"
}

View file

@ -310,6 +310,9 @@ object WolfskinTokens {
val chipIcon = 13.0.dp
val captionWidth = 320.0.dp
const val captionSeparatorOpacity = 0.4f
val captionFactsGap = 9.0.dp
val badgeIcon = 14.0.dp
val badgeBorder = 1.5.dp
val plateTop = 7.0.dp
val plateSide = 8.0.dp
val plateBottom = 23.0.dp
@ -376,6 +379,7 @@ object WolfskinTokens {
val sheetGap = 9.0.dp
val sheetSubLift = -4.0.dp
val sheetSubBottom = 7.0.dp
val sheetLabelTop = 4.0.dp
val sheetRowSide = 13.0.dp
val versionRowVertical = 10.0.dp
val trackHeight = 42.0.dp
@ -384,14 +388,14 @@ object WolfskinTokens {
val radioDot = 8.0.dp
val radioBorder = 1.5.dp
val infoGap = 7.0.dp
val infoLabelWidth = 80.0.dp
val infoLabelWidth = 100.0.dp
val infoRowGap = 3.0.dp
val infoColumnGap = 10.0.dp
val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em)
val gridHeight = 244.0.dp
val minimumEndPadding = 40.0.dp
val ratingGap = 10.0.dp
val ratingMarkGap = 4.0.dp
val plateSide = 8.0.dp
val plateTop = 7.0.dp
val plateFade = 23.0.dp
val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em)
val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em)
const val fallbackHue = 260.0f
@ -413,9 +417,6 @@ object WolfskinTokens {
val chipBorder = 1.0.dp
val heroSheetRight = 90.0.dp
val heroSheetTop = 75.0.dp
const val mutedTomatoOpacity = 0.55f
const val ratingWordmarkAspect = 1.8f
const val ratingPlateStepSp = 0.25f
val gridHeadingTop = 12.0.dp
val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
val receiptSide = 15.0.dp
@ -441,6 +442,10 @@ object WolfskinTokens {
val tileSheetLeft = 11.0.dp
val tileSheetRadius = 5.0.dp
val tileAmbientBlur = 13.0.dp
val tileAmbientInset = 15.0.dp
val tileShadowRadius = 15.0.dp
val tileShadowY = 5.0.dp
const val tileShadowOpacity = 0.5f
const val tileAmbientBrightness = 0.5f
const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))"
const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))"
@ -452,10 +457,15 @@ object WolfskinTokens {
val tileOverlayBottom = 9.0.dp
const val cardOverlayOpacity = 0.92f
val cardProgressInset = 9.0.dp
val cardProgressBottom = 6.0.dp
val tileProgressBottom = 5.0.dp
const val ratingPlateEntryEm = 0.64f
const val ratingPlateMarkEm = 0.29f
val ambientInset = 40.0.dp
const val ambientScale = 1.1f
val posterShadowRadius = 40.0.dp
val posterShadowY = 15.0.dp
const val posterShadowOpacity = 0.6f
val sheetShadowRadius = 40.0.dp
val sheetShadowX = -15.0.dp
const val sheetShadowOpacity = 0.5f
}
object SavedIndicator {
val height = 26.0.dp

View file

@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,3 L2,20 H22 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineJoin="round" />
<path android:pathData="M12,10 V14.5 M12,17 V17.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 4v11m-5-5 5 5 5-5M5 20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View file

@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M9.0,12.0a3.0,3.0 0 1,0 6.0,0a3.0,3.0 0 1,0 -6.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
</vector>

View file

@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
</vector>

View file

@ -1,3 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M8 5v14l11-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -1,3 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 3c.6 4.2 2.8 6.4 7 7-4.2.6-6.4 2.8-7 7-.6-4.2-2.8-6.4-7-7 4.2-.6 6.4-2.8 7-7z" android:fillColor="#FFFFFFFF" />
</vector>

View file

@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,5.0H18.5Q21.0,5.0 21.0,7.5V14.5Q21.0,17.0 18.5,17.0H5.5Q3.0,17.0 3.0,14.5V7.5Q3.0,5.0 5.5,5.0Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M8 21h8M12 17v4" android:fillColor="#FFFFFFFF" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" android:strokeLineCap="round" android:strokeLineJoin="miter" />
</vector>

View file

@ -1,4 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,8.5a3.5,3.5 0 1,0 7.0,0a3.5,3.5 0 1,0 -7.0,0" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="butt" android:strokeLineJoin="miter" />
<path android:pathData="M2.5 19.5c.8-3.4 3.4-5 6.5-5s5.7 1.6 6.5 5M15.5 5.2a3.4 3.4 0 0 1 0 6.6M18 14.8c1.8.7 3 2.2 3.5 4.7" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="miter" />
</vector>

View file

@ -1 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:pathData="M5.5,4 L18.5,4 Q21,4 21,6.5 L21,17.5 Q21,20 18.5,20 L5.5,20 Q3,20 3,17.5 L3,6.5 Q3,4 5.5,4 M7,4 L7,20 M17,4 L17,20 M3,9 L7,9 M3,15 L7,15 M17,9 L21,9 M17,15 L21,15" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round"/></vector>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5.5,4 H18.5 A2.5,2.5 0,0 1,21 6.5 V17.5 A2.5,2.5 0,0 1,18.5 20 H5.5 A2.5,2.5 0,0 1,3 17.5 V6.5 A2.5,2.5 0,0 1,5.5 4 Z" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.8" />
<path android:pathData="M7,4 V20 M17,4 V20 M3,9 H7 M3,15 H7 M17,9 H21 M17,15 H21" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="1.6" />
</vector>

View file

@ -26,6 +26,7 @@
"sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"},
"sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"},
"sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"},
"sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"},
"sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"},
"versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"},
"trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"},
@ -34,14 +35,14 @@
"radioDot": {"dp": 8, "px": 16, "source": "index.html:236"},
"radioBorder": {"dp": 1.5, "px": 3, "source": "index.html:234"},
"infoGap": {"dp": 7, "px": 14, "source": "index.html:1948"},
"infoLabelWidth": {"dp": 80, "px": 160, "source": "index.html:272"},
"infoLabelWidth": {"dp": 100, "px": 200, "source": "index.html:318"},
"infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"},
"infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"},
"infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"},
"gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"},
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},
"plateSide": {"dp": 8, "px": 16, "source": "index.html:162"},
"plateTop": {"dp": 7, "px": 14, "source": "index.html:162"},
"plateFade": {"dp": 23, "px": 46, "source": "index.html:162"},
"initialSize": {"$type": "typography", "sp": 32, "px": 64, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:288"},
"personInitialSize": {"$type": "typography", "sp": 48, "px": 96, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:1317"},
"fallbackHue": {"$value": 260, "source": "index.html:130"},
@ -63,9 +64,6 @@
"chipBorder": {"dp": 1, "px": 2, "source": "index.html:188"},
"heroSheetRight": {"dp": 90, "px": 180, "source": "index.html:97"},
"heroSheetTop": {"dp": 75, "px": 150, "source": "index.html:97"},
"mutedTomatoOpacity": {"$value": 0.55, "source": "index.html:161"},
"ratingWordmarkAspect": {"$value": 1.8, "source": "index.html:160"},
"ratingPlateStepSp": {"$value": 0.25, "source": "index.html:802"},
"gridHeadingTop": {"dp": 12, "px": 24, "source": "index.html:1312"},
"castName": {"$type": "typography", "sp": 13, "px": 26, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:290"},
"receiptSide": {"dp": 15, "px": 30, "source": "index.html:243"},
@ -91,6 +89,10 @@
"tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"},
"tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"},
"tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"},
"tileAmbientInset": {"dp": 15, "px": 30, "source": "index.html:129"},
"tileShadowRadius": {"dp": 15, "px": 30, "source": "index.html:128"},
"tileShadowY": {"dp": 5, "px": 10, "source": "index.html:128"},
"tileShadowOpacity": {"$value": 0.5, "source": "index.html:128"},
"tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"},
"cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"},
"personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"},
@ -102,10 +104,15 @@
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
"cardProgressBottom": {"dp": 6, "px": 12, "source": "index.html:138"},
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
"ratingPlateEntryEm": {"$value": 0.64, "source": "index.html:164"},
"ratingPlateMarkEm": {"$value": 0.29, "source": "index.html:165"}
"ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"},
"ambientScale": {"$value": 1.1, "source": "index.html:96"},
"posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"},
"posterShadowY": {"dp": 15, "px": 30, "source": "index.html:97"},
"posterShadowOpacity": {"$value": 0.6, "source": "index.html:97"},
"sheetShadowRadius": {"dp": 40, "px": 80, "source": "index.html:227"},
"sheetShadowX": {"dp": -15, "px": -30, "source": "index.html:227"},
"sheetShadowOpacity": {"$value": 0.5, "source": "index.html:227"}
},
"savedIndicator": {
"height": {"dp": 26, "px": 52, "source": "index.html:87"},

View file

@ -182,6 +182,9 @@
--library-chipTop: 16.0px;
--library-chipIcon: 26.0px;
--library-captionWidth: 640.0px;
--library-captionFactsGap: 18.0px;
--library-badgeIcon: 28.0px;
--library-badgeBorder: 3.0px;
--library-plateTop: 14.0px;
--library-plateSide: 16.0px;
--library-plateBottom: 46.0px;
@ -230,6 +233,7 @@
--title-sheetGap: 18.0px;
--title-sheetSubLift: -8.0px;
--title-sheetSubBottom: 14.0px;
--title-sheetLabelTop: 8.0px;
--title-sheetRowSide: 26.0px;
--title-versionRowVertical: 20.0px;
--title-trackHeight: 84.0px;
@ -238,14 +242,14 @@
--title-radioDot: 16.0px;
--title-radioBorder: 3.0px;
--title-infoGap: 14.0px;
--title-infoLabelWidth: 160.0px;
--title-infoLabelWidth: 200.0px;
--title-infoRowGap: 6.0px;
--title-infoColumnGap: 20.0px;
--title-infoKey: 24.0px;
--title-gridHeight: 488.0px;
--title-minimumEndPadding: 80.0px;
--title-ratingGap: 20.0px;
--title-ratingMarkGap: 8.0px;
--title-plateSide: 16.0px;
--title-plateTop: 14.0px;
--title-plateFade: 46.0px;
--title-initialSize: 64.0px;
--title-personInitialSize: 96.0px;
--title-skeletonKickerWidth: 420.0px;
@ -289,6 +293,9 @@
--title-tileSheetLeft: 22.0px;
--title-tileSheetRadius: 10.0px;
--title-tileAmbientBlur: 26.0px;
--title-tileAmbientInset: 30.0px;
--title-tileShadowRadius: 30.0px;
--title-tileShadowY: 10.0px;
--title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%));
--title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%));
--title-initialsColor: #ffffffd1;
@ -298,8 +305,12 @@
--title-tileOverlaySide: 22.0px;
--title-tileOverlayBottom: 18.0px;
--title-cardProgressInset: 18.0px;
--title-cardProgressBottom: 12.0px;
--title-tileProgressBottom: 10.0px;
--title-ambientInset: 80.0px;
--title-posterShadowRadius: 80.0px;
--title-posterShadowY: 30.0px;
--title-sheetShadowRadius: 80.0px;
--title-sheetShadowX: -30.0px;
--savedIndicator-height: 52.0px;
--savedIndicator-side: 20.0px;
--savedIndicator-gap: 10.0px;

View file

@ -1,6 +1,6 @@
811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py
1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json
384416f878c9c91b718fc9f1623de53aeb8fcde4440d99c1035ce58043bad0dd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
10e4d911a8b6b582eab835f0631ee83c822bbd93b35480801616df8ab2679139 title-tokens.json
b8e05d5275a32548a5cf8776955ce6df72a9a5f7be110bdf0e15ea79cb0d7123 tokens.css
b36140ec4745837b9e912deb6eba5d9ce94871a18512d7fdaddb37b7dd79ce25 shell-tokens.json
c56109dc854d03d74baf0384e09451461ad50ca266b1cc4d598b310475927d11 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
bc82f477611669fbc6dd0d3e6559b1e05b0ad280531a883102803ffb0ebcdecb title-tokens.json
ae9ca44fc6e1d3bd4ecce133ba56f2d1bce267f1754b30028a6cc0124539a6f7 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json