Reuse shared artwork and correct title focus edges
This commit is contained in:
parent
3bfb8cd779
commit
a8951827ba
41 changed files with 581 additions and 438 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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")) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
|
|
|
|||
|
|
@ -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 = "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
3
wolfskin-design/src/main/res/drawable/title_download.xml
Normal file
3
wolfskin-design/src/main/res/drawable/title_download.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue