From a8951827baeaebb870d4e140986378b45da722d7 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 19:58:36 +0100 Subject: [PATCH] Reuse shared artwork and correct title focus edges --- .../wolfskin/tv/TitleCaptureTest.kt | 10 +- .../justinvisser/wolfskin/tv/TitleKeysTest.kt | 40 ++++++- .../wolfskin/tv/TitleLocaleTest.kt | 6 +- .../wolfskin/tv/TitleReadFacadeTest.kt | 26 ++++- .../wolfskin/tv/TitleDestination.kt | 4 +- .../justinvisser/wolfskin/tv/TitleFixture.kt | 14 +-- .../wolfskin/tv/WolfskinImageLoader.kt | 31 ------ .../wolfskin/tv/read/TitleReadFacade.kt | 6 +- docker/README.md | 62 ++++++++++- wolfskin-design/shell-tokens.json | 3 + .../justinvisser/wolfskin/design/Caption.kt | 70 ++++++++---- .../org/justinvisser/wolfskin/design/Glyph.kt | 23 +++- .../wolfskin/design/LibraryGridState.kt | 30 +++-- .../wolfskin/design/LibraryImages.kt | 6 +- .../wolfskin/design/LibraryPoster.kt | 97 ++++------------ .../justinvisser/wolfskin/design/NavBar.kt | 2 +- .../wolfskin/design/PersonPage.kt | 2 +- .../wolfskin/design/SourceRatings.kt | 105 ++++++++++++++++++ .../wolfskin/design/TitleArtwork.kt | 60 ++++++---- .../wolfskin/design/TitleControl.kt | 36 ++---- .../wolfskin/design/TitleHeader.kt | 7 +- .../wolfskin/design/TitleModel.kt | 16 +-- .../wolfskin/design/TitleOptionsSheet.kt | 29 +++-- .../justinvisser/wolfskin/design/TitlePage.kt | 26 +++-- .../{WolfskinImages.kt => TitlePrefetch.kt} | 18 +-- .../wolfskin/design/TitleRating.kt | 96 ---------------- .../wolfskin/design/TitleShelf.kt | 48 ++++---- .../wolfskin/design/TitleState.kt | 26 +++-- .../wolfskin/design/WolfskinTokens.kt | 30 +++-- .../src/main/res/drawable/title_alert.xml | 4 - .../src/main/res/drawable/title_download.xml | 3 + .../src/main/res/drawable/title_eye.xml | 4 - .../src/main/res/drawable/title_film.xml | 4 - .../src/main/res/drawable/title_play.xml | 3 - .../src/main/res/drawable/title_sparkle.xml | 3 - .../src/main/res/drawable/title_tv.xml | 4 - .../src/main/res/drawable/title_users.xml | 4 - .../src/main/res/drawable/wolfskin_film.xml | 5 +- wolfskin-design/title-tokens.json | 27 +++-- wolfskin-design/tokens.css | 21 +++- wolfskin-design/tokens.sha256 | 8 +- 41 files changed, 581 insertions(+), 438 deletions(-) delete mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinImageLoader.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt rename wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/{WolfskinImages.kt => TitlePrefetch.kt} (65%) delete mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt delete mode 100644 wolfskin-design/src/main/res/drawable/title_alert.xml create mode 100644 wolfskin-design/src/main/res/drawable/title_download.xml delete mode 100644 wolfskin-design/src/main/res/drawable/title_eye.xml delete mode 100644 wolfskin-design/src/main/res/drawable/title_film.xml delete mode 100644 wolfskin-design/src/main/res/drawable/title_play.xml delete mode 100644 wolfskin-design/src/main/res/drawable/title_sparkle.xml delete mode 100644 wolfskin-design/src/main/res/drawable/title_tv.xml delete mode 100644 wolfskin-design/src/main/res/drawable/title_users.xml diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 5a3d73b6..0a8ef616 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -134,7 +134,11 @@ class TitleCaptureTest { @Test fun st049_watched() = capture(49, transform = { it.copy(watchTarget = it.watchTarget.copy(watched = true)) }) { state, _ -> option(state, "watched") } @Test fun st050_seriesConfirm() = capture(50, "Andor") { state, shell -> open(state, shell, "watched") } @Test fun st051_episodeRestart() = capture(51, "episode-sex-and-the-country-2001-s4e9", "mindel") { state, _ -> option(state, "restart") } - @Test fun st052_filmCast() = capture(52) { state, _ -> state.focus("cast", 0) } + @Test fun st052_filmCast() = capture(52) { state, shell -> + state.focus("cast", 0) + state.key(ShellKey.Right, true, 0, shell) + state.key(ShellKey.Right, false, 0, shell) + } @Test fun st053_seriesCast() = capture(53, "Andor") { state, _ -> state.focus("cast", 0) } @Test fun st054_initialsCast() = capture(54, "Spirited Away", "shared") { state, _ -> state.focus("cast", 0) } @Test fun st055_castLoading() = capture(55, transform = { it.copy(cast = emptyList(), castStatus = TitleReadStatus.Loading) }) @@ -162,7 +166,9 @@ class TitleCaptureTest { @Test fun st067_trailerFallback() = capture(67, "The Amazing Spider-Man") @Test fun st068_requested() = capture(68, "The Amazing Spider-Man", transform = { it.copy(requestedStage = "Searching", requestActor = "Justin") }) @Test fun st069_missingArt() = capture(69, "The Amazing Spider-Man", transform = { it.copy(item = it.item.copy(poster = null, backdrop = null)) }) - @Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { it.copy(connection = TitleConnection.Offline) }) + @Test fun st070_offline() = capture(70, "The Amazing Spider-Man", transform = { + it.copy(connection = TitleConnection.Offline, savedAt = "20:42", item = it.item.copy(ratings = emptyList())) + }) @Test fun st104_relatedPlate() = capture(104, "The Girl Who Leapt Through Time") { state, _ -> state.focus("related", state.data.related.indexOfFirst { it.name == "INVINCIBLE" }.coerceAtLeast(0)) } @Test fun st106_heldRatings() = capture(106, "American Gangster", "shared") @Test fun st107_seriesEpisodeRatings() = capture(107, "Andor") diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 2b17330f..3da70809 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv import android.app.Application import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.compose.ui.input.key.Key import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onRoot @@ -9,6 +10,10 @@ import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.text.TextLayoutResult import org.justinvisser.wolfskin.design.WolfskinTokens import org.json.JSONArray import org.json.JSONObject @@ -50,7 +55,10 @@ class TitleKeysTest { private fun render(title: String = "Locke", transform: (org.justinvisser.wolfskin.design.TitlePageData) -> org.justinvisser.wolfskin.design.TitlePageData = { it }) { shell = ShellState(fixture.people, "justin", shellEffects::add).apply { pickerVisible = false; pickerLaunch = false; enterDestination(titleRoute(title)) } state = TitleState(transform(fixture.title(title, "justin")), shell.person, effects::add) - rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } + rule.setContent { WolfskinTheme { + BackHandler { state.back(shell) } + TitleSurface(state, shell) + } } } private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } @@ -251,4 +259,34 @@ class TitleKeysTest { press(Key.DirectionDown) assertEquals(expected, state.row) } + @Test fun seriesOptionsKeepTheCompleteHouseholdActionLabel() { + render("Andor") + val layouts = mutableListOf() + 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) + } } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt index 0202fff2..e9462bff 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleLocaleTest.kt @@ -18,7 +18,7 @@ import org.justinvisser.wolfskin.design.ShellState import org.justinvisser.wolfskin.design.TitleConnection import org.justinvisser.wolfskin.design.TitleItem import org.justinvisser.wolfskin.design.TitlePageData -import org.justinvisser.wolfskin.design.TitleRating +import org.justinvisser.wolfskin.design.LibraryRating import org.justinvisser.wolfskin.design.TitleState import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.design.WolfskinTheme @@ -39,14 +39,14 @@ class TitleLocaleTest { try { Locale.setDefault(locale) val person = ShellPerson("justin", "Justin", "J", false) - val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(TitleRating("tmdb", 7.8, "tmdb"))) + val item = TitleItem("movie:1", "Title", "movie", ratings = listOf(LibraryRating("tmdb", 7.8))) val shell = ShellState(listOf(person), person.key).apply { pickerVisible = false; enterDestination("title/movie:1") } val state = TitleState(TitlePageData(item, related = listOf(item.copy(id = "movie:2")), connection = TitleConnection.SignedIn), person) {} state.focus("related", 0) rule.setContent { WolfskinTheme { TitleSurface(state, shell) } } rule.mainClock.advanceTimeBy(1000) val layouts = mutableListOf() - rule.onAllNodesWithText(expected).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } + rule.onAllNodesWithText(expected, useUnmergedTree = true).onLast().performSemanticsAction(SemanticsActions.GetTextLayoutResult) { get -> get(layouts) } assertEquals(1, layouts.single().lineCount) assertFalse(layouts.single().didOverflowWidth) } finally { Locale.setDefault(previous) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt index 20157a52..f180eb38 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleReadFacadeTest.kt @@ -2,6 +2,7 @@ package org.justinvisser.wolfskin.tv import android.app.Application import android.content.Context +import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -12,8 +13,11 @@ import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.ui.successQueryResult import com.github.damontecres.wholphin.ui.successResponse import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject import kotlinx.coroutines.test.runTest import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi @@ -29,6 +33,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.junit.Assert.* +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -40,6 +45,7 @@ import org.justinvisser.wolfskin.design.TitleState import org.justinvisser.wolfskin.tv.read.TitleReadFacade import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.RuntimeEnvironment import java.util.UUID /** SDK replies stay in-process; disk reload and identity isolation exercise the real facade. */ @@ -59,9 +65,15 @@ class TitleReadFacadeTest { private val secondId = UUID.fromString("00000000-0000-0000-0000-000000000002") private val seriesId = UUID.fromString("00000000-0000-0000-0000-000000000003") private val seasonId = UUID.fromString("00000000-0000-0000-0000-000000000004") - private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString() + private val sourceId = UUID.fromString("00000000-0000-0000-0000-000000000005").toString().replace("-", "") @Before fun setup() { + // BaseItem formats runtime using application resources; no Hilt/network application is started. + val app = mockk { + every { resources } returns RuntimeEnvironment.getApplication().resources + } + mockkObject(WholphinApplication.Companion) + every { WholphinApplication.instance } returns app every { api.userLibraryApi } returns library every { api.itemsApi } returns items every { api.tvShowsApi } returns shows @@ -72,6 +84,10 @@ class TitleReadFacadeTest { facade = TitleReadFacade(api, mockk(relaxed = true), playback, repository, mockk(), context) } + @After fun teardown() { + unmockkObject(WholphinApplication.Companion) + } + private fun file(id: UUID, kind: BaseItemKind = BaseItemKind.MOVIE): BaseItemDto { val source = TestTracks.Builder().addVideo().addAudio().addSubtitle().buildMediaSourceInfo() val streams = source.mediaStreams.orEmpty().map { stream -> @@ -142,4 +158,12 @@ class TitleReadFacadeTest { assertEquals(listOf(2), data.seasonNumbers) assertEquals(2, TitleState(data, ShellPerson("justin", "Justin", "J", false)) {}.season) } + + @Test fun compactJellyfinSourceIdUsesTheSameVersionPersistenceKey() = runTest { + coEvery { library.getItem(firstId) } returns successResponse(file(firstId)) + coEvery { playback.savePlayVersion(any(), any()) } returns null + val data = facade.read(firstId, null, preferences) + facade.apply(org.justinvisser.wolfskin.design.TitleAction.Version(data.watchTarget, data.versions.single())) + coVerify(exactly = 1) { playback.savePlayVersion(firstId, UUID.fromString("00000000-0000-0000-0000-000000000005")) } + } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt index f361376f..2223c3ee 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleDestination.kt @@ -235,8 +235,10 @@ fun TitleDestination(route: String, shell: ShellState, preferences: UserPreferen LaunchedEffect(title?.data?.status) { if (title?.data?.status == TitleReadStatus.Ready) shell.navFocused = false } - BackHandler { title?.back() } val person = viewModel.person + BackHandler(enabled = title != null) { + title?.back(shell) + } if (person != null && route.startsWith("person/")) PersonSurface(person, shell, modifier) else if (title != null) TitleSurface(title, shell, modifier) } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt index c7fe1b84..3e9709aa 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/TitleFixture.kt @@ -14,7 +14,7 @@ import org.justinvisser.wolfskin.design.TitleConnection import org.justinvisser.wolfskin.design.TitleItem import org.justinvisser.wolfskin.design.TitlePageData import org.justinvisser.wolfskin.design.TitlePerson -import org.justinvisser.wolfskin.design.TitleRating +import org.justinvisser.wolfskin.design.LibraryRating import org.justinvisser.wolfskin.design.TitleTrack import org.justinvisser.wolfskin.design.TitleVersion import java.util.Locale @@ -80,23 +80,23 @@ class TitleFixture(private val fixture: JSONObject) { private fun art(path: String?): String? = path?.takeIf { it in availableArt }?.let { "file:///android_asset/wolfskin-titles/$it" } - private fun ratings(item: JSONObject): List { + private fun ratings(item: JSONObject): List { val episode = item.optString("type") == "episode" if (episode) { val series = all.firstOrNull { it.optString("title") == item.optString("seriesTitle") } val base = series?.let { ratings(it) }.orEmpty() val score = item.optDouble("communityRating", Double.NaN) - return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + TitleRating("tmdb", score, "tmdb", true) else base + return if (score in 0.0..10.0) base.filter { it.source != "tmdb" } + LibraryRating("tmdb", score, episode = true) else base } val ratings = item.optJSONObject("ratings") ?: JSONObject().apply { if (!item.isNull("criticRating")) put("rtCritics", item.getDouble("criticRating")) if (!item.isNull("communityRating")) put("tmdb", item.getDouble("communityRating")) } return buildList { - if (!ratings.isNull("rtCritics")) add(TitleRating("rtCritics", ratings.getDouble("rtCritics"), when (ratings.optString("rtCriticsLabel")) { "Rotten" -> "rotten"; "Fresh", "Certified Fresh" -> "fresh"; else -> "tomato" })) - if (!ratings.isNull("rtAudience")) add(TitleRating("rtAudience", ratings.getDouble("rtAudience"), if (ratings.optString("rtAudienceLabel") == "Spilled") "spilled" else "upright")) - if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(TitleRating("imdb", ratings.getDouble("imdb"), "imdb")) - if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(TitleRating("tmdb", ratings.getDouble("tmdb"), "tmdb")) + if (!ratings.isNull("rtCritics")) add(LibraryRating("rtCritics", ratings.getDouble("rtCritics"), ratings.optString("rtCriticsLabel").takeIf { it.isNotEmpty() })) + if (!ratings.isNull("rtAudience")) add(LibraryRating("rtAudience", ratings.getDouble("rtAudience"), ratings.optString("rtAudienceLabel").takeIf { it.isNotEmpty() })) + if (item.optString("type") == "movie" && (!ratings.has("imdbVotes") || ratings.optInt("imdbVotes") > 0) && !ratings.isNull("imdb")) add(LibraryRating("imdb", ratings.getDouble("imdb"))) + if (isEmpty() && (!ratings.has("tmdbVotes") || ratings.optInt("tmdbVotes") > 0) && !ratings.isNull("tmdb")) add(LibraryRating("tmdb", ratings.getDouble("tmdb"))) }.filter { it.score in 0.0..if (it.source.startsWith("rt")) 100.0 else 10.0 } } diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinImageLoader.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinImageLoader.kt deleted file mode 100644 index 29b189d2..00000000 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinImageLoader.kt +++ /dev/null @@ -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() -} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt index 0d714dd9..ee8fac28 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/read/TitleReadFacade.kt @@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.json.JSONObject import org.justinvisser.wolfskin.design.TitleAction import org.justinvisser.wolfskin.design.TitleConnection @@ -97,7 +98,10 @@ class TitleReadFacade @Inject constructor( /** Track selection uses the same repository consumed by the upstream player. */ suspend fun apply(action: TitleAction) = withContext(Dispatchers.IO) { when (action) { - is TitleAction.Version -> playback.savePlayVersion(UUID.fromString(action.item.id), UUID.fromString(action.version.id)) + is TitleAction.Version -> playback.savePlayVersion( + UUID.fromString(action.item.id), + requireNotNull(action.version.id.toUUIDOrNull()) { "Invalid Jellyfin media source identifier" }, + ) is TitleAction.Track -> { val id = UUID.fromString(action.item.id) val item = BaseItem(api.userLibraryApi.getItem(id).content) diff --git a/docker/README.md b/docker/README.md index 7aca5bef..be05469c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -59,8 +59,9 @@ cover the Wolfskin nav. The independent `:wolfskin-design` module owns the shell components, fixed person colours, focus policy, per-person root-view memory, palette and motion. Its `tokens.json` is the copied approved TV-spec source, retaining the locked prototype -SHA and provenance. `shell-tokens.json` adds shell and grid measurements from that -same prototype. Generate the committed Kotlin, CSS and checksum manifest together: +SHA and provenance. `shell-tokens.json` adds shell and grid measurements; +`title-tokens.json` adds title, performer and playback-sheet measurements from +that same prototype. Generate the committed Kotlin, CSS and checksum manifest together: ```sh python3 wolfskin-design/generate_tokens.py @@ -149,8 +150,10 @@ Home/Discover shelves open a full list through The optional suspend page provider returns `LibraryPage(items, hasMore)` using the generated Conductarr client. The grid reads within ten cards of its end, deduplicates title identities, and pauses on Try again or Show more rather than looping. -Title activation pushes the explicitly named `library-title/{id}` destination for -the title-screen implementation; this slice does not implement title pages. +Title activation opens `title/{id}` through `Destination.Wolfskin` for prototype +identities, or the existing typed media destination for real Jellyfin identities. +Movies, series, episodes and people use the native title family; collections and +music keep their upstream destinations. `LibraryCaptureTest` renders the sixteen approved library/See all states offline. `LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly @@ -158,6 +161,57 @@ the title-screen implementation; this slice does not implement title pages. failure/retry, stale copies, and person/server isolation. Captures are written to the same `build/wolfskin-captures/` directory as the shell captures. +## Title pages and people + +`TitleSurface` owns the film/series/episode header, season chips, episode tiles, +cast and related shelves. `PersonSurface` owns the real biography and continuous +six-column filmography. Both reuse the shell/nav/back ladder, `LibraryPoster`, +the adaptive `LibraryRatingPlate`, shared captions and the single bounded +`LibraryImages` loader. Image requests use exact slot sizes, RGB565 for opaque +posters, no crossfade, four concurrent reads and next-row prefetch. The fixed +title backdrop remains visible behind deep rows; only off-screen row images +leave composition. + +The options row preserves complete labels beyond the copy-column measure. +Version choices close after selection; Audio and subtitles stays open, including +an explicit Off choice. Real source IDs use the Jellyfin SDK UUID parser, and +stream indexes remain the server's indexes rather than positions in a list. +Selections persist through the same playback repository as the upstream player. +Marking a whole series asks first; an explicit episode remains the exact play and +watched target even while another season is selected. Nested person/title visits +retain their entry's focus and selection state. +Hardware and remote Back share the sheet/picker/request-overlay precedence, then +pop a pushed title/person from content **or** nav. Initial loading has no content +target: Down stays in nav, while Back can still leave the title. A missing rating +set emits no layout node, so it cannot reserve an invisible extra facts gap. + +`TitleReadFacade` confines SDK reads, server/user-scoped last-good disk copies, +person credits and playback mutations to one boundary. Cached content appears +before refresh. Production ratings stay hidden until Conductarr sign-in exists. +Debug-only `wolfskin-titles.json` and local `wolfskin-titles/assets/` project the +locked household fixture, including portraits; release never packages them. +Those synthetic IDs are not invented Jellyfin playback IDs. Real held-title +playback uses the existing player; debug catalogue identities cannot play a file. + +Held trailers use Jellyfin remote/local trailer data. YouTube URLs target an +installed YouTube handler such as SmartTube, never a browser. No handler leaves +the title visible with a calm message. Titles nobody holds have no Trailer +button until Conductarr's trailer field exists. Conductarr sign-in and request +submission/receipt workflows belong to their separate slices; title pages do not +pretend those operations succeeded or fall back to Seerr. + +`TitleCaptureTest` writes the 34 approved title/person/options frames and native +heading layout measurements at 1920×1080, density 2.0. The approved typeface ruling +keeps platform Roboto rather than bundling the browser's DejaVu Sans: there is no +per-title compensation and no wrapping-parity claim. `TitleKeysTest` replays +real Android key events from title entry, including modal Back, held OK, nearest +row entry, remembered columns, pending seasons and mouse-wheel re-entry. +`title-press-paths.json` also retains the complete launch paths and their entry +boundaries; their Home prefixes require the Home surface before end-to-end replay. +SDK cache, exact-episode, track-index, identity, locale-layout and trailer-handler +tests run entirely offline with in-process service replies and local art. + + ## Toolchain and disk use `Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c605746..e13046b6 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -325,6 +325,9 @@ "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, "captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"}, + "captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"}, + "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, + "badgeBorder": {"$type":"dimension","px":3,"dp":1.5,"source":"index.html:144"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, "plateBottom": {"$type":"dimension","px":46,"dp":23,"source":"index.html:162"}, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index b0740cef..de702d29 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -11,38 +11,60 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.constrainHeight /** Captions fade without moving the reserved band under the focused poster. */ @Composable -fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false) { +fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false) { val alpha = remember(title, facts) { Animatable(0f) } LaunchedEffect(title, facts, visible) { alpha.animateTo(if (visible) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f))) } Column(modifier.graphicsLayer { this.alpha = alpha.value }) { Text(title, color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - val styledFacts = remember(facts, accentLastFact) { - buildAnnotatedString { - val separator = " · " - append(facts) - if (accentLastFact) { - val suffix = facts.lastIndexOf(separator).takeIf { it >= 0 }?.plus(separator.length) ?: facts.length - if (suffix < facts.length) addStyle(SpanStyle(color = WolfskinTokens.Color.accentText), suffix, facts.length) - } - var start = facts.indexOf(separator) - if (start >= 0) { - val muted = WolfskinTokens.Color.muted - val separatorStyle = SpanStyle(color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity)) - val dotOffset = separator.indexOf('·') - while (start >= 0) { - addStyle(separatorStyle, start + dotOffset, start + dotOffset + 1) - start = facts.indexOf(separator, start + separator.length) - } - } - } - } - Text(styledFacts, color = WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + CaptionFacts(facts, accentFirstFact, accentLastFact) + } +} + +/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */ +@Composable +private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean) { + val parts = remember(facts) { facts.split(" · ") } + val muted = WolfskinTokens.Color.muted + val colour = WolfskinTokens.Color.accentText + val style = WolfskinTokens.Type.caption + Layout( + content = { + parts.forEachIndexed { index, part -> + val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) + Text(part, color = if (accented) colour else muted, style = style, maxLines = 1, overflow = TextOverflow.Clip) + if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style) + } + }, + modifier = Modifier.fillMaxWidth().clipToBounds(), + ) { children, constraints -> + val gap = WolfskinTokens.Library.captionFactsGap.roundToPx() + val basis = IntArray(children.size) + var fixed = gap * (children.size - 1).coerceAtLeast(0) + var flexible = 0 + fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast) + children.forEachIndexed { index, child -> + val width = child.maxIntrinsicWidth(constraints.maxHeight) + basis[index] = width + if (shrinks(index)) flexible += width else fixed += width + } + val available = (constraints.maxWidth - fixed).coerceAtLeast(0) + val ratio = if (flexible > available) available.toFloat() / flexible else 1f + val measured = children.mapIndexed { index, child -> + val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index] + child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0)) + } + layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) { + var x = 0 + measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap } + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt index cd313516..244d60b1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Glyph.kt @@ -6,23 +6,36 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.tv.material3.Icon -/** Shell glyph geometry is copied from the locked prototype, not a platform icon font. */ +/** Shared glyph geometry comes from the locked prototype, never a platform icon font. */ @Composable fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) { val resource = when (name) { "gear" -> R.drawable.wolfskin_gear "requests" -> R.drawable.wolfskin_tray "interface" -> R.drawable.wolfskin_grid - "playback" -> R.drawable.wolfskin_play - "nextup" -> R.drawable.wolfskin_tv - "profile" -> R.drawable.wolfskin_users + "play", "playback" -> R.drawable.wolfskin_play + "tv", "nextup" -> R.drawable.wolfskin_tv + "users", "profile" -> R.drawable.wolfskin_users "about" -> R.drawable.wolfskin_info - "seerr" -> R.drawable.wolfskin_sparkle + "sparkle", "seerr" -> R.drawable.wolfskin_sparkle "advanced" -> R.drawable.wolfskin_sort "alert" -> R.drawable.wolfskin_alert "film" -> R.drawable.wolfskin_film "eye" -> R.drawable.wolfskin_eye "down" -> R.drawable.wolfskin_down + "restart" -> R.drawable.title_restart + "version" -> R.drawable.title_version + "tracks" -> R.drawable.title_tracks + "eyeoff" -> R.drawable.title_eyeoff + "trailer" -> R.drawable.title_trailer + "more" -> R.drawable.title_more + "plus" -> R.drawable.title_plus + "clock" -> R.drawable.title_clock + "download" -> R.drawable.title_download + "wifi" -> R.drawable.title_wifi + "check" -> R.drawable.title_check + "undo" -> R.drawable.title_undo + "left" -> R.drawable.title_left else -> R.drawable.wolfskin_right } Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index 0921f582..e782be6f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -5,26 +5,41 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.derivedStateOf import java.util.Locale +import androidx.compose.runtime.Immutable private val leadingArticle = Regex("^(the|a|an)\\s+", RegexOption.IGNORE_CASE) +/** Art-only card data shared without per-card adapter allocations. */ +@Immutable +interface PosterContent { + val id: String + val posterTitle: String + val posterIsMovie: Boolean + val poster: String? + val ratings: List + val progress: Float + val badge: String? +} + /** Immutable title facts cross the provider boundary without SDK objects. */ data class LibraryTitle( - val id: String, + override val id: String, val title: String, val type: String, val year: Int? = null, val runtimeSeconds: Long? = null, val seasons: Int? = null, - val poster: String? = null, + override val poster: String? = null, val played: Boolean = false, - val progress: Float = 0f, + override val progress: Float = 0f, val addedAt: String = "", - val ratings: List = emptyList(), + override val ratings: List = emptyList(), val availability: String = "", - val badge: String? = null, + override val badge: String? = null, val tmdbId: String? = null, -) { +) : PosterContent { + override val posterTitle: String get() = title + override val posterIsMovie: Boolean get() = type == "movie" val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) val catalogueKey: String = "$type:${tmdbId ?: id}" val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' @@ -40,7 +55,8 @@ data class LibraryTitle( } /** Ordered rating entries are formatted after the active TV locale is known. */ -data class LibraryRating(val source: String, val score: Double, val label: String? = null) { +@Immutable +data class LibraryRating(val source: String, val score: Double, val label: String? = null, val episode: Boolean = false) { fun formatted(locale: Locale = Locale.getDefault()): String = if (source.startsWith("rt")) "${score.toInt()}%" else String.format(locale, "%.1f", score) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryImages.kt index be6a4361..609a5dc6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryImages.kt @@ -26,7 +26,7 @@ object LibraryImages { .components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) } .crossfade(false).build().also { loader = it } - fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster) - .size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt()) - .allowRgb565(true).crossfade(false).build() + fun request(context: Context, path: String, width: Int = WolfskinTokens.Image.posterRequestWidth, + height: Int = WolfskinTokens.Image.posterRequestHeight, poster: Boolean = true): ImageRequest = + ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build() } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index 328bdc23..dc1d57f8 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -1,9 +1,8 @@ package org.justinvisser.wolfskin.design -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -11,20 +10,10 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp -import androidx.tv.material3.Text import coil3.compose.AsyncImage import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L @@ -32,92 +21,46 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Library as L /** Poster slots use exact requests and share the shell's draw-only focus treatment. */ @Composable fun LibraryPoster( - title: LibraryTitle, + title: PosterContent, focused: Boolean, ratingsVisible: Boolean, catalogue: Boolean, modifier: Modifier = Modifier, offlineArt: ((String) -> ImageBitmap?)? = null, ) { + val poster = title.poster val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) .focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) .testTag("poster-${title.id}")) { Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) { - val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } } - if (bitmap != null) Image(bitmap, title.title, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - else if (title.poster != null && offlineArt == null) { + val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } + if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + else if (poster != null && offlineArt == null) { + var ready by remember(poster) { mutableStateOf(false) } + if (!ready) MediaArtworkFallback(title.posterTitle, title.posterIsMovie) val context = LocalContext.current - val request = remember(title.poster, context) { LibraryImages.request(context, title.poster) } - AsyncImage(request, title.title, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - } else MissingLibraryArt(title) + val request = remember(poster, context) { LibraryImages.request(context, poster) } + AsyncImage(request, title.posterTitle, imageLoader = LibraryImages.loader(context), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + onSuccess = { ready = true }, onError = { ready = false }) + } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) - if (title.progress > 0f && title.progress < 1f) { + val progress = title.progress + if (progress > 0f && progress < 1f) { Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Layout.captionOffset, vertical = WolfskinTokens.Layout.navItemGap) .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).background(C.progressTrack, CircleShape)) { - Box(Modifier.fillMaxHeight().fillMaxWidth(title.progress).background(C.accent, CircleShape)) + Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) } } if (catalogue && title.badge != null) Box(Modifier.align(Alignment.BottomEnd).padding(WolfskinTokens.Layout.badgeInset) - .size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape), contentAlignment = Alignment.Center) { - Text(if (title.badge == "held") "✓" else if (title.badge == "failed") "!" else "◷", color = if (title.badge == "held") C.onFocus else C.accent, style = WolfskinTokens.Type.button) + .size(WolfskinTokens.Layout.badge).background(if (title.badge == "held") C.ok else C.badgeRest, CircleShape) + .then(if (title.badge != "held") Modifier.border(L.badgeBorder, if (title.badge == "failed") C.fail else C.accent, CircleShape) else Modifier), + contentAlignment = Alignment.Center) { + Glyph(if (title.badge == "held") "check" else if (title.badge == "failed") "alert" else "clock", + if (title.badge == "held") C.onFocus else if (title.badge == "failed") C.fail else C.accent, Modifier.size(L.badgeIcon)) } } } } -@Composable -private fun MissingLibraryArt(title: LibraryTitle) { - val hue = remember(title.title) { title.title.fold(0) { hash, character -> (hash * L.fallbackHueMultiplier.toInt() + character.code) % 360 }.toFloat() } - Column(Modifier.fillMaxSize().background(Brush.linearGradient(listOf( - Color.hsl(hue, L.fallbackSaturationTop, L.fallbackLightnessTop), - Color.hsl(hue, L.fallbackSaturationBottom, L.fallbackLightnessBottom)))) - .padding(L.fallbackPadding), verticalArrangement = Arrangement.SpaceBetween) { - Glyph(if (title.type == "movie") "film" else "nextup", C.fg.copy(alpha = L.fallbackIconOpacity), Modifier.size(L.fallbackIcon)) - Text(title.title, style = WolfskinTokens.Type.button, color = C.fg, maxLines = 4) - } -} - -/** A measured prefix stays on one line, shrinking only to the approved type floor. */ -@Composable -fun LibraryRatingPlate(ratings: List, 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) - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index ef4bb613..9521ce1b 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -105,7 +105,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, .offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button)) .padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph("wifi", C.warn, Modifier.size(V.icon)) + Glyph("wifi", C.warn, Modifier.size(V.icon)) Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index e1640b81..cade1bcd 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -145,7 +145,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { row.forEachIndexed { column, item -> val index = rowIndex * columns + column - TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), onFocus = { state.index = index }) { state.click(index) } + TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt new file mode 100644 index 00000000..380bb809 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/SourceRatings.kt @@ -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, 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, 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) + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt index 46a2f204..22b617f2 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleArtwork.kt @@ -15,6 +15,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ShaderBrush @@ -28,8 +33,8 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.tv.material3.Text -import coil3.SingletonImageLoader import coil3.compose.AsyncImage import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L @@ -46,9 +51,9 @@ fun TitleArtwork(path: String?, width: Int, height: Int, modifier: Modifier = Mo else if (path != null) { val context = LocalContext.current val request = remember(context, path, width, height, poster) { - titleImageRequest(context, path, width, height, poster) + LibraryImages.request(context, path, width, height, poster) } - AsyncImage(model = request, imageLoader = LocalWolfskinImageLoader.current ?: SingletonImageLoader.get(context), + AsyncImage(model = request, imageLoader = LibraryImages.loader(context), contentDescription = null, modifier = modifier, contentScale = ContentScale.Crop, colorFilter = colorFilter) } } @@ -93,40 +98,57 @@ fun TitleBackdrop(item: TitleItem, modifier: Modifier = Modifier) { for (index in 0 until 15) matrix.values[index] *= filter[3].toFloat() ColorFilter.colorMatrix(matrix) } - TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, Modifier.fillMaxSize().blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) + TitleArtwork(item.poster, WolfskinTokens.Image.heroRequestWidth, WolfskinTokens.Image.heroRequestHeight, + Modifier.fillMaxSize().artOverscan(D.ambientInset).graphicsLayer { scaleX = D.ambientScale; scaleY = D.ambientScale } + .blur(Dp(filter[1].toFloat() / 2)), colorFilter = colorFilter) + val posterShape = remember { RoundedCornerShape(WolfskinTokens.Radius.heroSheet) } + val posterShadow = remember { Shadow(radius = D.posterShadowRadius, offset = DpOffset.Zero.copy(y = D.posterShadowY), color = Color.Black.copy(alpha = D.posterShadowOpacity)) } TitleArtwork(item.poster, (L.heroSheetWidth.value * 2).toInt(), (L.heroSheetHeight.value * 2).toInt(), Modifier.offset(WolfskinTokens.Shell.stageWidth - L.heroSheetWidth - D.heroSheetRight, D.heroSheetTop) - .size(L.heroSheetWidth, L.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet))) + .size(L.heroSheetWidth, L.heroSheetHeight).dropShadow(posterShape, posterShadow).clip(posterShape)) } Box(Modifier.fillMaxSize().background(horizontal)) Box(Modifier.fillMaxSize().background(vertical)) } } -/** Fallback art stays behind an image, so a failed decode never leaves an empty card. */ +/** Library, related and person posters share the exact locked missing-art treatment. */ @Composable -fun TitleCardArtwork(item: TitleItem, tile: Boolean) { - Box(Modifier.fillMaxSize().titleFallbackBackground(item.seriesName ?: item.name, D.cardFallbackGradient)) { - TitleGlyph(if (item.kind == "movie") "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph)) - if (!tile) Text(item.name, color = C.fg, style = D.fallbackTitle, maxLines = 4, +internal fun MediaArtworkFallback(name: String, movie: Boolean, showTitle: Boolean = true) { + Box(Modifier.fillMaxSize().titleFallbackBackground(name, D.cardFallbackGradient)) { + Glyph(if (movie) "film" else "tv", D.fallbackGlyphColor, Modifier.offset(D.fallbackInset, D.fallbackInset).size(D.fallbackGlyph)) + if (showTitle) Text(name, color = C.fg, style = D.fallbackTitle, maxLines = 4, overflow = TextOverflow.Ellipsis, modifier = Modifier.align(Alignment.BottomStart).padding(D.fallbackInset)) - if (tile && item.isPosterBackdrop && item.poster != null) { + } +} + +/** Episode tiles use their backdrop, or the prototype's blurred poster and small one-sheet. */ +@Composable +fun TitleTileArtwork(item: TitleItem) { + Box(Modifier.fillMaxSize()) { + MediaArtworkFallback(item.seriesName ?: item.name, item.kind == "movie", showTitle = false) + if (item.isPosterBackdrop && item.poster != null) { val filter = remember { ColorFilter.colorMatrix(ColorMatrix().apply { for (index in 0 until 15) values[index] *= D.tileAmbientBrightness }) } - TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().blur(D.tileAmbientBlur), colorFilter = filter) + TitleArtwork(item.poster, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize().artOverscan(D.tileAmbientInset).blur(D.tileAmbientBlur), colorFilter = filter) val height = WolfskinTokens.Card.tileH - D.tileSheetInset * 2 + val shape = remember { RoundedCornerShape(D.tileSheetRadius) } + val shadow = remember { Shadow(radius = D.tileShadowRadius, offset = DpOffset.Zero.copy(y = D.tileShadowY), color = Color.Black.copy(alpha = D.tileShadowOpacity)) } TitleArtwork(item.poster, (height.value * 2 * 2 / 3).toInt(), (height.value * 2).toInt(), - Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).clip(RoundedCornerShape(D.tileSheetRadius)), poster = true) - } else { - TitleArtwork(if (tile) item.backdrop else item.poster, - if (tile) WolfskinTokens.Image.tileRequestWidth else WolfskinTokens.Image.posterRequestWidth, - if (tile) WolfskinTokens.Image.tileRequestHeight else WolfskinTokens.Image.posterRequestHeight, - Modifier.fillMaxSize(), poster = !tile) - } + Modifier.offset(D.tileSheetLeft, D.tileSheetInset).size(height * (2f / 3), height).dropShadow(shape, shadow).clip(shape), poster = true) + } else TitleArtwork(item.backdrop, WolfskinTokens.Image.tileRequestWidth, WolfskinTokens.Image.tileRequestHeight, Modifier.fillMaxSize()) } } +private fun Modifier.artOverscan(inset: Dp): Modifier = layout { measurable, constraints -> + val pixels = inset.roundToPx() + val width = constraints.maxWidth + pixels * 2 + val height = constraints.maxHeight + pixels * 2 + val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width, minHeight = height, maxHeight = height)) + layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(-pixels, -pixels) } +} + private fun Modifier.titleFallbackBackground(name: String, token: String): Modifier = drawWithCache { var hue = 0 name.forEach { hue = (hue * 31 + it.code) % 360 } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index f02c00af..9b3b6fdb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -14,9 +14,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.platform.testTag -import androidx.tv.material3.Icon +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.tv.material3.Text import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L @@ -33,42 +36,17 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod val shape = RoundedCornerShape(WolfskinTokens.Radius.button) val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) + .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent) .background(background, shape) .then(if (option.on) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), horizontalArrangement = Arrangement.spacedBy(D.iconGap, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { - if (option.glyph.isNotEmpty()) TitleGlyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) + if (option.glyph.isNotEmpty()) Glyph(option.glyph, foreground, Modifier.size(if (option.icon) D.iconSize else L.buttonIcon)) if (!option.icon) Text(option.label, color = foreground, style = WolfskinTokens.Type.button, maxLines = 1) } } -/** Title glyphs are the locked prototype paths, independent of platform icon substitutions. */ -@Composable -fun TitleGlyph(name: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier = Modifier) { - val resource = when (name) { - "play" -> R.drawable.title_play - "restart" -> R.drawable.title_restart - "version" -> R.drawable.title_version - "tracks" -> R.drawable.title_tracks - "eyeoff" -> R.drawable.title_eyeoff - "eye" -> R.drawable.title_eye - "trailer" -> R.drawable.title_trailer - "more" -> R.drawable.title_more - "plus" -> R.drawable.title_plus - "clock" -> R.drawable.title_clock - "users" -> R.drawable.title_users - "tv" -> R.drawable.title_tv - "film" -> R.drawable.title_film - "alert" -> R.drawable.title_alert - "wifi" -> R.drawable.title_wifi - "check" -> R.drawable.title_check - "sparkle" -> R.drawable.title_sparkle - "undo" -> R.drawable.title_undo - else -> R.drawable.title_left - } - Icon(painterResource(resource), contentDescription = null, modifier = modifier, tint = color) -} /** A radio remains visibly selected independently of the inverse focused row. */ @Composable diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt index 5adef0ff..366a0d0d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleHeader.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -38,7 +39,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie val type = if (item.kind == "movie") "Movie" else "Series" Column(modifier.offset(L.safeSide, L.detailCopyTop + D.lineLift).width(L.detailCopyWidth)) { Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon)) + Glyph(if (item.held) "check" else "sparkle", C.accentText, Modifier.size(D.lineIcon)) Text(buildString { append("$type · ${if (item.held) "In your library" else "Not in your library"}") if (item.held && state.watched) append(" · Watched") @@ -57,7 +58,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie state.data.requestedStage?.let { stage -> val color = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost ready" -> C.accent; else -> C.muted } Row(Modifier.padding(bottom = D.lineGap), horizontalArrangement = Arrangement.spacedBy(L.navItemGap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph(if (stage == "Ready") "check" else "clock", color, Modifier.size(D.lineIcon)) + Glyph(titleStageGlyph(stage), color, Modifier.size(D.lineIcon)) Text(stage, color = color, style = WolfskinTokens.Type.label) Text("for you · by ${state.data.requestActor.orEmpty()}", color = C.muted, style = D.dLine) } @@ -79,7 +80,7 @@ fun TitleHeader(state: TitleState, active: Boolean, modifier: Modifier = Modifie if (state.data.connection != TitleConnection.SignedOut) TitleRatings(target.ratings, style = WolfskinTokens.Type.label) } } - Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { + Row(Modifier.wrapContentWidth(Alignment.Start, unbounded = true), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, active && state.column == index, Modifier.onGloballyPositioned { state.recordCenter("header", index, it.boundsInRoot().center.x) }, onFocus = { state.focus("header", index) }) { state.click("header", index) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 2b104c5f..052627dc 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Immutable /** Screen data is independent of Jellyfin and Conductarr wire objects. */ @Immutable data class TitleItem( - val id: String, + override val id: String, val name: String, val kind: String, val year: Int? = null, @@ -13,7 +13,7 @@ data class TitleItem( val seasons: Int = 0, val overview: String = "", val genres: List = emptyList(), - val poster: String? = null, + override val poster: String? = null, val backdrop: String? = null, val held: Boolean = false, val watched: Boolean = false, @@ -22,11 +22,15 @@ data class TitleItem( val seriesName: String? = null, val season: Int = 0, val episode: Int = 0, - val ratings: List = emptyList(), + override val ratings: List = emptyList(), val character: String? = null, val otherOwners: List = emptyList(), val isPosterBackdrop: Boolean = false, -) { +) : PosterContent { + override val posterTitle: String get() = name + override val posterIsMovie: Boolean get() = kind == "movie" + override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f + override val badge: String? get() = if (held) "held" else null val episodeLabel: String get() = "S$season E$episode" val runtime: String get() { val minutes = (runtimeSeconds + 30) / 60 @@ -34,10 +38,6 @@ data class TitleItem( } } -/** A rating is supplied only after sign-in and only when its source observed a valid score. */ -@Immutable -data class TitleRating(val source: String, val score: Double, val mark: String, val episode: Boolean = false) - /** A cast identity can open a performer page without borrowing a title's identity. */ @Immutable data class TitlePerson(val id: String, val name: String, val character: String = "", val portrait: String? = null, val biography: String = "") diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 0f8891fe..0c3d6bd5 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -31,6 +31,12 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.tv.material3.Text +import androidx.compose.ui.draw.dropShadow +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.shadow.Shadow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.layout.layout import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D @@ -40,13 +46,14 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D fun TitleOptionsSheet(state: TitleState, shell: ShellState) { val focus = remember { FocusRequester() } LaunchedEffect(state.sheet) { focus.requestFocus() } + val shadow = remember { Shadow(radius = D.sheetShadowRadius, offset = DpOffset.Zero.copy(x = D.sheetShadowX), color = Color.Black.copy(alpha = D.sheetShadowOpacity)) } Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable { } .testTag("title-sheet").semantics { stateDescription = "${state.sheet}:${state.sheetIndex}" } .focusRequester(focus).onPreviewKeyEvent { event -> val key = readShellKey(event) key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) }.focusable()) { - Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().background(C.surface) + Column(Modifier.align(Alignment.TopEnd).width(L.sheetWidth).fillMaxHeight().dropShadow(RectangleShape, shadow).background(C.surface) .verticalScroll(rememberScrollState()).padding(horizontal = D.sheetPaddingSide, vertical = D.sheetPaddingTop), verticalArrangement = Arrangement.spacedBy(if (state.sheet == TitleSheet.Tracks) D.trackGap else if (state.sheet == TitleSheet.MediaInfo) D.infoGap else D.sheetGap)) { when (state.sheet) { @@ -76,11 +83,11 @@ private fun TrackChoices(state: TitleState) { val version = state.version ?: return Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle) SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}") - Text("Audio", color = C.dim, style = WolfskinTokens.Type.label) + Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop)) version.audio.forEachIndexed { index, track -> PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } } - Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label) + Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop)) val start = version.audio.size PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) } version.subtitles.forEachIndexed { index, track -> @@ -121,10 +128,10 @@ private fun MediaInformation(state: TitleState) { @Composable private fun MediaInfoSection(label: String, values: List>) { - Text(label, color = C.dim, style = WolfskinTokens.Type.label) - Column { values.forEach { (name, value) -> - Row(Modifier.fillMaxWidth()) { - Text(name, color = C.dim, style = WolfskinTokens.Type.caption, modifier = Modifier.width(D.infoLabelWidth)) + Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop)) + Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { + Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth)) Text(value, color = C.fg, style = WolfskinTokens.Type.caption) } } } @@ -143,7 +150,11 @@ private fun WatchedConfirmation(state: TitleState) { @Composable private fun SheetSubtitle(text: String) { - Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.sheetSubBottom)) + Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints -> + val child = measurable.measure(constraints) + val lift = D.sheetSubLift.roundToPx() + layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) } + }.padding(bottom = D.sheetSubBottom)) } @Composable @@ -151,7 +162,7 @@ private fun HiddenReceipt(state: TitleState) { Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.receiptSide, vertical = D.receiptTop), verticalArrangement = Arrangement.spacedBy(D.receiptGap)) { Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph("check", C.fg, Modifier.size(D.receiptIcon)) + Glyph("check", C.fg, Modifier.size(D.receiptIcon)) Text("Hidden from your picks", color = C.fg, style = D.receiptTitle) } Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index 156c9588..db850792 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -52,7 +52,18 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Title as D /** The shared shell remains authoritative; title sheets overlay it and trap all input. */ @Composable fun TitleSurface(state: TitleState, shell: ShellState, modifier: Modifier = Modifier) { - Box(modifier.fillMaxSize()) { + Box(modifier.fillMaxSize().onPreviewKeyEvent { event -> + // No content target exists during an initial load; nav Down must not enter a skeleton. + if (state.data.status != TitleReadStatus.Loading || shell.pickerVisible || shell.requestsVisible) false + else when (readShellKey(event)) { + ShellKey.Down -> true + ShellKey.Back -> { + if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.repeatCount == 0) state.back(shell) + true + } + else -> false + } + }) { val focusOwner = remember(shell) { { shell.navFocused = false } } androidx.compose.runtime.CompositionLocalProvider(LocalTitleFocusOwner provides focusOwner) { ShellSurface(shell, handlesContentKeys = false, savedAt = state.data.savedAt) { TitlePage(state, shell, it) } @@ -72,7 +83,7 @@ internal fun TitleNotice(message: String?, modifier: Modifier = Modifier, onDism Row(modifier.padding(top = L.navBand, end = L.safeSide).widthIn(max = D.toastMaxWidth) .background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = D.toastSide, vertical = D.toastTop), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph("alert", C.warn, Modifier.size(L.buttonIcon)) + Glyph("alert", C.warn, Modifier.size(L.buttonIcon)) Text(message, color = C.fg, style = WolfskinTokens.Type.body) } } @@ -91,6 +102,7 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie val displayedRows = remember(state.data) { buildList { add("header") + if (state.data.status != TitleReadStatus.Ready) return@buildList if (state.data.item.held && state.data.item.kind == "tv" && state.data.item.seasons > 0) { add("seasons") if (state.data.episodesStatus == TitleReadStatus.Loading || state.data.episodes.isNotEmpty()) add("episodes") @@ -117,7 +129,6 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie if (target != position) list.animateScrollBy(target - position, titleFrameScrollSpec) } val dim by remember(rowOffsets, list) { derivedStateOf { (position / D.dimScrollPx).coerceAtMost(D.dimMaximum) } } - val heroVisible by remember(rowOffsets, list, density) { derivedStateOf { position < with(density) { WolfskinTokens.Shell.stageHeight.toPx() } } } val lastVisible by remember(list) { derivedStateOf { list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } } val nextArtRow = remember(displayedRows, lastVisible) { if (lastVisible < 0) null else displayedRows.drop(lastVisible + 1).firstOrNull { it == "cast" || it == "episodes" || it == "related" } } TitleRowPrefetch(state.data, nextArtRow) @@ -135,10 +146,9 @@ fun TitlePage(state: TitleState, shell: ShellState, modifier: Modifier = Modifie } key != null && state.key(key, event.type == KeyEventType.KeyDown, event.nativeKeyEvent.repeatCount, shell) }.focusable()) { - if (heroVisible) { - TitleBackdrop(state.data.item, Modifier.fillMaxSize()) - Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg)) - } else Box(Modifier.fillMaxSize().background(C.bg)) + // This remains visible behind the scrolled rows; it is not an off-screen header image. + TitleBackdrop(state.data.item, Modifier.fillMaxSize()) + Box(Modifier.fillMaxSize().graphicsLayer { alpha = dim }.background(C.bg)) if (state.data.status == TitleReadStatus.Unavailable) TitleReadError(state) else LazyColumn(state = list, modifier = Modifier.fillMaxSize()) { displayedRows.forEach { id -> item(key = id, contentType = if (id == "header") "header" else if (id == "seasons") "chips" else "shelf") { @@ -193,7 +203,7 @@ private fun TitleReadError(state: TitleState) { .border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) .padding(horizontal = D.errorSide, vertical = D.errorVertical)) { Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { - TitleGlyph("alert", C.warn, Modifier.size(D.receiptIcon)) + Glyph("alert", C.warn, Modifier.size(D.receiptIcon)) Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle) } Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt similarity index 65% rename from wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt rename to wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt index 78e0439b..3edd96ca 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePrefetch.kt @@ -1,33 +1,21 @@ package org.justinvisser.wolfskin.design -import androidx.compose.runtime.compositionLocalOf -import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.platform.LocalContext -import coil3.request.ImageRequest import coil3.request.Disposable -import coil3.request.allowRgb565 -import coil3.request.crossfade -import coil3.ImageLoader - -/** Owned screens share one bounded loader; standalone fixtures can use Coil's local asset loader. */ -val LocalWolfskinImageLoader = compositionLocalOf { null } - -/** Visible and prefetched images use exactly the same size and decode policy. */ -internal fun titleImageRequest(context: Context, path: String, width: Int, height: Int, poster: Boolean): ImageRequest = - ImageRequest.Builder(context).data(path).size(width, height).allowRgb565(poster).crossfade(false).build() /** Read the next hidden art row into the bounded loader, cancelling work when the window changes. */ @Composable internal fun TitleRowPrefetch(data: TitlePageData, row: String?) { - val loader = LocalWolfskinImageLoader.current ?: return val context = LocalContext.current if (row == null || data.status != TitleReadStatus.Ready) return + if (LocalTitleBitmaps.current.isNotEmpty()) return + val loader = LibraryImages.loader(context) DisposableEffect(loader, context, row, data.cast, data.episodes, data.related) { val requests = ArrayList() fun queue(path: String?, width: Int, height: Int, poster: Boolean = false) { - if (path != null) requests.add(loader.enqueue(titleImageRequest(context, path, width, height, poster))) + if (path != null) requests.add(loader.enqueue(LibraryImages.request(context, path, width, height, poster))) } when (row) { "cast" -> for (index in 0 until minOf(data.cast.size, WolfskinTokens.Card.personSlots)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt deleted file mode 100644 index ae18cf2c..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleRating.kt +++ /dev/null @@ -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, 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, 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) - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index a4cd14c8..9d1a67cf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -103,40 +103,50 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { /** Posters draw art at rest, with facts only in the reserved focused-caption band. */ @Composable -fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { +fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current - val width = if (tile) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW - val height = if (tile) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH Column(modifier.zIndex(if (focused) 1f else 0f)) { - Box(Modifier.size(width, height).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) + if (!tile) { + val bitmaps = LocalTitleBitmaps.current + val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } + LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, + modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) + } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { - TitleCardArtwork(item, tile) - if (tile) { - Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) - .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { - Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(if (item.kind == "episode") "${item.episodeLabel} · ${item.name}" else "${if (item.kind == "movie") "Movie" else "Series"} · ${item.year ?: ""}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) - } + TitleTileArtwork(item) + Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) + .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { + Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } - val progress = if (item.runtimeSeconds > 0) item.resumeSeconds.toFloat() / item.runtimeSeconds else 0f + val progress = item.progress if (progress > 0f && progress < 1f) { - Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = if (tile) D.tileProgressBottom else D.cardProgressBottom) + Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom) .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) } } - if (item.held && !tile) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { - TitleGlyph("check", C.onFocus, Modifier.size(L.buttonIcon)) - } androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(WolfskinTokens.Motion.base)), exit = fadeOut(tween(WolfskinTokens.Motion.base))) { - TitleRatingPlate(item.ratings, Modifier.fillMaxWidth()) + LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) } } if (!tile) { + val availability = androidx.compose.runtime.remember(item, catalogue) { + if (catalogue) { + if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } + } else if (item.watched) "Watched" + else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left" + else null + } + val facts = androidx.compose.runtime.remember(item, availability) { + listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), + if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", + availability).joinToString(" · ") + } Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { - Caption(item.name, listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), if (item.kind == "movie") item.runtime.takeIf { item.runtimeSeconds > 0 } ?: "Movie" else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" }).joinToString(" · "), focused, - Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth)) + Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth), + accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank()) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt index a2bf2187..c122ec9f 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleState.kt @@ -125,7 +125,7 @@ class TitleState( } if (sheet != null) { sheetKey(key); return true } when (key) { - ShellKey.Back -> onAction(TitleAction.Pop) + ShellKey.Back -> back(shell) ShellKey.Up -> if (row == "header") { shell.focusNav(shell.currentTab) } else moveRow(-1) ShellKey.Down -> moveRow(1) ShellKey.Left -> focus(row, (column - 1).coerceAtLeast(0)) @@ -135,8 +135,14 @@ class TitleState( return true } - /** Platform Back shares the sheet-first ladder with remote Back. */ - fun back() { if (sheet != null) sheet = null else onAction(TitleAction.Pop) } + /** Sheets and shell overlays close first; a pushed title/person pops from content or nav. */ + fun back(shell: ShellState) { + when { + sheet != null -> sheet = null + shell.pickerVisible || shell.requestsVisible -> shell.key(ShellKey.Back) + else -> onAction(TitleAction.Pop) + } + } /** Pointer selection stays inside the modal's row order. */ fun clickSheet(index: Int) { sheetIndex = index; sheetKey(ShellKey.Ok) } @@ -227,7 +233,6 @@ class TitleState( } else -> sheet = null } - else -> Unit } } @@ -249,8 +254,8 @@ fun titleOptions(state: TitleState): List = buildList { val target = state.target if (data.status != TitleReadStatus.Ready) { if (data.status == TitleReadStatus.Unavailable) { - add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) - add(TitleOption("back", "Back", "left", icon = false)) + add(TitleOption("retry", "Try again", "", primary = true, icon = false)) + add(TitleOption("back", "Back", "", icon = false)) } return@buildList } @@ -271,9 +276,16 @@ fun titleOptions(state: TitleState): List = buildList { } else if (data.connection != TitleConnection.SignedIn) { add(TitleOption("retry", "Try again", "wifi", primary = true, icon = false)) } else { - add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", if (data.requestedStage == null) "plus" else "clock", primary = true, icon = false)) + add(TitleOption(if (data.requestedStage == null) "request" else "status", data.requestedStage?.let { "Requested · $it" } ?: "Request", data.requestedStage?.let(::titleStageGlyph) ?: "plus", primary = true, icon = false)) add(TitleOption("other", "For someone else", "users", icon = false)) if (data.hidden) add(TitleOption("unhide", "Show again", "undo", icon = false)) else if (data.pick) add(TitleOption("hide", "Not interested", "eyeoff", icon = false)) } } + +internal fun titleStageGlyph(stage: String): String = when (stage) { + "Ready" -> "check" + "Failed" -> "alert" + "Downloading", "Almost ready" -> "download" + else -> "clock" +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index de4f7c15..b9d03090 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -310,6 +310,9 @@ object WolfskinTokens { val chipIcon = 13.0.dp val captionWidth = 320.0.dp const val captionSeparatorOpacity = 0.4f + val captionFactsGap = 9.0.dp + val badgeIcon = 14.0.dp + val badgeBorder = 1.5.dp val plateTop = 7.0.dp val plateSide = 8.0.dp val plateBottom = 23.0.dp @@ -376,6 +379,7 @@ object WolfskinTokens { val sheetGap = 9.0.dp val sheetSubLift = -4.0.dp val sheetSubBottom = 7.0.dp + val sheetLabelTop = 4.0.dp val sheetRowSide = 13.0.dp val versionRowVertical = 10.0.dp val trackHeight = 42.0.dp @@ -384,14 +388,14 @@ object WolfskinTokens { val radioDot = 8.0.dp val radioBorder = 1.5.dp val infoGap = 7.0.dp - val infoLabelWidth = 80.0.dp + val infoLabelWidth = 100.0.dp + val infoRowGap = 3.0.dp + val infoColumnGap = 10.0.dp + val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) val gridHeight = 244.0.dp val minimumEndPadding = 40.0.dp val ratingGap = 10.0.dp val ratingMarkGap = 4.0.dp - val plateSide = 8.0.dp - val plateTop = 7.0.dp - val plateFade = 23.0.dp val initialSize = TextStyle(fontSize = 32.0.sp, fontWeight = FontWeight(800), lineHeight = 41.6.sp, letterSpacing = 0.0.em) val personInitialSize = TextStyle(fontSize = 48.0.sp, fontWeight = FontWeight(800), lineHeight = 62.400000000000006.sp, letterSpacing = 0.0.em) const val fallbackHue = 260.0f @@ -413,9 +417,6 @@ object WolfskinTokens { val chipBorder = 1.0.dp val heroSheetRight = 90.0.dp val heroSheetTop = 75.0.dp - const val mutedTomatoOpacity = 0.55f - const val ratingWordmarkAspect = 1.8f - const val ratingPlateStepSp = 0.25f val gridHeadingTop = 12.0.dp val castName = TextStyle(fontSize = 13.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.900000000000002.sp, letterSpacing = 0.0.em) val receiptSide = 15.0.dp @@ -441,6 +442,10 @@ object WolfskinTokens { val tileSheetLeft = 11.0.dp val tileSheetRadius = 5.0.dp val tileAmbientBlur = 13.0.dp + val tileAmbientInset = 15.0.dp + val tileShadowRadius = 15.0.dp + val tileShadowY = 5.0.dp + const val tileShadowOpacity = 0.5f const val tileAmbientBrightness = 0.5f const val cardFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))" const val personFallbackGradient = "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))" @@ -452,10 +457,15 @@ object WolfskinTokens { val tileOverlayBottom = 9.0.dp const val cardOverlayOpacity = 0.92f val cardProgressInset = 9.0.dp - val cardProgressBottom = 6.0.dp val tileProgressBottom = 5.0.dp - const val ratingPlateEntryEm = 0.64f - const val ratingPlateMarkEm = 0.29f + val ambientInset = 40.0.dp + const val ambientScale = 1.1f + val posterShadowRadius = 40.0.dp + val posterShadowY = 15.0.dp + const val posterShadowOpacity = 0.6f + val sheetShadowRadius = 40.0.dp + val sheetShadowX = -15.0.dp + const val sheetShadowOpacity = 0.5f } object SavedIndicator { val height = 26.0.dp diff --git a/wolfskin-design/src/main/res/drawable/title_alert.xml b/wolfskin-design/src/main/res/drawable/title_alert.xml deleted file mode 100644 index 5ab53448..00000000 --- a/wolfskin-design/src/main/res/drawable/title_alert.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_download.xml b/wolfskin-design/src/main/res/drawable/title_download.xml new file mode 100644 index 00000000..1ffcfa88 --- /dev/null +++ b/wolfskin-design/src/main/res/drawable/title_download.xml @@ -0,0 +1,3 @@ + + + diff --git a/wolfskin-design/src/main/res/drawable/title_eye.xml b/wolfskin-design/src/main/res/drawable/title_eye.xml deleted file mode 100644 index 7fb22ce8..00000000 --- a/wolfskin-design/src/main/res/drawable/title_eye.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_film.xml b/wolfskin-design/src/main/res/drawable/title_film.xml deleted file mode 100644 index 3544afeb..00000000 --- a/wolfskin-design/src/main/res/drawable/title_film.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_play.xml b/wolfskin-design/src/main/res/drawable/title_play.xml deleted file mode 100644 index 96af58e1..00000000 --- a/wolfskin-design/src/main/res/drawable/title_play.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/title_sparkle.xml b/wolfskin-design/src/main/res/drawable/title_sparkle.xml deleted file mode 100644 index aa2679ce..00000000 --- a/wolfskin-design/src/main/res/drawable/title_sparkle.xml +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/wolfskin-design/src/main/res/drawable/title_tv.xml b/wolfskin-design/src/main/res/drawable/title_tv.xml deleted file mode 100644 index 596638d1..00000000 --- a/wolfskin-design/src/main/res/drawable/title_tv.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/title_users.xml b/wolfskin-design/src/main/res/drawable/title_users.xml deleted file mode 100644 index af7f26b6..00000000 --- a/wolfskin-design/src/main/res/drawable/title_users.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/main/res/drawable/wolfskin_film.xml b/wolfskin-design/src/main/res/drawable/wolfskin_film.xml index 4961e89d..3544afeb 100644 --- a/wolfskin-design/src/main/res/drawable/wolfskin_film.xml +++ b/wolfskin-design/src/main/res/drawable/wolfskin_film.xml @@ -1 +1,4 @@ - + + + + diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index d4248ae0..eebba4c6 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -26,6 +26,7 @@ "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, "sheetSubLift": {"dp": -4, "px": -8, "source": "index.html:229"}, "sheetSubBottom": {"dp": 7, "px": 14, "source": "index.html:229"}, + "sheetLabelTop": {"dp": 4, "px": 8, "source": "index.html:230"}, "sheetRowSide": {"dp": 13, "px": 26, "source": "index.html:231"}, "versionRowVertical": {"dp": 10, "px": 20, "source": "index.html:1936"}, "trackHeight": {"dp": 42, "px": 84, "source": "index.html:1941"}, @@ -34,14 +35,14 @@ "radioDot": {"dp": 8, "px": 16, "source": "index.html:236"}, "radioBorder": {"dp": 1.5, "px": 3, "source": "index.html:234"}, "infoGap": {"dp": 7, "px": 14, "source": "index.html:1948"}, - "infoLabelWidth": {"dp": 80, "px": 160, "source": "index.html:272"}, + "infoLabelWidth": {"dp": 100, "px": 200, "source": "index.html:318"}, + "infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"}, + "infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"}, + "infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"}, "gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"}, "minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"}, "ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"}, "ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"}, - "plateSide": {"dp": 8, "px": 16, "source": "index.html:162"}, - "plateTop": {"dp": 7, "px": 14, "source": "index.html:162"}, - "plateFade": {"dp": 23, "px": 46, "source": "index.html:162"}, "initialSize": {"$type": "typography", "sp": 32, "px": 64, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:288"}, "personInitialSize": {"$type": "typography", "sp": 48, "px": 96, "weight": 800, "$value": {"fontWeight": 800}, "source": "index.html:1317"}, "fallbackHue": {"$value": 260, "source": "index.html:130"}, @@ -63,9 +64,6 @@ "chipBorder": {"dp": 1, "px": 2, "source": "index.html:188"}, "heroSheetRight": {"dp": 90, "px": 180, "source": "index.html:97"}, "heroSheetTop": {"dp": 75, "px": 150, "source": "index.html:97"}, - "mutedTomatoOpacity": {"$value": 0.55, "source": "index.html:161"}, - "ratingWordmarkAspect": {"$value": 1.8, "source": "index.html:160"}, - "ratingPlateStepSp": {"$value": 0.25, "source": "index.html:802"}, "gridHeadingTop": {"dp": 12, "px": 24, "source": "index.html:1312"}, "castName": {"$type": "typography", "sp": 13, "px": 26, "weight": 700, "$value": {"fontWeight": 700}, "source": "index.html:290"}, "receiptSide": {"dp": 15, "px": 30, "source": "index.html:243"}, @@ -91,6 +89,10 @@ "tileSheetLeft": {"dp": 11, "px": 22, "source": "index.html:128"}, "tileSheetRadius": {"dp": 5, "px": 10, "source": "index.html:128"}, "tileAmbientBlur": {"dp": 13, "px": 26, "source": "index.html:129"}, + "tileAmbientInset": {"dp": 15, "px": 30, "source": "index.html:129"}, + "tileShadowRadius": {"dp": 15, "px": 30, "source": "index.html:128"}, + "tileShadowY": {"dp": 5, "px": 10, "source": "index.html:128"}, + "tileShadowOpacity": {"$value": 0.5, "source": "index.html:128"}, "tileAmbientBrightness": {"$value": 0.5, "source": "index.html:129"}, "cardFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%))", "source": "index.html:130"}, "personFallbackGradient": {"$type": "gradient", "$value": "linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%))", "source": "index.html:288"}, @@ -102,10 +104,15 @@ "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, - "cardProgressBottom": {"dp": 6, "px": 12, "source": "index.html:138"}, "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, - "ratingPlateEntryEm": {"$value": 0.64, "source": "index.html:164"}, - "ratingPlateMarkEm": {"$value": 0.29, "source": "index.html:165"} + "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, + "ambientScale": {"$value": 1.1, "source": "index.html:96"}, + "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, + "posterShadowY": {"dp": 15, "px": 30, "source": "index.html:97"}, + "posterShadowOpacity": {"$value": 0.6, "source": "index.html:97"}, + "sheetShadowRadius": {"dp": 40, "px": 80, "source": "index.html:227"}, + "sheetShadowX": {"dp": -15, "px": -30, "source": "index.html:227"}, + "sheetShadowOpacity": {"$value": 0.5, "source": "index.html:227"} }, "savedIndicator": { "height": {"dp": 26, "px": 52, "source": "index.html:87"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index d65f39be..e5ce8e97 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -182,6 +182,9 @@ --library-chipTop: 16.0px; --library-chipIcon: 26.0px; --library-captionWidth: 640.0px; + --library-captionFactsGap: 18.0px; + --library-badgeIcon: 28.0px; + --library-badgeBorder: 3.0px; --library-plateTop: 14.0px; --library-plateSide: 16.0px; --library-plateBottom: 46.0px; @@ -230,6 +233,7 @@ --title-sheetGap: 18.0px; --title-sheetSubLift: -8.0px; --title-sheetSubBottom: 14.0px; + --title-sheetLabelTop: 8.0px; --title-sheetRowSide: 26.0px; --title-versionRowVertical: 20.0px; --title-trackHeight: 84.0px; @@ -238,14 +242,14 @@ --title-radioDot: 16.0px; --title-radioBorder: 3.0px; --title-infoGap: 14.0px; - --title-infoLabelWidth: 160.0px; + --title-infoLabelWidth: 200.0px; + --title-infoRowGap: 6.0px; + --title-infoColumnGap: 20.0px; + --title-infoKey: 24.0px; --title-gridHeight: 488.0px; --title-minimumEndPadding: 80.0px; --title-ratingGap: 20.0px; --title-ratingMarkGap: 8.0px; - --title-plateSide: 16.0px; - --title-plateTop: 14.0px; - --title-plateFade: 46.0px; --title-initialSize: 64.0px; --title-personInitialSize: 96.0px; --title-skeletonKickerWidth: 420.0px; @@ -289,6 +293,9 @@ --title-tileSheetLeft: 22.0px; --title-tileSheetRadius: 10.0px; --title-tileAmbientBlur: 26.0px; + --title-tileAmbientInset: 30.0px; + --title-tileShadowRadius: 30.0px; + --title-tileShadowY: 10.0px; --title-cardFallbackGradient: linear-gradient(160deg,hsl(var(--h) 22% 22%),hsl(var(--h) 26% 10%)); --title-personFallbackGradient: linear-gradient(160deg,hsl(var(--h) 24% 30%),hsl(var(--h) 26% 14%)); --title-initialsColor: #ffffffd1; @@ -298,8 +305,12 @@ --title-tileOverlaySide: 22.0px; --title-tileOverlayBottom: 18.0px; --title-cardProgressInset: 18.0px; - --title-cardProgressBottom: 12.0px; --title-tileProgressBottom: 10.0px; + --title-ambientInset: 80.0px; + --title-posterShadowRadius: 80.0px; + --title-posterShadowY: 30.0px; + --title-sheetShadowRadius: 80.0px; + --title-sheetShadowX: -30.0px; --savedIndicator-height: 52.0px; --savedIndicator-side: 20.0px; --savedIndicator-gap: 10.0px; diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 4add1740..9dbe745c 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ 811d6397d71d7771cad62e6745e8d85acd22a60878a4d2719792831de58d2fdb generate_tokens.py -1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json -384416f878c9c91b718fc9f1623de53aeb8fcde4440d99c1035ce58043bad0dd src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -10e4d911a8b6b582eab835f0631ee83c822bbd93b35480801616df8ab2679139 title-tokens.json -b8e05d5275a32548a5cf8776955ce6df72a9a5f7be110bdf0e15ea79cb0d7123 tokens.css +b36140ec4745837b9e912deb6eba5d9ce94871a18512d7fdaddb37b7dd79ce25 shell-tokens.json +c56109dc854d03d74baf0384e09451461ad50ca266b1cc4d598b310475927d11 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +bc82f477611669fbc6dd0d3e6559b1e05b0ad280531a883102803ffb0ebcdecb title-tokens.json +ae9ca44fc6e1d3bd4ecce133ba56f2d1bce267f1754b30028a6cc0124539a6f7 tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json