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 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 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 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 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 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) })
|
@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 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 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 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 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 st106_heldRatings() = capture(106, "American Gangster", "shared")
|
||||||
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
@Test fun st107_seriesEpisodeRatings() = capture(107, "Andor")
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.ui.input.key.Key
|
import androidx.compose.ui.input.key.Key
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onRoot
|
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.pressKey
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performMouseInput
|
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.justinvisser.wolfskin.design.WolfskinTokens
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
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 }) {
|
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)) }
|
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)
|
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) } }
|
private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } }
|
||||||
|
|
@ -251,4 +259,34 @@ class TitleKeysTest {
|
||||||
press(Key.DirectionDown)
|
press(Key.DirectionDown)
|
||||||
assertEquals(expected, state.row)
|
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.TitleConnection
|
||||||
import org.justinvisser.wolfskin.design.TitleItem
|
import org.justinvisser.wolfskin.design.TitleItem
|
||||||
import org.justinvisser.wolfskin.design.TitlePageData
|
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.TitleState
|
||||||
import org.justinvisser.wolfskin.design.TitleSurface
|
import org.justinvisser.wolfskin.design.TitleSurface
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTheme
|
import org.justinvisser.wolfskin.design.WolfskinTheme
|
||||||
|
|
@ -39,14 +39,14 @@ class TitleLocaleTest {
|
||||||
try {
|
try {
|
||||||
Locale.setDefault(locale)
|
Locale.setDefault(locale)
|
||||||
val person = ShellPerson("justin", "Justin", "J", false)
|
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 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) {}
|
val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {}
|
||||||
state.focus("related", 0)
|
state.focus("related", 0)
|
||||||
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
rule.setContent { WolfskinTheme { TitleSurface(state, shell) } }
|
||||||
rule.mainClock.advanceTimeBy(1000)
|
rule.mainClock.advanceTimeBy(1000)
|
||||||
val layouts = mutableListOf<TextLayoutResult>()
|
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)
|
assertEquals(1, layouts.single().lineCount)
|
||||||
assertFalse(layouts.single().didOverflowWidth)
|
assertFalse(layouts.single().didOverflowWidth)
|
||||||
} finally { Locale.setDefault(previous) }
|
} finally { Locale.setDefault(previous) }
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
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.successQueryResult
|
||||||
import com.github.damontecres.wholphin.ui.successResponse
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkObject
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
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.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.junit.Assert.*
|
import org.junit.Assert.*
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -40,6 +45,7 @@ import org.justinvisser.wolfskin.design.TitleState
|
||||||
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
import org.justinvisser.wolfskin.tv.read.TitleReadFacade
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.RuntimeEnvironment
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */
|
/** 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 secondId = UUID.fromString("00000000-0000-0000-0000-000000000002")
|
||||||
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003")
|
||||||
private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004")
|
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() {
|
@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.userLibraryApi } returns library
|
||||||
every { api.itemsApi } returns items
|
every { api.itemsApi } returns items
|
||||||
every { api.tvShowsApi } returns shows
|
every { api.tvShowsApi } returns shows
|
||||||
|
|
@ -72,6 +84,10 @@ class TitleReadFacadeTest {
|
||||||
facade = TitleReadFacade(api, mockk<ImageUrlService>(relaxed = true), playback, repository, mockk<FavoriteWatchManager>(), context)
|
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 {
|
private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto {
|
||||||
val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo()
|
val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo()
|
||||||
val streams = source.mediaStreams.orEmpty().map { stream ->
|
val streams = source.mediaStreams.orEmpty().map { stream ->
|
||||||
|
|
@ -142,4 +158,12 @@ class TitleReadFacadeTest {
|
||||||
assertEquals(listOf(2), data.seasonNumbers)
|
assertEquals(listOf(2), data.seasonNumbers)
|
||||||
assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season)
|
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) {
|
LaunchedEffect(title?.data?.status) {
|
||||||
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false
|
||||||
}
|
}
|
||||||
BackHandler { title?.back() }
|
|
||||||
val person = viewModel.person
|
val person = viewModel.person
|
||||||
|
BackHandler(enabled = title != null) {
|
||||||
|
title?.back(shell)
|
||||||
|
}
|
||||||
if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier)
|
if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier)
|
||||||
else if (title != null) TitleSurface(title, 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.TitleItem
|
||||||
import org.justinvisser.wolfskin.design.TitlePageData
|
import org.justinvisser.wolfskin.design.TitlePageData
|
||||||
import org.justinvisser.wolfskin.design.TitlePerson
|
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.TitleTrack
|
||||||
import org.justinvisser.wolfskin.design.TitleVersion
|
import org.justinvisser.wolfskin.design.TitleVersion
|
||||||
import java.util.Locale
|
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 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"
|
val episode = item.optString("type") == "episode"
|
||||||
if (episode) {
|
if (episode) {
|
||||||
val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") }
|
val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") }
|
||||||
val base = series?.let { ratings(it) }.orEmpty()
|
val base = series?.let { ratings(it) }.orEmpty()
|
||||||
val score = item.optDouble("communityRating", Double.NaN)
|
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 {
|
val ratings = item.optJSONObject("ratings") ?: JSONObject().apply {
|
||||||
if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating"))
|
if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating"))
|
||||||
if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating"))
|
if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating"))
|
||||||
}
|
}
|
||||||
return buildList {
|
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("rtCritics")) add(LibraryRating("rtCritics", ratings.getDouble("rtCritics"), ratings.optString("rtCriticsLabel").takeIf { it.isNotEmpty() }))
|
||||||
if (!ratings.isNull("rtAudience")) add(TitleRating("rtAudience", ratings.getDouble("rtAudience"), if (ratings.optString("rtAudienceLabel") == "Spilled") "spilled" else "upright"))
|
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(TitleRating("imdb", ratings.getDouble("imdb"), "imdb"))
|
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(TitleRating("tmdb", ratings.getDouble("tmdb"), "tmdb"))
|
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 }
|
}.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.GetEpisodesRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.justinvisser.wolfskin.design.TitleAction
|
import org.justinvisser.wolfskin.design.TitleAction
|
||||||
import org.justinvisser.wolfskin.design.TitleConnection
|
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. */
|
/** Track selection uses the same repository consumed by the upstream player. */
|
||||||
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) {
|
||||||
when (action) {
|
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 -> {
|
is TitleAction.Track -> {
|
||||||
val id = UUID.fromString(action.item.id)
|
val id = UUID.fromString(action.item.id)
|
||||||
val item = BaseItem(api.userLibraryApi.getItem(id).content)
|
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
|
The independent `:wolfskin-design` module owns the shell components, fixed person
|
||||||
colours, focus policy, per-person root-view memory, palette and motion. Its
|
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
|
`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
|
SHA and provenance. `shell-tokens.json` adds shell and grid measurements;
|
||||||
same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
|
`title-tokens.json` adds title, performer and playback-sheet measurements from
|
||||||
|
that same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 wolfskin-design/generate_tokens.py
|
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
|
The optional suspend page provider returns `LibraryPage(items, hasMore)` using the
|
||||||
generated Conductarr client. The grid reads within ten cards of its end, deduplicates
|
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 identities, and pauses on Try again or Show more rather than looping.
|
||||||
Title activation pushes the explicitly named `library-title/{id}` destination for
|
Title activation opens `title/{id}` through `Destination.Wolfskin` for prototype
|
||||||
the title-screen implementation; this slice does not implement title pages.
|
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.
|
`LibraryCaptureTest` renders the sixteen approved library/See all states offline.
|
||||||
`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly
|
`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
|
failure/retry, stale copies, and person/server isolation. Captures are written to
|
||||||
the same `build/wolfskin-captures/` directory as the shell captures.
|
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
|
## Toolchain and disk use
|
||||||
|
|
||||||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
`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"},
|
"chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"},
|
||||||
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
|
"captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"},
|
||||||
"captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"},
|
"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"},
|
"plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"},
|
||||||
"plateSide": {"$type":"dimension","px":16,"dp":8,"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"},
|
"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.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
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. */
|
/** Captions fade without moving the reserved band under the focused poster. */
|
||||||
@Composable
|
@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) }
|
val alpha = remember(title, facts) { Animatable(0f) }
|
||||||
LaunchedEffect(title, facts, visible) {
|
LaunchedEffect(title, facts, visible) {
|
||||||
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)))
|
||||||
}
|
}
|
||||||
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
Column(modifier.graphicsLayer { this.alpha = alpha.value }) {
|
||||||
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
val styledFacts = remember(facts, accentLastFact) {
|
CaptionFacts(facts, accentFirstFact, accentLastFact)
|
||||||
buildAnnotatedString {
|
}
|
||||||
val separator = " · "
|
}
|
||||||
append(facts)
|
|
||||||
if (accentLastFact) {
|
/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */
|
||||||
val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length
|
@Composable
|
||||||
if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length)
|
private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean) {
|
||||||
}
|
val parts = remember(facts) { facts.split(" · ") }
|
||||||
var start = facts.indexOf(separator)
|
val muted = WolfskinTokens.Color.muted
|
||||||
if (start >= 0) {
|
val colour = WolfskinTokens.Color.accentText
|
||||||
val muted = WolfskinTokens.Color.muted
|
val style = WolfskinTokens.Type.caption
|
||||||
val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
|
Layout(
|
||||||
val dotOffset = separator.indexOf('·')
|
content = {
|
||||||
while (start >= 0) {
|
parts.forEachIndexed { index, part ->
|
||||||
addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1)
|
val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast)
|
||||||
start = facts.indexOf(separator, start + separator.length)
|
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(),
|
||||||
Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
) { 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.compose.ui.res.painterResource
|
||||||
import androidx.tv.material3.Icon
|
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
|
@Composable
|
||||||
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
val resource = when (name) {
|
val resource = when (name) {
|
||||||
"gear" -> R.drawable.wolfskin_gear
|
"gear" -> R.drawable.wolfskin_gear
|
||||||
"requests" -> R.drawable.wolfskin_tray
|
"requests" -> R.drawable.wolfskin_tray
|
||||||
"interface" -> R.drawable.wolfskin_grid
|
"interface" -> R.drawable.wolfskin_grid
|
||||||
"playback" -> R.drawable.wolfskin_play
|
"play", "playback" -> R.drawable.wolfskin_play
|
||||||
"nextup" -> R.drawable.wolfskin_tv
|
"tv", "nextup" -> R.drawable.wolfskin_tv
|
||||||
"profile" -> R.drawable.wolfskin_users
|
"users", "profile" -> R.drawable.wolfskin_users
|
||||||
"about" -> R.drawable.wolfskin_info
|
"about" -> R.drawable.wolfskin_info
|
||||||
"seerr" -> R.drawable.wolfskin_sparkle
|
"sparkle", "seerr" -> R.drawable.wolfskin_sparkle
|
||||||
"advanced" -> R.drawable.wolfskin_sort
|
"advanced" -> R.drawable.wolfskin_sort
|
||||||
"alert" -> R.drawable.wolfskin_alert
|
"alert" -> R.drawable.wolfskin_alert
|
||||||
"film" -> R.drawable.wolfskin_film
|
"film" -> R.drawable.wolfskin_film
|
||||||
"eye" -> R.drawable.wolfskin_eye
|
"eye" -> R.drawable.wolfskin_eye
|
||||||
"down" -> R.drawable.wolfskin_down
|
"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
|
else -> R.drawable.wolfskin_right
|
||||||
}
|
}
|
||||||
Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color)
|
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.setValue
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE)
|
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. */
|
/** Immutable title facts cross the provider boundary without SDK objects. */
|
||||||
data class LibraryTitle(
|
data class LibraryTitle(
|
||||||
val id: String,
|
override val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val type: String,
|
val type: String,
|
||||||
val year: Int? = null,
|
val year: Int? = null,
|
||||||
val runtimeSeconds: Long? = null,
|
val runtimeSeconds: Long? = null,
|
||||||
val seasons: Int? = null,
|
val seasons: Int? = null,
|
||||||
val poster: String? = null,
|
override val poster: String? = null,
|
||||||
val played: Boolean = false,
|
val played: Boolean = false,
|
||||||
val progress: Float = 0f,
|
override val progress: Float = 0f,
|
||||||
val addedAt: String = "",
|
val addedAt: String = "",
|
||||||
val ratings: List<LibraryRating> = emptyList(),
|
override val ratings: List<LibraryRating> = emptyList(),
|
||||||
val availability: String = "",
|
val availability: String = "",
|
||||||
val badge: String? = null,
|
override val badge: String? = null,
|
||||||
val tmdbId: 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 sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT)
|
||||||
val catalogueKey: String = "$type:${tmdbId ?: id}"
|
val catalogueKey: String = "$type:${tmdbId ?: id}"
|
||||||
val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#'
|
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. */
|
/** 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)
|
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() } }) }
|
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
|
||||||
.crossfade(false).build().also { loader = it }
|
.crossfade(false).build().also { loader = it }
|
||||||
|
|
||||||
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
|
fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth,
|
||||||
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
|
height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest =
|
||||||
.allowRgb565(true).crossfade(false).build()
|
ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
package org.justinvisser.wolfskin.design
|
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.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
@ -11,20 +10,10 @@ import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.ImageBitmap
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.testTag
|
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 coil3.compose.AsyncImage
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L
|
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. */
|
/** Poster slots use exact requests and share the shell's draw-only focus treatment. */
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryPoster(
|
fun LibraryPoster(
|
||||||
title: LibraryTitle,
|
title: PosterContent,
|
||||||
focused: Boolean,
|
focused: Boolean,
|
||||||
ratingsVisible: Boolean,
|
ratingsVisible: Boolean,
|
||||||
catalogue: Boolean,
|
catalogue: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
offlineArt: ((String) -> ImageBitmap?)? = null,
|
offlineArt: ((String) -> ImageBitmap?)? = null,
|
||||||
) {
|
) {
|
||||||
|
val poster = title.poster
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
|
||||||
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
|
||||||
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
|
||||||
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
|
||||||
.testTag("poster-${title.id}")) {
|
.testTag("poster-${title.id}")) {
|
||||||
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
|
||||||
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }
|
val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } }
|
||||||
if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
else if (title.poster != null && offlineArt == null) {
|
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 context = LocalContext.current
|
||||||
val request = remember(title.poster, context) { LibraryImages.request(context, title.poster) }
|
val request = remember(poster, context) { LibraryImages.request(context, poster) }
|
||||||
AsyncImage(request, title.title, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
AsyncImage(request, title.posterTitle, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
|
||||||
} else MissingLibraryArt(title)
|
onSuccess = { ready = true }, onError = { ready = false })
|
||||||
|
} else MediaArtworkFallback(title.posterTitle, title.posterIsMovie)
|
||||||
if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible)
|
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)
|
Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap)
|
||||||
.fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) {
|
.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)
|
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) {
|
.size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape)
|
||||||
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)
|
.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)
|
.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))
|
.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) {
|
.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)
|
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(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) {
|
||||||
row.forEachIndexed { column, item ->
|
row.forEachIndexed { column, item ->
|
||||||
val index = rowIndex * columns + column
|
val index = rowIndex * columns + column
|
||||||
TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), 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.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
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.ColorFilter
|
||||||
import androidx.compose.ui.graphics.ColorMatrix
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
import androidx.compose.ui.graphics.ShaderBrush
|
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.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.SingletonImageLoader
|
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
@ -46,9 +51,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo
|
||||||
else if (path != null) {
|
else if (path != null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val request = remember(context, path, width, height, poster) {
|
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)
|
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()
|
for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat()
|
||||||
ColorFilter.colorMatrix(matrix)
|
ColorFilter.colorMatrix(matrix)
|
||||||
}
|
}
|
||||||
TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, 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(),
|
TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(),
|
||||||
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop)
|
||||||
.size(L.heroSheetWidth, L.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)))
|
.size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape))
|
||||||
}
|
}
|
||||||
Box(Modifier.fillMaxSize().background(horizontal))
|
Box(Modifier.fillMaxSize().background(horizontal))
|
||||||
Box(Modifier.fillMaxSize().background(vertical))
|
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
|
@Composable
|
||||||
fun TitleCardArtwork(item: TitleItem, tile: Boolean) {
|
internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) {
|
||||||
Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) {
|
Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) {
|
||||||
TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph))
|
Glyph(if (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,
|
if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset))
|
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 {
|
val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply {
|
||||||
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
for (index in 0 until 15) values[index] *= D.tileAmbientBrightness
|
||||||
}) }
|
}) }
|
||||||
TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().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 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(),
|
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)
|
Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true)
|
||||||
} else {
|
} else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize())
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache {
|
||||||
var hue = 0
|
var hue = 0
|
||||||
name.forEach { hue = (hue * 31 + it.code) % 360 }
|
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.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.platform.testTag
|
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 androidx.tv.material3.Text
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
|
|
@ -33,42 +36,17 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod
|
||||||
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
val shape = RoundedCornerShape(WolfskinTokens.Radius.button)
|
||||||
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX)
|
||||||
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl)
|
||||||
|
.semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on }
|
||||||
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
|
||||||
.background(background, shape)
|
.background(background, shape)
|
||||||
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
.then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier)
|
||||||
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
.clickable { focusOwner?.invoke(); onClick() }.then(dimensions),
|
||||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
|
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)
|
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. */
|
/** A radio remains visibly selected independently of the inverse focused row. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
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"
|
val type = if (item.kind == "movie") "Movie" else "Series"
|
||||||
Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) {
|
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) {
|
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 {
|
Text(buildString {
|
||||||
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
|
append("$type · ${if (item.held) "In your library" else "Not in your library"}")
|
||||||
if (item.held && state.watched) append(" · Watched")
|
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 ->
|
state.data.requestedStage?.let { stage ->
|
||||||
val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted }
|
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) {
|
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(stage, color = color, style = WolfskinTokens.Type.label)
|
||||||
Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine)
|
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)
|
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,
|
state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index,
|
||||||
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) },
|
||||||
onFocus = { state.focus("header", index) }) { state.click("header", index) } }
|
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. */
|
/** Screen data is independent of Jellyfin and Conductarr wire objects. */
|
||||||
@Immutable
|
@Immutable
|
||||||
data class TitleItem(
|
data class TitleItem(
|
||||||
val id: String,
|
override val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val kind: String,
|
val kind: String,
|
||||||
val year: Int? = null,
|
val year: Int? = null,
|
||||||
|
|
@ -13,7 +13,7 @@ data class TitleItem(
|
||||||
val seasons: Int = 0,
|
val seasons: Int = 0,
|
||||||
val overview: String = "",
|
val overview: String = "",
|
||||||
val genres: List<String> = emptyList(),
|
val genres: List<String> = emptyList(),
|
||||||
val poster: String? = null,
|
override val poster: String? = null,
|
||||||
val backdrop: String? = null,
|
val backdrop: String? = null,
|
||||||
val held: Boolean = false,
|
val held: Boolean = false,
|
||||||
val watched: Boolean = false,
|
val watched: Boolean = false,
|
||||||
|
|
@ -22,11 +22,15 @@ data class TitleItem(
|
||||||
val seriesName: String? = null,
|
val seriesName: String? = null,
|
||||||
val season: Int = 0,
|
val season: Int = 0,
|
||||||
val episode: Int = 0,
|
val episode: Int = 0,
|
||||||
val ratings: List<TitleRating> = emptyList(),
|
override val ratings: List<LibraryRating> = emptyList(),
|
||||||
val character: String? = null,
|
val character: String? = null,
|
||||||
val otherOwners: List<String> = emptyList(),
|
val otherOwners: List<String> = emptyList(),
|
||||||
val isPosterBackdrop: Boolean = false,
|
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 episodeLabel: String get() = "S$season E$episode"
|
||||||
val runtime: String get() {
|
val runtime: String get() {
|
||||||
val minutes = (runtimeSeconds + 30) / 60
|
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. */
|
/** A cast identity can open a performer page without borrowing a title's identity. */
|
||||||
@Immutable
|
@Immutable
|
||||||
data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "")
|
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.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.tv.material3.Text
|
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.Color as C
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
|
||||||
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
|
|
@ -40,13 +46,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D
|
||||||
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
fun TitleOptionsSheet(state: TitleState, shell: ShellState) {
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
LaunchedEffect(state.sheet) { focus.requestFocus() }
|
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 { }
|
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { }
|
||||||
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
.testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" }
|
||||||
.focusRequester(focus).onPreviewKeyEvent { event ->
|
.focusRequester(focus).onPreviewKeyEvent { event ->
|
||||||
val key = readShellKey(event)
|
val key = readShellKey(event)
|
||||||
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
}.focusable()) {
|
}.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),
|
.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)) {
|
verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) {
|
||||||
when (state.sheet) {
|
when (state.sheet) {
|
||||||
|
|
@ -76,11 +83,11 @@ private fun TrackChoices(state: TitleState) {
|
||||||
val version = state.version ?: return
|
val version = state.version ?: return
|
||||||
Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
|
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}")
|
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 ->
|
version.audio.forEachIndexed { index, track ->
|
||||||
PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) }
|
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
|
val start = version.audio.size
|
||||||
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) }
|
||||||
version.subtitles.forEachIndexed { index, track ->
|
version.subtitles.forEachIndexed { index, track ->
|
||||||
|
|
@ -121,10 +128,10 @@ private fun MediaInformation(state: TitleState) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
private fun MediaInfoSection(label: String, values: List<Pair<String, String>>) {
|
||||||
Text(label, color = C.dim, style = WolfskinTokens.Type.label)
|
Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop))
|
||||||
Column { values.forEach { (name, value) ->
|
Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) ->
|
||||||
Row(Modifier.fillMaxWidth()) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) {
|
||||||
Text(name, color = C.dim, style = WolfskinTokens.Type.caption, modifier = Modifier.width(D.infoLabelWidth))
|
Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth))
|
||||||
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
|
Text(value, color = C.fg, style = WolfskinTokens.Type.caption)
|
||||||
}
|
}
|
||||||
} }
|
} }
|
||||||
|
|
@ -143,7 +150,11 @@ private fun WatchedConfirmation(state: TitleState) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SheetSubtitle(text: String) {
|
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
|
@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),
|
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop),
|
||||||
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
|
verticalArrangement = Arrangement.spacedBy(D.receiptGap)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
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("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)
|
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. */
|
/** The shared shell remains authoritative; title sheets overlay it and trap all input. */
|
||||||
@Composable
|
@Composable
|
||||||
fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) {
|
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 } }
|
val focusOwner = remember(shell) { { shell.navFocused = false } }
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) {
|
||||||
ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) }
|
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)
|
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),
|
.background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop),
|
||||||
horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
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)
|
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) {
|
val displayedRows = remember(state.data) {
|
||||||
buildList {
|
buildList {
|
||||||
add("header")
|
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) {
|
if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) {
|
||||||
add("seasons")
|
add("seasons")
|
||||||
if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes")
|
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)
|
if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec)
|
||||||
}
|
}
|
||||||
val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } }
|
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 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" } }
|
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)
|
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)
|
key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell)
|
||||||
}.focusable()) {
|
}.focusable()) {
|
||||||
if (heroVisible) {
|
// This remains visible behind the scrolled rows; it is not an off-screen header image.
|
||||||
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
TitleBackdrop(state.data.item, Modifier.fillMaxSize())
|
||||||
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg))
|
||||||
} else Box(Modifier.fillMaxSize().background(C.bg))
|
|
||||||
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state)
|
||||||
else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) {
|
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") {
|
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))
|
.border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
|
||||||
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
|
.padding(horizontal = D.errorSide, vertical = D.errorVertical)) {
|
||||||
Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) {
|
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.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))
|
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
|
package org.justinvisser.wolfskin.design
|
||||||
|
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import coil3.request.ImageRequest
|
|
||||||
import coil3.request.Disposable
|
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. */
|
/** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
internal fun TitleRowPrefetch(data: TitlePageData, row: String?) {
|
||||||
val loader = LocalWolfskinImageLoader.current ?: return
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
if (row == null || data.status != TitleReadStatus.Ready) return
|
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) {
|
DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) {
|
||||||
val requests = ArrayList<Disposable>()
|
val requests = ArrayList<Disposable>()
|
||||||
fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) {
|
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) {
|
when (row) {
|
||||||
"cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots))
|
"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. */
|
/** Posters draw art at rest, with facts only in the reserved focused-caption band. */
|
||||||
@Composable
|
@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 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)) {
|
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)
|
.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() }) {
|
.clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) {
|
||||||
TitleCardArtwork(item, tile)
|
TitleTileArtwork(item)
|
||||||
if (tile) {
|
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
||||||
Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity))))
|
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
||||||
.padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) {
|
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f
|
val progress = item.progress
|
||||||
if (progress > 0f && progress < 1f) {
|
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)) {
|
.fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) {
|
||||||
Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent))
|
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))) {
|
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) {
|
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)) {
|
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,
|
Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth),
|
||||||
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 }
|
if (sheet != null) { sheetKey(key); return true }
|
||||||
when (key) {
|
when (key) {
|
||||||
ShellKey.Back -> onAction(TitleAction.Pop)
|
ShellKey.Back -> back(shell)
|
||||||
ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1)
|
ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1)
|
||||||
ShellKey.Down -> moveRow(1)
|
ShellKey.Down -> moveRow(1)
|
||||||
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
|
ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0))
|
||||||
|
|
@ -135,8 +135,14 @@ class TitleState(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Platform Back shares the sheet-first ladder with remote Back. */
|
/** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */
|
||||||
fun back() { if (sheet != null) sheet = null else onAction(TitleAction.Pop) }
|
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. */
|
/** Pointer selection stays inside the modal's row order. */
|
||||||
fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) }
|
fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) }
|
||||||
|
|
@ -227,7 +233,6 @@ class TitleState(
|
||||||
}
|
}
|
||||||
else -> sheet = null
|
else -> sheet = null
|
||||||
}
|
}
|
||||||
else -> Unit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,8 +254,8 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||||
val target = state.target
|
val target = state.target
|
||||||
if (data.status != TitleReadStatus.Ready) {
|
if (data.status != TitleReadStatus.Ready) {
|
||||||
if (data.status == TitleReadStatus.Unavailable) {
|
if (data.status == TitleReadStatus.Unavailable) {
|
||||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
add(TitleOption("retry", "Try again", "", primary = true, icon = false))
|
||||||
add(TitleOption("back", "Back", "left", icon = false))
|
add(TitleOption("back", "Back", "", icon = false))
|
||||||
}
|
}
|
||||||
return@buildList
|
return@buildList
|
||||||
}
|
}
|
||||||
|
|
@ -271,9 +276,16 @@ fun titleOptions(state: TitleState): List<TitleOption> = buildList {
|
||||||
} else if (data.connection != TitleConnection.SignedIn) {
|
} else if (data.connection != TitleConnection.SignedIn) {
|
||||||
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false))
|
||||||
} else {
|
} 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))
|
add(TitleOption("other", "For someone else", "users", icon = false))
|
||||||
if (data.hidden) add(TitleOption("unhide", "Show again", "undo", 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))
|
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 chipIcon = 13.0.dp
|
||||||
val captionWidth = 320.0.dp
|
val captionWidth = 320.0.dp
|
||||||
const val captionSeparatorOpacity = 0.4f
|
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 plateTop = 7.0.dp
|
||||||
val plateSide = 8.0.dp
|
val plateSide = 8.0.dp
|
||||||
val plateBottom = 23.0.dp
|
val plateBottom = 23.0.dp
|
||||||
|
|
@ -376,6 +379,7 @@ object WolfskinTokens {
|
||||||
val sheetGap = 9.0.dp
|
val sheetGap = 9.0.dp
|
||||||
val sheetSubLift = -4.0.dp
|
val sheetSubLift = -4.0.dp
|
||||||
val sheetSubBottom = 7.0.dp
|
val sheetSubBottom = 7.0.dp
|
||||||
|
val sheetLabelTop = 4.0.dp
|
||||||
val sheetRowSide = 13.0.dp
|
val sheetRowSide = 13.0.dp
|
||||||
val versionRowVertical = 10.0.dp
|
val versionRowVertical = 10.0.dp
|
||||||
val trackHeight = 42.0.dp
|
val trackHeight = 42.0.dp
|
||||||
|
|
@ -384,14 +388,14 @@ object WolfskinTokens {
|
||||||
val radioDot = 8.0.dp
|
val radioDot = 8.0.dp
|
||||||
val radioBorder = 1.5.dp
|
val radioBorder = 1.5.dp
|
||||||
val infoGap = 7.0.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 gridHeight = 244.0.dp
|
||||||
val minimumEndPadding = 40.0.dp
|
val minimumEndPadding = 40.0.dp
|
||||||
val ratingGap = 10.0.dp
|
val ratingGap = 10.0.dp
|
||||||
val ratingMarkGap = 4.0.dp
|
val ratingMarkGap = 4.0.dp
|
||||||
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 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)
|
val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em)
|
||||||
const val fallbackHue = 260.0f
|
const val fallbackHue = 260.0f
|
||||||
|
|
@ -413,9 +417,6 @@ object WolfskinTokens {
|
||||||
val chipBorder = 1.0.dp
|
val chipBorder = 1.0.dp
|
||||||
val heroSheetRight = 90.0.dp
|
val heroSheetRight = 90.0.dp
|
||||||
val heroSheetTop = 75.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 gridHeadingTop = 12.0.dp
|
||||||
val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em)
|
||||||
val receiptSide = 15.0.dp
|
val receiptSide = 15.0.dp
|
||||||
|
|
@ -441,6 +442,10 @@ object WolfskinTokens {
|
||||||
val tileSheetLeft = 11.0.dp
|
val tileSheetLeft = 11.0.dp
|
||||||
val tileSheetRadius = 5.0.dp
|
val tileSheetRadius = 5.0.dp
|
||||||
val tileAmbientBlur = 13.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 tileAmbientBrightness = 0.5f
|
||||||
const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))"
|
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%))"
|
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
|
val tileOverlayBottom = 9.0.dp
|
||||||
const val cardOverlayOpacity = 0.92f
|
const val cardOverlayOpacity = 0.92f
|
||||||
val cardProgressInset = 9.0.dp
|
val cardProgressInset = 9.0.dp
|
||||||
val cardProgressBottom = 6.0.dp
|
|
||||||
val tileProgressBottom = 5.0.dp
|
val tileProgressBottom = 5.0.dp
|
||||||
const val ratingPlateEntryEm = 0.64f
|
val ambientInset = 40.0.dp
|
||||||
const val ratingPlateMarkEm = 0.29f
|
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 {
|
object SavedIndicator {
|
||||||
val height = 26.0.dp
|
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"},
|
"sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"},
|
||||||
"sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"},
|
"sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"},
|
||||||
"sheetSubBottom": {"dp": 7, "px": 14, "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"},
|
"sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"},
|
||||||
"versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"},
|
"versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"},
|
||||||
"trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"},
|
"trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"},
|
||||||
|
|
@ -34,14 +35,14 @@
|
||||||
"radioDot": {"dp": 8, "px": 16, "source": "index.html:236"},
|
"radioDot": {"dp": 8, "px": 16, "source": "index.html:236"},
|
||||||
"radioBorder": {"dp": 1.5, "px": 3, "source": "index.html:234"},
|
"radioBorder": {"dp": 1.5, "px": 3, "source": "index.html:234"},
|
||||||
"infoGap": {"dp": 7, "px": 14, "source": "index.html:1948"},
|
"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"},
|
"gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"},
|
||||||
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
|
"minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"},
|
||||||
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
|
"ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"},
|
||||||
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},
|
"ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"},
|
||||||
"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"},
|
"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"},
|
"personInitialSize": {"$type": "typography", "sp": 48, "px": 96, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:1317"},
|
||||||
"fallbackHue": {"$value": 260, "source": "index.html:130"},
|
"fallbackHue": {"$value": 260, "source": "index.html:130"},
|
||||||
|
|
@ -63,9 +64,6 @@
|
||||||
"chipBorder": {"dp": 1, "px": 2, "source": "index.html:188"},
|
"chipBorder": {"dp": 1, "px": 2, "source": "index.html:188"},
|
||||||
"heroSheetRight": {"dp": 90, "px": 180, "source": "index.html:97"},
|
"heroSheetRight": {"dp": 90, "px": 180, "source": "index.html:97"},
|
||||||
"heroSheetTop": {"dp": 75, "px": 150, "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"},
|
"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"},
|
"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"},
|
"receiptSide": {"dp": 15, "px": 30, "source": "index.html:243"},
|
||||||
|
|
@ -91,6 +89,10 @@
|
||||||
"tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"},
|
"tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"},
|
||||||
"tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"},
|
"tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"},
|
||||||
"tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"},
|
||||||
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
|
"cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"},
|
||||||
"cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"},
|
"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"},
|
"tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"},
|
||||||
"ratingPlateEntryEm": {"$value": 0.64, "source": "index.html:164"},
|
"ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"},
|
||||||
"ratingPlateMarkEm": {"$value": 0.29, "source": "index.html:165"}
|
"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": {
|
"savedIndicator": {
|
||||||
"height": {"dp": 26, "px": 52, "source": "index.html:87"},
|
"height": {"dp": 26, "px": 52, "source": "index.html:87"},
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,9 @@
|
||||||
--library-chipTop: 16.0px;
|
--library-chipTop: 16.0px;
|
||||||
--library-chipIcon: 26.0px;
|
--library-chipIcon: 26.0px;
|
||||||
--library-captionWidth: 640.0px;
|
--library-captionWidth: 640.0px;
|
||||||
|
--library-captionFactsGap: 18.0px;
|
||||||
|
--library-badgeIcon: 28.0px;
|
||||||
|
--library-badgeBorder: 3.0px;
|
||||||
--library-plateTop: 14.0px;
|
--library-plateTop: 14.0px;
|
||||||
--library-plateSide: 16.0px;
|
--library-plateSide: 16.0px;
|
||||||
--library-plateBottom: 46.0px;
|
--library-plateBottom: 46.0px;
|
||||||
|
|
@ -230,6 +233,7 @@
|
||||||
--title-sheetGap: 18.0px;
|
--title-sheetGap: 18.0px;
|
||||||
--title-sheetSubLift: -8.0px;
|
--title-sheetSubLift: -8.0px;
|
||||||
--title-sheetSubBottom: 14.0px;
|
--title-sheetSubBottom: 14.0px;
|
||||||
|
--title-sheetLabelTop: 8.0px;
|
||||||
--title-sheetRowSide: 26.0px;
|
--title-sheetRowSide: 26.0px;
|
||||||
--title-versionRowVertical: 20.0px;
|
--title-versionRowVertical: 20.0px;
|
||||||
--title-trackHeight: 84.0px;
|
--title-trackHeight: 84.0px;
|
||||||
|
|
@ -238,14 +242,14 @@
|
||||||
--title-radioDot: 16.0px;
|
--title-radioDot: 16.0px;
|
||||||
--title-radioBorder: 3.0px;
|
--title-radioBorder: 3.0px;
|
||||||
--title-infoGap: 14.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-gridHeight: 488.0px;
|
||||||
--title-minimumEndPadding: 80.0px;
|
--title-minimumEndPadding: 80.0px;
|
||||||
--title-ratingGap: 20.0px;
|
--title-ratingGap: 20.0px;
|
||||||
--title-ratingMarkGap: 8.0px;
|
--title-ratingMarkGap: 8.0px;
|
||||||
--title-plateSide: 16.0px;
|
|
||||||
--title-plateTop: 14.0px;
|
|
||||||
--title-plateFade: 46.0px;
|
|
||||||
--title-initialSize: 64.0px;
|
--title-initialSize: 64.0px;
|
||||||
--title-personInitialSize: 96.0px;
|
--title-personInitialSize: 96.0px;
|
||||||
--title-skeletonKickerWidth: 420.0px;
|
--title-skeletonKickerWidth: 420.0px;
|
||||||
|
|
@ -289,6 +293,9 @@
|
||||||
--title-tileSheetLeft: 22.0px;
|
--title-tileSheetLeft: 22.0px;
|
||||||
--title-tileSheetRadius: 10.0px;
|
--title-tileSheetRadius: 10.0px;
|
||||||
--title-tileAmbientBlur: 26.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-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-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%));
|
||||||
--title-initialsColor: #ffffffd1;
|
--title-initialsColor: #ffffffd1;
|
||||||
|
|
@ -298,8 +305,12 @@
|
||||||
--title-tileOverlaySide: 22.0px;
|
--title-tileOverlaySide: 22.0px;
|
||||||
--title-tileOverlayBottom: 18.0px;
|
--title-tileOverlayBottom: 18.0px;
|
||||||
--title-cardProgressInset: 18.0px;
|
--title-cardProgressInset: 18.0px;
|
||||||
--title-cardProgressBottom: 12.0px;
|
|
||||||
--title-tileProgressBottom: 10.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-height: 52.0px;
|
||||||
--savedIndicator-side: 20.0px;
|
--savedIndicator-side: 20.0px;
|
||||||
--savedIndicator-gap: 10.0px;
|
--savedIndicator-gap: 10.0px;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py
|
811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py
|
||||||
1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json
|
b36140ec4745837b9e912deb6eba5d9ce94871a18512d7fdaddb37b7dd79ce25 shell-tokens.json
|
||||||
384416f878c9c91b718fc9f1623de53aeb8fcde4440d99c1035ce58043bad0dd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
c56109dc854d03d74baf0384e09451461ad50ca266b1cc4d598b310475927d11 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
|
||||||
10e4d911a8b6b582eab835f0631ee83c822bbd93b35480801616df8ab2679139 title-tokens.json
|
bc82f477611669fbc6dd0d3e6559b1e05b0ad280531a883102803ffb0ebcdecb title-tokens.json
|
||||||
b8e05d5275a32548a5cf8776955ce6df72a9a5f7be110bdf0e15ea79cb0d7123 tokens.css
|
ae9ca44fc6e1d3bd4ecce133ba56f2d1bce267f1754b30028a6cc0124539a6f7 tokens.css
|
||||||
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue